The page and the service worker were strict TypeScript; the tests, the build, the home page's generator, the load tests, boot.js and the directory's site.js were plain JavaScript that nothing checked. All of it is TypeScript now, in five programs, each with its own globals, all checked by npm run typecheck: the page (tsconfig.json, with boot.ts and the directory's site.ts), the worker, Node's (tsconfig.tools.json: the Node tests, the browser suite, the build, the site generator, the load harness), and k6's (tsconfig.k6.json, with @types/k6). Node 24 runs the .ts files by stripping their types, so they hold only TypeScript that erases (erasableSyntaxOnly) and import each other by .ts name; package.json says "type": "module" for it, and Dockerfile.web moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types itself. The browser suite's page.evaluate calls reach the page's globals; the page's tsc run now also writes its declarations to target/page-types, which the suite's program includes, so those calls are typed by the page's own code rather than by hand-written copies. boot.ts is compiled by build.mts with swc, as the worker is, and named by the hash of what is served. The directory's build has no Node, so site.js is compiled from site.ts and committed (node web/build.mts --directory-script), and tests/directory-script.ts fails if they drift. Checked: the site generator's 139 pages are byte for byte what the JavaScript made; the page build's app.js is identical and only boot.js (now minified) and its hash differ; contrast, page-smoke, page-unit, native-bridge, the browser suite (96 passed, 2 skipped) and the directory's and ipx's Rust tests pass; k6 parses each load script. The load tests themselves did not run: they build a release binary outside the capped builder. .agents/skills/security-audit is an installed skill's, not ours, and is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
17 KiB
Markdown
229 lines
17 KiB
Markdown
# How it works
|
|
|
|
Three services, each a container of its own with its own version and changelog (#207):
|
|
|
|
| Service | What it is | Source | Image |
|
|
|---|---|---|---|
|
|
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
|
|
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
|
|
| ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` |
|
|
|
|
The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds
|
|
for both Rust services. Versions: ipx's in `crates/ipx/Cargo.toml`, the directory's in
|
|
`crates/directory/Cargo.toml`, the page's in `package.json`; CLAUDE.md has the release steps.
|
|
|
|
## Traces and logs
|
|
|
|
A request is one trace (#213). The page starts one for each tap and sends `traceparent` with its
|
|
requests (#190); Caddy's span continues it and passes it on to ipx; ipx's request span, its steps
|
|
and its calls to the directory follow, and the directory's request span joins it. Scans,
|
|
downloads, DeadAir's work, shares with the directory and the directory's refresher rounds are
|
|
traces of their own. All three log one JSON object a line, for Loki; ipx's lines carry their trace
|
|
id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] metrics_bind`,
|
|
#194). The exporter and the `traceparent` handling both Rust services use are in
|
|
`crates/directory/src/otel.rs`.
|
|
|
|
## Modules
|
|
|
|
| File | Responsibility | What it replaced in the Python |
|
|
|---|---|---|
|
|
| `crates/ipx/src/main.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
|
|
| `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` |
|
|
| `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — |
|
|
| `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` |
|
|
| `crates/ipx/src/scan.rs` | When a feed is due, reading it, download policy, downloads and torrents | `iPXAgent.py` |
|
|
| `crates/ipx/src/catalogue.rs` | Adding, removing and leaving feeds; subscriptions; retiring a list's feeds | — |
|
|
| `crates/ipx/src/lists.rs` | OPML import, and keeping an OPML's or a Patreon creator's feeds in step | — |
|
|
| `crates/ipx/src/logging.rs` | Logging setup, and JSON lines carrying their trace | — |
|
|
| `crates/ipx/src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
|
| `crates/ipx/src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
|
| `crates/ipx/src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
|
| `crates/feed/src/lib.rs` | ipx-feed's modules and what it exports | — |
|
|
| `crates/feed/src/fetch.rs` | Conditional GET, redirects followed one by one, bounded bodies | `FeedData.__getFeed` |
|
|
| `crates/feed/src/parse.rs` | RSS and Atom into `ParsedFeed`: titles, notes, enclosures, artwork, dates | `FeedData.__getEntries` |
|
|
| `crates/feed/src/outlines.rs` | OPML: recognising, reading safely, listing its feeds | — |
|
|
| `crates/feed/src/patreon.rs` | A Patreon creator's shows | — |
|
|
| `crates/feed/src/discover.rs` | Finding a feed from an address, a site or a page | — |
|
|
| `crates/feed/src/wordpress.rs` | WordPress.com's API for a site behind a browser check | — |
|
|
| `crates/feed/src/failure.rs` | A failed read in words, and its kind for the log | — |
|
|
| `crates/feed/src/privacy.rs` | `redact`, and `url_looks_private` | — |
|
|
| `crates/feed/src/artwork.rs` | Artwork on https, and site icons | — |
|
|
| `crates/feed/src/category.rs` | Apple's categories | — |
|
|
| `crates/feed/src/jev.rs` | Jev's pick of a category | — |
|
|
| `crates/feed/src/guard.rs` | The connection guard against internal addresses (#320) | — |
|
|
| `crates/ipx/src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
|
| `crates/ipx/src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
|
| `crates/ipx/src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
|
|
| `crates/ipx/src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
|
|
| `crates/ipx/src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
|
| `crates/ipx/src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
|
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
|
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
|
|
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
|
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
|
|
| `crates/directory/src/lib.rs` | The directory's modules and what it exports | — |
|
|
| `crates/directory/src/api.rs` | The directory's HTTP API and its token check | — |
|
|
| `crates/directory/src/directory.rs` | `Directory`: its store and tokens, the refresher, the config reloader | — |
|
|
| `crates/directory/src/wire.rs` | What servers and the directory send each other | — |
|
|
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
|
|
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
|
|
| `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — |
|
|
| `web/index.html` | The app's markup | — |
|
|
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
|
| `web/app.css` | The stylesheet both pages share | — |
|
|
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
|
| `web/build.mts` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
|
|
| `web/boot.ts` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
|
|
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
|
|
|
|
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
|
ipx-web rebuilt**, not ipx, and building it needs node and `npm ci` run once. ipx serves the API
|
|
and the files, and no page.
|
|
|
|
## A scan
|
|
|
|
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
|
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
|
|
a twelfth of how long it has been the same, up to four hours (#242).
|
|
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
|
|
body identical to the last one (`body_hash`), from a server that ignores the validators.
|
|
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
|
|
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
|
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
|
shows come from Patreon's web API and each becomes a derived feed.
|
|
4. Record entries: the new ones, and those the feed now gives a different title, text, artwork,
|
|
length or number, compared with what is stored for the items it lists (#141). A corrected item
|
|
keeps its read state.
|
|
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
|
|
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
|
|
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
|
|
a reason. What a filter skipped is judged again every scan, so a change of settings brings it
|
|
back. A feed in a group takes your settings on the group for anything you have not set on it.
|
|
7. Download what is still pending, newest first, up to the per-scan cap. A `.torrent` body goes to
|
|
the torrent path whatever its advertised type; an HTML body is a failed download — a login wall
|
|
or an error page — and is deleted.
|
|
|
|
## Data model
|
|
|
|
```
|
|
feeds id, url, title, image, category, description, etag, last_modified,
|
|
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
|
|
body_hash, same_since
|
|
entries feed_id, guid, title, link, published, description, first_seen,
|
|
image, duration, episode, season PK (feed_id, guid)
|
|
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
|
bytes_done, downloaded_at, last_error
|
|
users id, name, pass_hash, is_admin, created, last_login
|
|
sessions token, user_id, seen
|
|
subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
|
max_new_per_check PK (user_id, feed_id)
|
|
entry_state user_id, feed_id, guid, read, flagged, position
|
|
PK (user_id, feed_id, guid)
|
|
signals id, user_id, name, keywords
|
|
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
|
|
```
|
|
|
|
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
|
|
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
|
|
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
|
|
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
|
|
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
|
|
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
|
|
|
|
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
|
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
|
|
|
Schema changes: the tables are the entities in `crates/ipx/src/entity.rs`, and `Db::open` creates whatever
|
|
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
|
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
|
`create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is,
|
|
or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
|
database the same way, so the tests run on the schema production gets. A database from before
|
|
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
|
|
|
## Control socket
|
|
|
|
Newline-delimited JSON, both directions, over `$XDG_RUNTIME_DIR/ipx.sock`.
|
|
|
|
```sh
|
|
printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
|
|
{"ev":"feed_start","feed":"atp"}
|
|
{"ev":"progress","feed":"atp","enclosure":42,"file":"ep1.mp3","done":8192,"total":3000000}
|
|
{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
|
|
{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
|
|
{"ev":"scan_done","feeds":1}
|
|
```
|
|
|
|
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
|
|
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
|
|
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
|
|
`status`).
|
|
|
|
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
|
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
|
|
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
|
|
there. Commands run one at a time, in the order they arrive, except `status` and `reload`: the
|
|
socket answers them straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
|
|
answers only the client that asked, since `status` would end any other client's session.
|
|
|
|
Progress carries the enclosure id, without which a UI cannot tell one download from another and
|
|
ends up animating every pending row. It is throttled to whole percents. The stream is a broadcast,
|
|
so a client attached to a busy daemon also sees that daemon's other work.
|
|
|
|
Inside the process the same events go over a `tokio::broadcast`; commands arrive on an `mpsc` and
|
|
are handled by a single worker, so nothing races over the same download. Shutdown is a `watch`
|
|
channel raced *inside* each job — `tokio::select!` only races branches at the point of selection,
|
|
so a long download had to be able to notice the signal itself.
|
|
|
|
## HTTP API
|
|
|
|
Everything below `/api` needs a signed-in user; the browser gets a redirect to `/login`, anything
|
|
else a `401`. A feed's items and files (its entries, `download-latest`, `/api/enclosures/{id}` and
|
|
`/media/{id}`) answer only someone who subscribes to the feed; anyone else gets a `404`.
|
|
|
|
| Route | |
|
|
|---|---|
|
|
| `GET /api/page-check`, `GET /api/login-check`, `GET /api/signin` | for Caddy (#203): whether the visitor may have the page in `X-Forwarded-Uri` (`204`), or where to send them; signing in with `?token=` |
|
|
| `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in; `/api/me` also gives the version and the account's theme from before |
|
|
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
|
|
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
|
|
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
|
| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) |
|
|
| `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
|
|
| `POST /api/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
|
|
| `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, kept, position |
|
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
|
| `POST /api/fetch` | |
|
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
|
| `GET /api/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address |
|
|
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up |
|
|
| `DELETE /api/directory/{id}`, `POST /api/directory/{id}/refresh` | admin-only, and the directory refuses unless this server's token is `rwa` (#206): remove and block a feed; check it now. `GET /api/directory/{id}` says `can_admin` when both hold |
|
|
| `GET /api/directory-admin`, `DELETE /api/directory-blocked/{id}`, `DELETE /api/directory-instances/{name}` | admin-only, `rwa` (#206): the servers sharing (name, feeds, subscribers) and the blocked feeds (id, title, when; never the address, often why it was blocked); unblock; drop a server's snapshot. Unblocking or dropping sends this server's snapshot again at once |
|
|
| `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went |
|
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one |
|
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
|
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
|
| `GET /media/{id}` | the file, with Range support so seeking works |
|
|
|
|
Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side
|
|
before they reach the page.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
|
node tests/page-smoke.ts # the page script loads and every selector it wires at load exists
|
|
node tests/native-bridge.ts # the page hands playback to a native shell
|
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
|
node tests/load/run.ts # k6: many people at once against a daemon with 1,500 generated feeds
|
|
```
|
|
|
|
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
|
|
renders empty — which is what has actually reached users. Each Playwright case maps to a bug that
|
|
did.
|
|
|
|
The suite starts its own daemon and database under `/tmp/ipx-ui-test`, wiped once per run. Tests
|
|
share that daemon and run in order, so a test that marks something read changes what later tests
|
|
see — make assertions that do not depend on earlier ones.
|