Phase 2: web front end
axum served from inside the daemon so it reads SQLite and the event bus directly: browse feeds, read show notes, play with seeking, download and delete files, mark read/flag, and edit feed settings. Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI edits apply without a daemon restart. Access is a shared token minted from /dev/urandom, carried in a cookie because an <audio> element cannot send headers. Show notes are untrusted feed HTML and are sanitized with ammonia server-side. read/flagged finally have a writer, which retention has needed since it started ordering by them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
75
PROGRESS.md
75
PROGRESS.md
@@ -17,6 +17,32 @@ The full design and step list live in the plan file at
|
||||
see the step 7 entry)
|
||||
- [x] **8. OPML + polish** — import/export, add/rm/status, tracing setup, systemd units, README.
|
||||
|
||||
### Phase 2 — web front end
|
||||
|
||||
Decided with Ray: axum serving plain HTML/JS (no WASM toolchain), running **inside the daemon**
|
||||
process so it reads SQLite and the event bus directly, LAN-bindable with a shared token.
|
||||
|
||||
- [x] **9. Config hot-reload + web skeleton.** `Ctx.cfg` becomes `RwLock<Arc<Config>>` so the UI can
|
||||
edit feeds without a daemon restart. `[web]` config section (enabled/bind/token, token
|
||||
auto-generated and saved on first run). axum server started by `ipx daemon`, token checked by
|
||||
middleware, `?token=` sets a cookie so `<audio>` requests authenticate too.
|
||||
*Done when:* `ipx daemon` serves a page on the configured bind, and a wrong token gets 401.
|
||||
- [x] **10. Browsing.** `/api/feeds`, `/api/feeds/:id/entries`, entry detail. Descriptions are
|
||||
untrusted feed HTML — sanitized with `ammonia` before they reach the page.
|
||||
*Done when:* the Glass Cannon feed's 131 entries browse and read correctly.
|
||||
- [x] **11. Media actions.** Range-request audio streaming (`tower-http` ServeFile) so seeking
|
||||
works, download-on-demand for a pending enclosure, delete a file, mark read/flagged.
|
||||
*Done when:* an episode plays and seeks in a browser, and delete reaps the row.
|
||||
- [x] **12. Feed configuration.** Add/remove feeds and edit folder, keywords, allow_explicit,
|
||||
auto_download, max_new_per_check from the UI, written back to config.toml and hot-reloaded.
|
||||
*Done when:* flipping allow_explicit in the UI takes effect on the next scan with no restart.
|
||||
- [x] **13. Live progress + polish.** SSE from the existing broadcast bus so downloads show live.
|
||||
README section, screenshot-free usage notes.
|
||||
*Done when:* starting a fetch from the UI shows progress advancing without a reload.
|
||||
|
||||
Note: `read`/`flagged` finally get a writer here. Retention orders by them (see the step 5 entry),
|
||||
and until now nothing set them.
|
||||
|
||||
## Smoke tests
|
||||
|
||||
1. `ipx add <feed>` + `ipx fetch` → file in `download_dir/<Show>/`, row in `enclosures`.
|
||||
@@ -30,6 +56,55 @@ The full design and step list live in the plan file at
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 — Phase 2: web front end (steps 9-13)
|
||||
|
||||
axum in the daemon process, plain HTML/JS in `web/index.html` (embedded with `include_str!`), no
|
||||
WASM toolchain. One binary still.
|
||||
|
||||
**Config hot-reload.** `Ctx.cfg` is now `RwLock<Arc<Config>>`; `ctx.cfg()` hands out a snapshot, so
|
||||
no guard is ever held across an await. The UI rewrites config.toml and calls `reload_cfg`, and a
|
||||
running daemon picks the change up on its next scan. The CLI mutation commands (`add`/`rm`/`import`)
|
||||
now clone a snapshot, edit, and save.
|
||||
|
||||
**Auth.** `[web] enabled/bind/token`; the token is minted from `/dev/urandom` on first run, written
|
||||
back to config.toml, and the URL printed. `?token=` sets a year-long cookie — it has to be a cookie
|
||||
because an `<audio>` element cannot send a header. Comparison is constant-time. An empty token makes
|
||||
the server refuse to serve rather than serve open.
|
||||
|
||||
**Endpoints.** `/api/feeds` (GET/POST), `/api/feeds/{id}` (PATCH/DELETE),
|
||||
`/api/feeds/{id}/entries`, `/api/entries/{feed}/{guid}/flags`, `/api/enclosures/{id}/download`,
|
||||
`/api/enclosures/{id}` (DELETE), `/api/fetch`, `/api/events` (SSE off the existing broadcast bus),
|
||||
`/media/{id}` (tower-http `ServeFile`, so Range works).
|
||||
|
||||
**`read`/`flagged` finally have a writer** — the UI sets them, and playing an episode marks it read.
|
||||
Retention has ordered by these since step 5 with nothing to set them.
|
||||
|
||||
**Download-on-demand needed no new machinery**: requeue the row to `pending` and kick a scan, since
|
||||
the queue is the table. That also un-skips an enclosure a filter rejected under older settings.
|
||||
|
||||
Verified against the live Glass Cannon feed, daemon on 127.0.0.1:8749:
|
||||
|
||||
- auth: no token 401, wrong token 401, right token 200 + cookie, API then works on the cookie alone;
|
||||
`/media/1` unauthenticated is 401.
|
||||
- browsing: 131 entries page with enclosures attached.
|
||||
- **XSS**: injected `<script>alert(1)</script><img src=x onerror=alert(2)>` into a stored
|
||||
description; it reaches the page as `<p>hi</p><img src="x">` — script tag and handler both gone.
|
||||
- media: 200 with `accept-ranges: bytes`; `Range: bytes=1000000-1000999` returns 206 with the right
|
||||
`content-range`, so seeking works.
|
||||
- config: PATCH -> 204, written to config.toml, and the running daemon reports the new values with
|
||||
no restart.
|
||||
- SSE: 105 events for one download (101 progress), then `download_done`/`feed_done`/`scan_done`.
|
||||
- flags: 204, unread count dropped 131 -> 130. Delete: file gone, row `reaped`/`path=NULL`.
|
||||
|
||||
`cargo test` 30/30.
|
||||
|
||||
**Caveats.** It is plain HTTP — on a LAN bind the token crosses the network in the clear, and a feed
|
||||
URL may itself carry a credential (Patreon's does), which the `/api/feeds` response includes. A
|
||||
reverse proxy with TLS is the answer if that matters. There is no per-user anything; the token is
|
||||
all-or-nothing access.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 — Tested against a real feed (Patreon / Glass Cannon)
|
||||
|
||||
First run against a live subscriber feed: 131 items, 6.17 GB total, all `audio/mpeg`, no `<ttl>`.
|
||||
|
||||
Reference in New Issue
Block a user