- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
and PATCH /api/me saves them, refusing anything but a plain name and
light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
quick run through the list could land out of order and keep a theme passed on
the way. The browser test caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeffgeerling.com answers 403 to an image request whose Referer is another
site, so his posts showed a broken image on iOS and the alt text on desktop.
The sanitiser now gives every <img> referrerpolicy="no-referrer".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Pull the item list down from its top: checks the feed (or every feed, on All
Subscriptions) for new items, which arrive as they do from the scan button.
overscroll-behavior keeps the browser's own pull-to-reload out of it.
- Swipe the item you are reading left for the next, right for the one before,
or back to the list from the first. A vertical move is a scroll; something
that scrolls sideways, or takes typing, keeps its own swipe.
Touch events only, so a mouse never sets them off.
Closes#22.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
/favicon.ico outside the auth layer, where a browser asking on its own got a
401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
file icon, lifting the icon above its row's; the bar now sits under it without
taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
triangle is, in the same column; a folder holding a failing feed has its
triangle turn red.
Closes#31, #32.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.
Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The theme picker lists Modern (the old Dark and Light), Classic and six new
palettes, from Dracula's spec (with Alucard), Material 3's baseline scheme,
libadwaita's CSS variables, Flat Remix's _colors.scss, Paper and Nord. A second
setting picks Light, Dark or Auto where a theme has both; Classic and Paper do
not, so it is hidden for them.
The page gets data-mode, light or dark, and Auto is worked out in theme.ts from
the system, so each palette is written once instead of again under a media
query. Every new palette clears WCAG AA for text on its backgrounds. An old
ipx.theme of dark, light or auto carries over as Modern.
Closes#27.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- web/src/*.ts: the script that was inline in index.html and login.html, split along its
existing sections. Still one scope, concatenated in order, not modules.
- web/build.mjs strips the types, puts the script in the page and minifies it with swc;
build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB.
- npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed
change no behaviour.
- The Docker build installs node and swc (npm ci --omit=dev).
- Two list requests racing no longer let the older one win, and switching tabs clears
the selection it closes, which made a browser test flaky.
Closes#23, #24.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The Add a feed dialog no longer lists Popular; the sidebar has it (#30).
- On a phone the files, with play and delete, come before the show notes. Below
them, long notes buried the delete button and it looked missing on iOS (#21).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Opening an item stays read: a list refresh that crossed with the write no longer
puts the unread dot back (#16).
- On the Unread tab the item you were reading goes when you move to the next (#17).
- Feed errors mark the feed with a red ! instead of a toast per failure (#20).
- The theme is chosen in Settings only (#15).
- The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29).
- Relative images and links in a post resolve against the post's link (#28).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file,
which read 0:08 left with 2:33 to play. The player's length is kept in
entry_state beside the position, per listener, where no scan can put
the feed's figure back, and preferred to the feed's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tab left paused further into an episode saved its older place as it
reloaded, over where the listener had got to since, and the episode
dropped out of Currently Listening. A jump back is now saved at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row in the player gets the amber EQ bars, as the item list's does,
and its progress rail and time left move as it plays. Rows say how
much is left, and their buttons are quiet so that row stands out.
savePos no longer saves before the file has loaded: currentTime is 0
then, and a failed load or an early pause wiped the saved position.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It forgets the saved position, which is what puts an episode on the
list. The one in the player is closed without saving first, or its
next save would put it straight back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening an episode marks it read, so filtering on read hid every
episode anyone had started. The player now also reports the length it
measured, filling in one the feed left out. Fixes#14.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inter (#11): the pages are set in Inter's variable font, served from the
binary at /inter.woff2 as the icon is, with its OFL licence beside it in
web/. Classic keeps Lucida Grande, the 2004 app's face.
Double audio (#12): WordPress numbers each audio player on a page by
adding ?_=N to its file's URL, so a post that embeds the file it encloses
listed it twice, and it was downloaded twice. The parser keeps the first
of an item's enclosures that differ only by that number. At startup the
repeats already stored fold into the first; where only the repeat had
been downloaded its file moves to the first rather than being deleted.
Pin heading (#13): the rows' icon buttons kept the browser's side
padding, which pushed their 16px icon 3px right of centre, and the
heading's icon sat at the left of its column. Both are centred now, and
the heading row takes the pixel of border the rows have, so every
heading sits over its column.
Closes#11, closes#12, closes#13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Almost no blog names a category the Directory can use, so a feed can
carry one of its own in config.toml, set by an admin in the feed's
settings and used when the feed names none. The feed's own iTunes
category still wins. The field offers the categories the Directory
already shows, so a blog about games joins Games rather than starting a
second chip. Setting it on a feed from an OPML promotes it to config, as
any other shared setting does.
Closes#10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeping an item is pinning it now: a thumbtack where the flag was, and
Pin, Pinned and Unpin where Keep, Kept and Stop keeping were, on the
toolbar, the item's own buttons, the filter tab, the table column, the
retention hint and the warning before deleting a shared file. Pinned is
the solid thumbtack and not pinned the same shape outlined, as the flag
had its regular and solid pair. The API and database keep `flagged`.
The icon test compared glyphs by their path alone, which the two pins
share; it compares the whole glyph now.
Closes#9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
j/n and k/p step through the items, Shift-J and Shift-K through the feed
list, and g with a letter goes to a place: All Subscriptions, Directory,
Popular, Currently Listening, Settings. o plays the selected item, m marks
it read or unread and s keeps it, each by pressing the toolbar's own
button; v opens the original, Shift-A marks all read, r refreshes, [ hides
the feed list, and ? lists them all. None fire while typing, with a
modifier held, or with a dialog open.
Closes#8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
davewiner's OPML left config.toml before retire_group existed, so its
derived rows were skipped by every scan but never cleared. At startup the
daemon now retires every group whose parent is gone from config. And
retire_group unmanages a feed that has its own config entry instead of
dropping it: eleven of davewiner's were promoted without being unmanaged,
and dropping them as derived would have deleted their entries. That also
covers removing an OPML or Patreon subscription from the page.
Closes#3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was a section at the bottom of the Popular page, so finding the
episode you were halfway through meant opening a list of feeds first.
It is a place in the feed list now, between Popular and All
Subscriptions, with its own page and count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Atom title of type="html", and an RSS title in CDATA, reach the parser
with their entities intact, so The Verge's "Meta’s" showed as typed:
55 stored titles across 17 feeds. Titles are decoded one entity at a time
with quick-xml's HTML5 table, leaving an & that starts none ("Q&A") alone
rather than failing the whole title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What a feed is and what it is about are two questions, so they are two
controls that combine: the same .tabs the item filters use for All,
Podcasts and Blogs, and a row of category chips that is always there,
offering only the categories among the feeds the filter lets through. A
picked chip lifts on a second press, so there is no second All.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple puts every tabletop and gaming show under Leisure, so the top level
alone put most of this server's podcasts behind one chip. Games says what
they are; a show with no subcategory keeps its top-level one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Feeds take their channel's first <itunes:category> into a new feeds.category
column; the migration drops ETag and Last-Modified once so every feed re-reads
on its normal schedule and picks one up. /api/popular and /api/directory carry
category and podcast (any audio or video enclosure). Directory becomes a grid of
cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's
categories once Podcasts is picked. Popular and Add a feed keep their rows.
Closes#4, closes#5, closes#6, closes#7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doghouse answers 200 with "Unable to establish a DB connection", and parse()
reported two parser errors about end of input that buried it. A body that does
not start with < now reports its first line, and explain_failure flags it as
the publisher's problem. Malformed XML keeps the parsers' errors.
Fixes#2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZbKERNSt4vQfyGV8rvkqp
Vendored from cloudflare/security-audit-skill under .agents/skills,
pinned in skills-lock.json and linked into .claude/skills. Also adds
the project's shared permission allow-rules in .claude/settings.json.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
TODO.md's open items are now issues #1-#7 on git.sdf1.net, with
blocked-by links between the Directory ones. CLAUDE.md says where
to find them so a later session doesn't recreate the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
- Remember the feed/place and tab across a reload or new visit; an
unknown or unsubscribed one lands on All Subscriptions instead of the
first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
header button's toggle.
- Add Currently Listening below Popular: episodes started and not
finished, across every subscribed feed, one tap to resume. Reuses the
existing entries/filter machinery (Filter::InProgress) rather than a
new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
until a hard refresh (100vh -> 100dvh); unverified on a real device.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
- Stop scanning an OPML/Patreon feed's derived rows once nobody subscribes
to it; retire them (drop or orphan) the way sync_group already does when
the list itself drops one. This is what let 922 defunct davewiner feeds
keep scanning hourly after the OPML left config.
- Repair feed XML with a bare `&`, and give a plain reason (moved web page
with its new address when linked, or nothing yet for an empty body)
instead of a raw parser error.
- Show a failing feed's plain-English reason and next step (Unsubscribe /
Use the new address) in the sidebar and on its own page, once it has
been down a day.
- Fix four small UI bugs: show-note links open in a new tab, video files
play as video, an opened item no longer disappears from the Unread tab,
and Subscribe/Unsubscribe get their own icons.
- Fix Settings disappearing for non-admin accounts: it was hiding the
whole modal instead of just the admin-only parts (Users, the editable
schedule/quota, Save), which are the only parts the server actually
refuses them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
status is a terminal event. Broadcast, the healthcheck's answer ended any
ipx fetch that was watching a scan, which stopped reading at the next probe
while the scan carried on. It could not happen while status waited behind
the scan; answering it at once made it happen every 30 seconds. Each
connection's writer now takes private replies beside the broadcast, and
the test checks another client hears nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
The worker runs one job at a time, and status was one of its jobs, so the
Docker healthcheck waited behind the startup scan (54 seconds of it after
the last deploy) and timed out at 5. Any scan or download longer than
three probes would have had a working daemon marked unhealthy. The socket
now answers status straight away; everything else still queues. A test
fills the queue and checks status comes back anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Sign out cleared ipx's cookies and showed its password page, while
Cloudflare Access still vouched for the person: nothing was signed out,
and the page looked like the wrong login. /api/me now says, for someone
the proxy signed in, where to go instead ([web] sign_out_url, which is
/cdn-cgi/access/logout behind Access), and /login sends anyone the proxy
vouches for on to their feeds. The header check both use is one function.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Authentik is Cloudflare Access's OpenID Connect identity provider, not
something in the request path, and the tunnel's requests reach ipx from
the content_default gateway, 192.168.16.1, not 127.0.0.1. The page is
rewritten from what was measured, with checks for both the trusted and
the refused path, and docs/history.md records every change made to get
there with how to undo it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
An account made by hand before the proxy was set up is called what it was
given ('rays'), while Cloudflare Access vouches for an email address. With
auto_create_users on, the first visit through the tunnel would make a
second, empty account. Renaming keeps the id, so feeds, read state and
admin rights go with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
users.created comes back, beside a new last_login, for whoever maintains
the server. A password sign-in, the token link and a request through the
proxy all count, recorded to the hour so the proxy's per-request vouching
is not a write each time. Settings -> Users and ipx user list show both.
The three user queries now share one row mapping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
libsyn served Daily Meditation Podcast's content:encoded cut at the '>'
inside a Tailwind class pasted from a web app, so 57 items began halfway
through a tag and the page showed the rest of it as text. Their
description was whole. A body that closes an attribute list before any
tag opens now falls back to the description, for RSS and Atom alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Drops the created columns on users, subscriptions and sessions, which were
written by every insert and read by nothing, and migrate()'s add list, whose
columns all predate 0.3.0. Removes Db::subscribed_feed_ids (no callers),
Db::subscriber_count (one caller wanting > 0) and Managed.orphaned (never
read). The old-database test now builds the tables with foreign keys on.
Popular now lists the feeds inside an OPML or a Patreon creator, never the
collection, as Directory does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
Popular still counts an OPML as one feed, since everyone subscribed to it
counts for every feed inside and they would bury the rest. The directory is
for finding a show, so it lists them one by one and never the OPML. A feed
inside an OPML that looks private is hidden with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk