387 Commits

Author SHA1 Message Date
0be8eb5cdf ipx 1.5.0, ipx-directory 1.4.0 and ipx-web 1.3.0
The security audit's 26 findings, three dependency advisories and the
performance issues found in traces (#318-#352), each already deployed
as a -dev build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:41:57 +00:00
e1291c1627 Count unread feed by feed (#352)
After #347 an unread count read only the items since each feed's
unread_from, but Postgres guessed a range join across every feed
keeps a third of its rows (17,000 where there are 200), so it hashed
all 22,949 of the person's entry_state rows. Counted in a subquery per
subscription, the feed and its unread_from are constants there, and
it looks up the few items since and their read state by key: on
production the feed list's unread counts went from 19.7 to 14.8 ms and
All Subscriptions' unread count from 19 to 13.

The feed list's unread counts and count_of for Unread without a search
(All Subscriptions, a feed, an OPML's shows) use it; a page of unread
items still joins, since it needs rows in order, not a count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:14:36 +00:00
a11e3b6367 Unread lists start from each person's oldest unread item (#347)
All Subscriptions' Unread page, its count and the feed list's unread
counts read every item of the person's feeds and hashed all their
entry_state rows to find the few unread: 11,727 items and 22,949 rows
for 44, 27-32 ms on production, growing with the back catalogue. A
NOT EXISTS rewrite of the same scan was no faster.

subscriptions.unread_from says every item of the feed first seen
before it is read by this person. Unread queries join the subscription
on e.first_seen >= coalesce(unread_from, 0), and the new index
entries_seen (feed_id, first_seen) makes that a range per feed.

Nothing has to keep a list in step, so nothing can drift: the reap job
and daemon start move it up to the oldest unread item (hidden ones
too, since a block list can be lifted), never past an hour ago, so an
item a scan is storing meanwhile is not passed over. The one way an
older item becomes unread, marking it so, clears the person's value
for that feed. NULL, or stale, only means reading more.

The filter test now subscribes its reader, as every list's reader is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:04:32 +00:00
cb4ee5a570 Kept artwork off the async threads, and one fetch per missing image (#345 #349)
#345: a cache hit made four blocking std::fs calls on a tokio worker,
and the cache was on shfs (FUSE). A page asks for about fifty images at
once, so hits ran 11 ms at p50 and over a second at p90. Reading,
keeping and the reap job's trim now run in spawn_blocking. Production's
cache also moves to /mnt/fast in the compose file, outside the repo.

#349: two requests for the same uncached image both fetched it from its
publisher. A per-address lock now makes the second wait for the first,
then find the image kept. The lock is forgotten once its last holder is
done, whichever way the request ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:33:41 +00:00
c4878731c6 Count a Signal's items and unread in one pass (#348)
signal_rows ran the same correlated subquery over signal_verdicts,
entries, subscriptions and entry_state twice a Signal, once for items
and once for unread. One grouped subquery now gives both: count(*) and
a sum of the unread, joined to the person's Signals. Same results on
SQLite and Postgres (the existing counts test runs on both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:30:30 +00:00
21fa0ae476 Run the item page's count and the feed list's queries together (#346)
entry_page awaited the page's query and then its count, about 40 and
24 ms on /api/entries, the busiest route; feed_list awaited five
independent queries in turn (entries, downloaded, unread, blocked,
feeds), 59 ms of a 67 ms /api/feeds, with the pool's connections idle.
Both now use tokio::try_join!. On SQLite they queue on its connection
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:09 +00:00
aa430f6d50 A page's files are looked up by feed and item, not item alone (#344)
entries_where found a page's enclosures with guid IN (...). The only
index is enclosures_entry (feed_id, guid), so Postgres read the whole
of it: 587k rows, 9,150 buffers and 250 ms for user 2's 50 newest, on
every /api/entries, feed page, Signal page and single item. With the
page's feed ids as well it is an index lookup, 0.5 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:24:29 +00:00
df643536c0 ipx-web drops the header TRUSTED_HEADER names from anyone but the tunnel (#323)
ipx trusts [web] trusted_header from Caddy's address, and Caddy
stripped only the two Cf-Access-* names from requests not from
TUNNEL_PEER. With trusted_header set to anything else (X-Remote-User
for another proxy), a request through ipx-web carried it to ipx as
Caddy's and was believed: anyone could sign in as anyone.

TRUSTED_HEADER, defaulting to Cloudflare's name, is stripped the same
way. Production uses Cloudflare's header and sets nothing, so it
behaves as before. caddy adapt shows the delete list with and without.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:20:55 +00:00
543d35f02c A snapshot's subscriber counts are bounded above (#342)
Store::snapshot clamped a count only from below, and LISTING sums each
feed's counts across instances, cast to BIGINT. Two instances sending
i64::MAX for a feed overflowed it (Postgres: bigint out of range;
SQLite: integer overflow), and list() and popular() failed for every
feed: the public pages 503, /v1/feeds and /v1/popular 500, until an
admin dropped the instance. Counts are now clamped to a million.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:17:43 +00:00
0de0fb4332 Bound icon picks per person, and keep people's Signals apart (#340 #343)
#340: POST /api/signals/icon spends the server's TYPESAFE_KEY for
anyone signed in, whatever signals_on_server says, and is not tied to
a Signal, so a loop on it ran up the bill and, through the shared pace
and a 429's back-off, held back everyone's judging. Each person now has
20 picks an hour; past that it is a 429 the page shows as a toast.

#343: on the server's key, calls() put everyone's Signals on an item in
one call (#275), so one person's free-text description was part of the
question that decided another's verdicts. Each person's Signals on an
item are now a call of their own. The saving #275 made is lost only
where two people have Signals on the same item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:13:02 +00:00
79b496bdfb A password check holds its turn until it finishes (#324)
check_password took a permit from CHECKS in the async function and
ran Argon2 in spawn_blocking. A client that hung up dropped the
future and with it the permit, while the blocking check ran on, so a
flood of sign-ins that disconnected at once ran unbounded Argon2 work,
the very thing CHECKS was added for (#137).

The permit is now owned (CHECKS is an Arc) and moves into the blocking
closure, so it is freed when the check ends, whoever stopped waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:03:19 +00:00
a20fade8ad OPML uploads are capped at 2,000 feeds and checked by address once (#338)
subscribe_opml, reachable by anyone signed in through POST /api/opml,
held the catalogue lock while, for each outline, it searched every
subscription and the whole catalogue for its address and probed
unique_slug's suffixes in order. 2 MB holds about 40,000 outlines, so
one upload was quadratic work with every other catalogue change
waiting, and its feeds stayed in the catalogue for good.

opml_feeds lists a document's feeds and refuses more than MAX_LISTED,
the limit a subscribed list already has (#337): a 400 from the web, a
message from `ipx opml`. subscribe_opml takes that list and looks
addresses up in a map built once, the first feed with an address
winning as the search in order did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:00:29 +00:00
daf95a25cd Only a subscriber or an admin marks a feed private (#333)
patch_feed let private = true through for anyone signed in, as the
field's doc comment meant for subscribers, but never checked that the
caller subscribed. Ids are slugs of titles, so anyone could mark
another person's feed, or a whole OPML (whose shows go with it),
private, keeping it out of the directory, and promote a derived feed
they did not read out of its OPML; only an admin could undo either.

A non-admin now needs a subscription to the feed, or gets 404, as the
per-person settings above already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:56:36 +00:00
91de1114d8 Live updates go to those who read the feed, while their account exists (#325 #335)
/api/events sent every daemon event to every open page. Event::redacted
strips the query and the password from addresses, not the path, so a
paid feed's key in its path (feeds.supercast.com/feeds/<key>) reached
everyone signed in through its FeedError or FeedMoved (#325). And the
stream checked who was asking once, when it opened: a removed account's
page kept getting events until it closed (#335).

Each stream now looks its account and subscriptions up again at most
every 30 s, as an event arrives, ends when the account is gone, and
sends an event about a feed only to someone subscribed to it. Admins
get everything, for the log; Reaped, a file path, goes to admins only;
SignalHits to the person it is for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:51:12 +00:00
37c08195f1 A new password signs the account out everywhere (#334)
ipx user passwd, the one way to change a password and the step taken
when one may be known to someone else, only replaced the hash. Every
session stayed, and session_user moves `seen` on each use, so a
session someone kept using never reached the idle cutoff.

set_password now deletes the account's sessions too. API tokens stay:
a reset that broke every script would be a reason not to reset, and
the owner lists and removes tokens in Settings. The command says how
many still work.

Also corrects the first-start log line from #321: there is no way to
change a password in the page, so it names the command alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:45:41 +00:00
d1149e1236 Requests that change something are refused from another site (#336)
Six POST routes take no body, so a cross-origin form or no-cors fetch
reached them with no preflight: Sign out, Read all (one feed's and
every feed's), Download, Refresh a listing and subscribing to a
Directory feed. The ipx cookies are SameSite=Lax, which stops a form
on an unrelated site but not one on a sibling subdomain, and the
Cloudflare Access path does not use ipx's cookies at all.

cross_site_refused wraps every route, login included: anything but
GET, HEAD and OPTIONS is a 403 when Sec-Fetch-Site is not same-origin
or none, or, from a browser too old to send it, when Origin is not the
Host. A request with neither header is not a browser's (a script with
a token, the healthcheck, k6) and goes through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:33:39 +00:00
46fe0dddbf directory.toml's fetch_internal lets the refresher reach listed addresses (#329)
3bfd8f8 gave the refresher the guard with no exceptions, which broke
the browser suite: its directory refreshes fixture feeds on 127.0.0.1,
and five Directory tests waited on listings that never filled. The
cargo tests passed because none of them fetches over HTTP.

directory.toml takes fetch_internal as config.toml does, empty by
default; the suite's lists 127.0.0.1. Production's has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:33:30 +00:00
d35b8054b4 Bound the scan work one feed's author can ask for (#339 #337)
Both run on the daemon's single worker, so every other feed's check
and download waited behind them.

#339: prefer_https asked each new artwork host whether it served the
image on https, up to 20 s each, one after another, and the per-scan
cache is keyed by the authority, which ports and user names multiply
for one server. It now asks at most eight hosts a read; past that an
http image stays http, for /api/art to fetch, as one that fails does.

#337: a list (OPML or Patreon) may bring in 2,000 feeds; past that
sync_group refuses it whole, so the feed shows an error and nothing
changes. davewiner's, the largest seen, had 922. sync_group also read
the whole feeds table and rebuilt an id map with a Feed cloned per
entry for every new feed; it now reads the table once and checks ids
against sets, and unique_slug takes a predicate rather than a map.
same_feed skips parsing two URLs unless both are Patreon's, since only
those can differ and match.

sync_group and sync_opml lose the parent argument, which nothing used
once promote_kept stopped taking it (#332).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:18:32 +00:00
3bfd8f848b The directory's refresher refuses internal addresses (#329)
The refresher fetches every feed a snapshot names, and anyone with a
write token sends snapshots; a listed feed's redirect goes wherever its
publisher likes. Nothing checked the destination, so the directory
fetched loopback, its Docker network and the LAN, and what answered
became a listing's title and description on its public pages.

Its client now has ipx-feed's guard (#320) with nothing allowed: no
feed the directory lists is on this network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:10:32 +00:00
fc04a741a6 The directory's public pages read the list once a minute (#341)
/, /all, /search and /category are anyone's to ask for (#215), and each
called Store::list: the whole catalogue, joined with the snapshots and
grouped, on the 10-connection pool the refresher and every server's
/v1 calls share, on the Postgres that holds production ipx's database.
The pages' Cache-Control did nothing for a request with a query string
of its own, such as /search?q=<random>.

Directory::pages_list keeps the list for a minute behind a mutex held
through the read, so a burst of misses waits on one query rather than
each sending its own. Filtering and rendering still happen per request,
in memory. /v1/feeds, behind a token, still reads the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:06:19 +00:00
80f12cda70 A feed's credentials go to its own host alone (#327 #328)
feed::fetch already sent basic auth only to the feed's own host, but
two paths around it did not:

- download::download attached the feed's username and password to
  every enclosure, on whatever host the feed named: a CDN, or the
  measurement and ad-insertion prefixes podcasts route through (#328).
- subscriptions() and promote_kept() gave every feed an OPML or Patreon
  list derives the list's credentials, and fetch's same-host check then
  compared against the derived feed's own URL, so the list's password
  went to every host the OPML named, on every scan (#327).

config::Feed::auth_for and credentials_for compare hosts once, and
each of the three uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:02:10 +00:00
4f7dab9c04 A kept show keeps its list's private mark and credentials (#332)
promote_kept copied the list's settings from the current config, but
leave, rm and the directory cleanup remove the list from config before
calling retire_group, so the lookup found nothing: the promoted show
got private = false and no username or password, and the next snapshot
sent its URL to the directory. Only sync_group, which promotes while
the list is still there, kept them.

The callers now pass the list's settings as they were before removal.
Where they are no longer known (retire_stranded, for a list removed
long ago), the promoted show is private: a URL kept back by mistake
can be shared by hand, one shared by mistake cannot be taken back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:59:02 +00:00
cc3b8742bc The first admin gets a random password, not admin / ipodderx (#321)
A database with no accounts created admin with the hard-coded password
ipodderx, published in the README, the docs and the sample compose
file, and the log line announcing it printed the password. /api/login
is open to anyone who reaches the port, and nothing made anyone change
it, so a fresh install was an admin account anyone could sign into.

Now the password is IPX_ADMIN_PASSWORD when set, for scripted installs,
or 24 random hex characters written to admin-password beside
config.toml, created 0600 so it is never readable by others even for a
moment. The log says where it is, never what it is, as it does for the
web token (#40). Existing databases are untouched: seeding runs only
with no accounts at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:34 +00:00
c99f481124 Update rustls-webpki, webpki-roots and ctutils for three advisories (#351)
cargo deny check failed on main:
- RUSTSEC-2026-0382, rustls-webpki 0.103.15: verification may accept
  distrusted intermediates. Now 0.103.16.
- RUSTSEC-2026-0381, webpki-roots 1.0.9: bundled roots trust distrusted
  emSign intermediates. Now 1.0.11. It surfaced once the first was fixed.
- RUSTSEC-2026-0385, ctutils 0.4.2: non-constant-time masks and u128
  comparisons. Now 0.4.5.

All transitive; Cargo.lock only. cargo deny check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:09 +00:00
a20062d0f7 Fetches refuse internal addresses unless fetch_internal lists them (#320)
ipx's clients had no destination policy and followed redirects
anywhere. Adding a feed returned the status or first line of whatever
answered at the address; an enclosure declared as audio was downloaded
whole from any URL and served back through /media; /api/art fetched
any image a feed named. Feeds come from publishers and anyone signed
in, so loopback, the Docker network and the LAN were all reachable.

ipx-feed's guard is a connector layer: after the name resolves and the
connection opens, before the request is written, it looks at the
peer's address and refuses anything off the public internet unless
config.toml's [general] fetch_internal lists it. Being on the
connection, it covers every request on ctx.client and feed_client,
each redirect hop, IP literals (which never reach a DNS resolver) and
a name that resolves differently the second time. reqwest's rustls
connection reports the peer address; a test run by hand checks HTTPS
gets through, since were it lost every https fetch would be refused.

The directory is on the server's own network by design
(http://ipx-directory:8100), so it moves to local_client, unguarded,
set by an admin. The browser suite and load tests serve their feeds on
loopback and list 127.0.0.1.

Not covered: librqbit's own connections (trackers, peers), which do
not go through these clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:27:40 +00:00
6d71613e19 ipx fetches a .torrent itself, capped at 10 MB (#330)
Handed an http(s) URL, librqbit 9.0.1 fetched the .torrent with its own
reqwest client (no timeout) and response.bytes(), buffering the whole
body: a publisher could serve one without end, for up to the 30-minute
metadata budget, two torrents at a time, on a host with no swap.

Now ipx fetches it on ctx.client (connect and read timeouts), stops past
10 MB, holds it to the stall budget too so a trickle cannot keep a slot,
and gives librqbit the bytes. Magnet links go to librqbit as before.

Not fixed here: librqbit reads each HTTP tracker announce the same way,
and offers no setting to bound it short of disable_trackers, which would
break torrents that need their tracker. #330 stays open for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:15:17 +00:00
f398211fd6 OPML nested past 100 levels is refused before it is parsed (#322)
The opml crate's Outline reader (hard-xml's derive) recurses once per
<outline> level with no limit. A 2 MB upload holds tens of thousands of
levels, enough to overflow a tokio worker's 2 MiB stack, and a stack
overflow aborts the process, not the task. A fetched OPML (up to 50 MB)
reached the same parse on every scan, so a publisher could keep the
daemon in a crash loop.

feed::read_opml counts nesting with quick-xml's streaming reader, which
does not recurse, and refuses past 100 before handing the text to the
opml crate. Every place that parsed OPML goes through it: the upload,
`ipx opml`, a fetched OPML's feeds and its title. The limit also bounds
collect_outlines' recursion and the drop of the nested Vec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:11:21 +00:00
708b69c083 Feed links are followed only when they are web addresses (#326)
An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.

webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:07:47 +00:00
62a2456dde Downloads open in the browser only when they cannot run script (#319)
/media served a downloaded file with ServeFile, which types it by its
extension, and the file and its name are the publisher's. An enclosure
saved as .svg or .html, which download::sniff lets through unless it
starts <html or <!doctype html, opened from "View in a new tab" ran its
script on iPX's origin with the viewer's session.

Now every /media response is nosniff, anything but audio, video, a
non-SVG image, a PDF or plain text is sent as an attachment, and all
but PDFs get the sandbox CSP artwork already had. PDFs go without it
because Chrome refuses to render one in a sandboxed document; its
viewer does not run the PDF's script as the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:01:00 +00:00
ec3dedf113 Torrents download into a folder of their own (#318 #331)
The recorded path was output_folder.join(handle.name()). The name is the
publisher's: librqbit checks it for traversal only when it chooses the
subfolder itself, and ipx passes output_folder, so a multi-file torrent
named "/config" or ".." pointed Delete, the reaper and /media at that
path. With output_folder set, librqbit also puts a multi-file torrent's
files straight into it, not under the name, so the joined path was not
where the files were even for an honest torrent.

Each enclosure now gets torrent-<id> inside its feed's folder, and that
folder is the path: playable() already picks the largest file under a
folder and remove() deletes one (#158). The same folder keeps
overwrite: true from replacing another enclosure's files of the same
name. A torrent half-downloaded before this restarts in its new folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:55:35 +00:00
511246acb2 More step spans where flame graphs showed time with nothing under it (#350)
A week of traces folded with query.py flame left whole requests opaque:
entries_in at 46-82 ms a request with the page's query and its enclosures
together, /api/art 37 ms, /api/status 95 ms, PATCH /api/feeds/{id} 72 ms and
DELETE 45 ms. Each now has a step around what it waits on: the page and its
enclosures; the artwork cache, the check that a feed names it, the fetch and the
write; the status counts; and in changing or leaving a feed, each database call,
the wait for the catalogue's lock and storing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:45:58 +00:00
ccc4766b03 query.py: flame graphs from traces
`query.py flame '<TraceQL>' [since] [out.svg]` folds the matching spans of up to
100 traces into stacks of self time and draws them as a flame graph, with the
folded stacks beside it for speedscope or inferno. A wall-clock picture of where
a route's time goes, waiting included, which a CPU profiler would not show: ipx
waits on Postgres and the disk far more than it computes. The traces are fetched
in one container, a docker run apiece being a second each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:41:12 +00:00
78b81e43aa CLAUDE.md: the style guide path is /src/styleguide, not sytleguide
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:14:06 +00:00
4af12363bc Read a WordPress.com site's posts from its API when the feed is behind a browser check (#317)
pelgranepress.com is on WordPress.com, whose CDN answers /feed/, /feed/atom/ and ?feed=rss2
with HTTP 403 and "Checking your browser...", a JavaScript challenge, whatever the User-Agent.
The checks that hit it failed for hours (8 in the day before this), and the publisher has
not changed anything.

The API at public-api.wordpress.com/wp/v2/sites/<host>/posts has the same posts with no check,
and each post's guid is the feed's own (https://host/?p=<id>), so the items read from it are
the ones already stored. fetch recognises the challenge by its /__challenge/ script, asks the
API instead, and parse reads WordPress's posts JSON as a feed. It sends no title or image for
the feed, which record_feed keeps as they were.

shortcut: the API's default ten posts, as many as WordPress puts in a feed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 12:06:22 +00:00
84b4203daf A feed's page follows its row after a check (#316)
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the
feed list but not the open page, so a check that cleared an error left the error showing, with the
old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect
timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page.

Only the stat line and the error banner are redrawn: the whole page would close the open item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:37:19 +00:00
0696583547 Reduced motion, touch hover, overscroll and artwork edges (#310 #311 #312 #313 #314 #315)
Fixes from the UI-polish review of the main window (2026-10-19).

Reduced motion took every animation away, and a turning icon was the only sign a check was
running. The refresh buttons now carry aria-busy and a "Checking for new items" label while
checking, and under reduced motion spinners pulse and toasts and sheets fade.

Every :hover rule is behind @media (hover:hover), since Safari keeps :hover on the last thing
tapped; the two phone resets that undid it for .btn and .ep had nothing left to do and are gone.
An item row's actions still show on hover without the query, so a tablet with no pointer can
reach them by tapping the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:16:41 +00:00
6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00
c8afcec73d Back to -dev after ipx 1.4.0, ipx-directory 1.3.0 and ipx-web 1.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:58 +00:00
5bad68670a Release ipx 1.4.0, ipx-directory 1.3.0 and ipx-web 1.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:40:00 +00:00
d87d024096 CLAUDE.md: no test counts to go stale (#297)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:13:01 +00:00
44afc08b9d Search treats % and _ as themselves (#296)
The changelog lines for #292, #293 and #295 ride along in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:58 +00:00
a21472730e A feed is read up to 50 MB (#295)
resp.bytes() read any size whole: an episode's MP3 given as a feed was
held in memory at every check. Feeds here are a few MB at most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:55 +00:00
995ee05557 Others' downloads stay off your page (#294)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:55 +00:00
7af6153d18 A clean-up with the downloads folder gone forgets no file (#293)
missing_files took every path that did not exist() as deleted by hand.
With /downloads out of reach (the array stopped, shfs dropped) that is
every file, and each would have been marked reaped at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:52 +00:00
c1d8983ca1 Download addresses lose their query before they go out (#292)
Progress and DownloadDone went to every open page and the log with the
file's whole address; a Patreon file's carries its signature. Seen in
production's log for gcn-news-minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:48 +00:00
4c1b78408f An x in a search box clears it (#291)
Once something is typed: at the box's right end, inside it. One click
handler for every box, which clears it and fires its input event, so each
box's own handler brings its list back whole. The browser's own x, drawn
by some browsers and not others, gives way to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:55:25 +00:00
d850c02501 DeadAir's pause slider without its heading (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:51:33 +00:00
363a5a122d A feed's address stays at the top of its settings (#290)
Asked for on the mockups: the address first, under the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:43:27 +00:00
3d9b45ddd7 Changelog: the redesign under the existing Changed (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:38:22 +00:00
b54b47897b Keep a feed out of Signals, and a feed's settings in named parts (#289, #290)
hide_from_signals on a subscription, on an OPML or creator covering every
show in it, as hide_from_all does for All Subscriptions: the judge does
not put its items to Jev, and each Signal's list and counts leave them
out. shown_in() is the one SQL rule for both.

The settings dialog had grown one captioned setting at a time into a
single column, one part boxed and the rest not. It is in named parts now,
the name in a column beside the controls and above them on a phone, ruled
apart; All Subscriptions and Signals carry the sidebar's icons. The
address, the same for everyone, moves to the foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:37:49 +00:00
b89c371662 Keep a feed, an OPML or a Patreon creator out of All Subscriptions (#288)
A switch in the feed's settings, In All Subscriptions, on its
subscription as hide_from_all. All Subscriptions' list and count leave
out a feed hidden on its own or through the OPML it is in, and the page's
unread count does the same. Signals read every feed as before, and
Currently Listening, which is All Subscriptions' in-progress filter, keeps
an episode under way whatever the feed's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:19:54 +00:00
6daab6989e A list's trace says which list it was (#287)
The prod check of 2026-10-19 found /api/entries requests of 0.5-0.8 s
overnight beside others of 80-100 ms on an idle host, the time all in
entries_in, and nothing in the trace said which filter, sort or page
they were. The step now carries them, and whether a search, not its words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:05:50 +00:00
187729a278 ipx-web's spans are named for the request (#286)
Caddy named every span ipx-web, so Tempo's lists and trace view showed the
same name for each request. Caddy 2.11 takes placeholders in the span name:
now the method and path, as ipx names its own. The prod check's span list
shows a span's url.path when it has no route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:04:10 +00:00
3df7c974e8 prod check: slow requests as spans, with their route, laid beside Tower's load
Check 5 listed traces over 5 s, which were all page traces holding minutes of
live updates or audio, so a slow request never showed. query.py's new `spans`
lists the matching spans with their own time; check 5 asks for ipx requests
over a second, steps over half a second, and says to set the hours beside
Tower's load: on 2026-10-18, 120 of 130 slow requests were the host stalling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:59:20 +00:00
59aa00e07c Checks and tests run for the part a change is in
The pre-commit hook ran clippy on the whole workspace for any Rust change;
it now checks the crates touched and those built on them (ipx uses
ipx-directory, both use ipx-feed), and nothing for a change to the page
alone. CLAUDE.md says which tests go with which part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:50:58 +00:00
b4c0048f68 The HTML and Markdown choice drops the Markdown source view (#285)
Rendered Markdown is the one wanted beside the publisher's HTML. A browser
that had chosen the source view shows the HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:45:18 +00:00
d5ffe5d042 Switch an item between its HTML and its Markdown (#285)
A feed that sends <source:markdown> beside its HTML (Scripting News) has its
Markdown kept in entries.markdown, added to existing databases by an ALTER.
A stored item gets it when a scan finds it told differently, the Markdown
counting as a change, the way corrections arrive (#141). The API sends it as
written and made HTML, cleaned by ammonia like the description.

The item's page has a choice between its meta line and its buttons: HTML,
Markdown or Markdown source, remembered in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:39:16 +00:00
d16460267f Changelog: #284 reaches stored items too, on the feed's next whole read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:33:32 +00:00
21c786f48a Artwork without a scan of entries, and the Directory's artwork let through (#272, #273)
/api/art asked whether a feed or item names the image before anything else,
cache hits included, and entries.image had no index: a sequential scan of
entries, 571 ms on production, 50 at once for a page of images. A kept image
is served first now, since it was only kept once let through, and entries.image
has an index: hash on Postgres, whose btree refuses keys over about 2.7 KB.

The Directory lists podcasts nobody here subscribes to, and their artwork was
refused, so the page drew initials. ipx remembers the images the directory's
listings and pages carry and lets those through too; any other address is
still refused, so /api/art cannot be used to fetch arbitrary URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:31:20 +00:00
3a8d564eb7 Items written in Markdown show formatted (#284)
A description with Markdown and no HTML tag is rendered to HTML with
pulldown-cmark, and an item with no text but RSS's <source:markdown>
(https://source.scripting.com/, as Scripting News sends beside its HTML) uses
that. HTML is left alone, and plain text without Markdown in it stays as it
was, so a line like "1984. ..." is not made a list. ammonia cleans the result
as any feed's HTML. Stored items keep what they have: the scanner does not
rewrite an item it has.

Reference: http://scripting.com/2026/10/06/005559.html

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:05:05 +00:00
f9246129f2 rustfmt on every edit, clippy before every commit touching Rust
A Claude Code hook formats each .rs file as it is edited, and the pre-commit
hook runs clippy as well as the format check. Clippy only checks, so with the
line-tables profile it takes under a minute (57 s after a change) at load 8.
rust-analyzer, which gave the same errors as clippy for 2.8 GB of memory, is
no longer used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:55:33 +00:00
5cf7a28c44 An OPML's page lists its shows' items together, not the shows (#283)
GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.

The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:41:38 +00:00
ac3808fe97 Build with line tables only: a test build no longer runs Tower out of memory
Linking ipx's 431 MB test binary took Tower, 31 GB with no swap and 4 GB free
beside its containers, into page-fault thrashing for 54 minutes: load 160 with
the CPU idle and 130 processes blocked. Line tables for our crates and no debug
info for dependencies make it 109 MB, built in 2m14s with load at 20 and 6.5 GB
left. CLAUDE.md says to watch memory beside the load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:41:38 +00:00
341d880e13 cargo-deny and cargo-machete: four advisories fixed, three unused dependencies gone (#281, #282)
cargo deny check found 10 RustSec advisories in Cargo.lock: ammonia
4.1.4 (three, XSS via SVG animation tags among them), rustls 0.23.44
(five), h2 0.4.15 (one) and ringbuf 0.4.8 (one). cargo update took ammonia
to 4.2.4, rustls to 0.23.46 and h2 to 0.4.20. ringbuf's fix is 0.5, which
librqbit 9.0.1 (the newest) cannot take; its double free needs an element
whose Drop panics and librqbit-utp's buffer holds u8, so deny.toml ignores
it with that reason.

deny.toml allows permissive licences only, crates.io only, and no
wildcard versions; ipx and ipx-directory say publish = false, as feed did.
cargo machete found crates/ipx still listing atom_syndication, quick-xml
and rss, which only crates/feed uses since parsing moved there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:29:01 +00:00
cb4407b19d CLAUDE.md: the containers resolve through 1.1.1.1, not NextDNS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:07:56 +00:00
7f61250852 Keep is a button on the show's page, a plus, not a switch in its settings (#280)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:02:55 +00:00
c90a66a24f Clippy clean, and rustfmt and clippy part of the checks
cargo clippy --workspace --all-targets had 24 warnings; its own fixes took
most, the rest by hand: two type aliases for long types, a struct built
whole, a checked division, a doc comment's stray blank line. Its fix for
the stale-feed fold was wrong: `any` stops at the first feed it removes,
where the fold removed every one, so that is a loop instead.

A pre-commit hook in .githooks refuses unformatted Rust; clippy, being a
build, is in CLAUDE.md's checks with -D warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:59:37 +00:00
692a510217 Format the Rust code with rustfmt
The code was formatted by hand, and `cargo fmt --check` differed in 866
places. rustfmt.toml keeps the house's long lines (120) and short
expressions on one line, which halves what changes; this commit is only
what rustfmt does with it, and is named in .git-blame-ignore-revs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:55:41 +00:00
2c4ba7896e Keep a show from an OPML when the OPML drops it (#280)
A show inside a subscribed OPML went when the OPML stopped listing it
(orphaned only if it had downloads), and went for you when you left the
OPML. Keep, in its settings, is a per-person flag on the subscription:
when the OPML drops a kept show, or nobody subscribes to the OPML any more,
it becomes a catalogue entry outside the OPML, taking the OPML's settings
while it is there, and stays on the lists of those who kept it. Whoever had
it only through the OPML loses it as before. Leaving the OPML yourself
leaves what you kept on your list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:37:01 +00:00
36a820f283 Artwork without an image type is known by its first bytes (#279)
Spotify's CloudFront serves some covers as application/octet-stream and
pippa.io sends no Content-Type at all; both are JPEGs, which browsers sniff
and show, but /api/art refused them with a 502 and the podcast showed its
initials. An untyped or octet-stream answer is now read and checked for
JPEG, PNG, GIF or WebP magic; anything else, SVG included, is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:26:01 +00:00
41fc2e3933 Dashboard: per-person Signals panels leave out counts from before #278
They had no person, and drew as " ( key)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:31:02 +00:00
4eba746ba4 Signals' metrics by person and whose key paid (#278)
With the server paying for everyone's Signals, its bill had no names.
ipx_signals_judged_total, ipx_signals_hits_total and ipx_jev_tokens_total
now carry user (the account's name) and key (server or own); a call
shared on the server's key is split by each person's share of its
questions. Icon picks bill the person on the server's key, key checks on
their own. The dashboard has tokens and items by person and a Who used
what table over its range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:26:50 +00:00
ce78599ab4 Signals: calls to TypeSafe paced under its rate limits, a 429 honoured, and the wait traced, counted and drawn (#277)
TypeSafe allows 80 requests and 100,000 input tokens a second. Six calls
in flight was a cap on concurrency, not rate: at 74 ms a call it is 81 a
second. Every call to Jev now takes a turn, a 60th of a second or its
guessed tokens' share of 80,000, so up to ten can be in flight. A 429
holds every call back for Retry-After and ends the pass. Each jev span
records paced_ms, /metrics ipx_jev_paced_seconds_total and a rate_limited
result, and the dashboard draws calls and tokens a second against the
limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:38:20 +00:00
5130f754cc Analyse entries once when entries_recent has no statistics (#274)
Deployed, the judge's query still read every entry: an expression index
has no statistics until its table is analysed, and Postgres, guessing a
third of entries were recent, chose a scan (898 ms) over entries_recent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:17:56 +00:00
2077ea5810 Signals: index the judge's query, six calls at once, one call per item on the server's key, tokens counted, a week's look-back an admin sets, edits that ask again only what was picked out (#274, #275, #276)
The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.

An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:13:25 +00:00
026fbd1622 Docs and site: who pays for Signals, and Signals in the API reference (#271, #269)
docs/signals.md says what the admin's setting does and that a key is
checked before it is saved; the install guide lists TYPESAFE_KEY; the API
reference has Signals' routes and types and the new fields of Me and
Settings; the home page says an admin may provide the key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:09 +00:00
1aef6ba421 Browser suite: the server's TypeSafe key is the shell's, and Signals' tests run only when Signals change (#271)
/src/.envrc's TYPESAFE_KEY is a development key, so the test daemon uses it
and the server paying for Signals is tested end to end. Signals' tests spend
it, so each runs only when the Signals code or the test itself has changed
since it last passed (a hash kept under target/signals-tests);
IPX_SIGNALS_TESTS=1 runs them anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:27:41 +00:00
9a41641eaa Signals: an admin chooses whether the server's TypeSafe key pays for everyone (#271)
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.

The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:18:03 +00:00
33eb1f4db2 Signals in /metrics and on the dashboard; failed Jev calls are failed spans (#270)
The judge had spans but nothing in Prometheus: no count of items judged or
of how calls to Jev ended, nor whether the judge was still passing. The jev
span recorded an error field without the span's status, so Tempo's failed
traces left Jev out, and a timeout recorded nothing. Calls now send
traceparent like every other call out of ipx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:55:37 +00:00
3b48f51a56 Home page: the Signals key sentence in the owner's words (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:15:04 +00:00
55b0192df5 Home page: the Signals key sentence reads plainly (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:08:21 +00:00
e07d27d9f2 Home page: no account section, and its screenshot gone (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:06:44 +00:00
3532710b3d Home page: no What it does heading; Signals and DeadAir copy in the radio's voice (#216, #254)
Tune out the noise and All talk, no dead air, each a radio idiom the dial
at the top already sets up, written with /brand-naming-strategies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:04:24 +00:00
b56c97259d Home page: the features zigzag, each picture centred in its column (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:00:24 +00:00
1025416181 Home page: a screenshot for Signals in place of the drawing (#254)
The drawn list's made-up titles read badly. The screenshot is a Signal
called Space on a scratch iPX on eight public feeds, with a stand-in
for Jev matching the Signal's words in titles, in Glass as the other
shots are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:49 +00:00
894e5e4711 Home page: the noise bars' widths in the stylesheet (#254)
The site's CSP is style-src 'self', which refuses style attributes, so
the bars would all have drawn at full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:48:14 +00:00
0e21195e8d Versions after ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0: -dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:32 +00:00
3b41abd860 Home page: a section on Signals (#254)
Drawn in CSS like DeadAir's: the noise fades and the three items about
the words listened for stay lit. No screenshot, since one would need a
TypeSafe key on the scratch server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:34 +00:00
47ad68c7da Release ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0
Signals (#254) in ipx and the page, and the compression and tracing
work of #239-#253 in all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:33 +00:00
ae58e4653a Signals in the theme's accent tint again (#268)
Neither orange tile nor orange icon on the tint worked, so each
Signal's tile is back in the theme's accent tint, as it was before
#268. The Signals folder icon and the larger DeadAir tile stay. The
tile rules go above the initials' comment, which they had split from
its rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:24:18 +00:00
dc3ee016db Signals back on the accent tint, their icon or initials in orange (#268)
A solid orange tile was too loud. Each Signal's tile is the folder's
accent tint again, and its icon or initials are drawn in the orange of
the Signals icon's point: #ff6a1a in dark. In light it is the light
palette's needle, #c43e00, since signals.svg's #ff5500 on the tint is
about 2:1, too faint for initials (about 3.4:1 instead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:21:44 +00:00
f916ca972a Signals solid in the orange of the Signals icon's point (#268)
--accent2 mixed a quarter into the ground came out salmon, and is pink
or blue in other themes. Each Signal's tile is now solid #ff6a1a, or
#ff5500 in light, the point of signals.svg and signals-dark.svg, in
every theme as the icon is, with the icon or initials in the logo's
navy (#0c2238, 5:1 or better on either).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:16:47 +00:00
7627af5bc8 Signals in the needle's orange, and a larger DeadAir tile in the play bar (#268)
Each Signal's tile, icon or initials, is tinted --accent2 in place of
--accent: the orange point of the Signals folder's own icon, so the
folder and what is in it read as one. The player's DeadAir tile goes
from 22px to 26px in its 30px button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:25 +00:00
fbe7629420 App-icon tiles for Signals and DeadAir, drawn as the logo is (#268)
web/signals.svg and web/deadair-{on,off}.svg, each with a -dark twin,
follow logo.svg's rules: square and opaque, the logo's gradient ground,
flat layers in its scale blues, the needle's orange, nothing thin
enough to vanish at 32px. Signals is the page's own glyph, waves going
out from a point, the point in orange. DeadAir on is the waveform
closed up with the needle at the cut; off has the silence still in its
middle, an orange flat line, and its bars muted, so the state is in
the shape as the old glyphs had it.

The Signals folder row and its page header show the Signals tile in
place of the glyph on an accent tint; the player's DeadAir switch shows
the on or off tile. tile() puts both variants on the page, and the mode
rule that picked the logo's now picks any .onlight/.ondark. The three
glyphs nothing uses any more are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:07:18 +00:00
e882904c11 Signals read an image's alt and title text, not the title alone (#267)
xkcd's posts are an <img> whose alt and title are the comic's words. text_of dropped tags whole,
attributes and all, so Jev was sent "Feed: xkcd.com / Title: Ground Effect", ground effect being
an F1 aerodynamics term, and the comic went into the Formula 1 Signal at 0.60. With the alt text
it scores 0.19. Alt and title are read once each, and once when they say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:47:47 +00:00
f1a156c974 A Signal with no key links to typesafe.ai and to the key's field in Settings (#266)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:32:57 +00:00
f34423735a Signals' page copy says what happens, not that Jev does it; Auto spins while it picks (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:28:52 +00:00
15bdce486d The icon picker's Jev button reads Auto (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:23:13 +00:00
0c2c3a379e No + on the Signals folder's row, so it is like any folder's (#265)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:29 +00:00
3e4d67fd2f Jev is offered a brand's icon only when its name is one of the Signal's words (#264)
Font Awesome's Mandalorian icon has "disney plus" among its search terms, the only icon with
"disney" in them, so a search for Disney Parks' words offered it and Jev picked a Star Wars helmet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:20:34 +00:00
2ed8781604 Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.

Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:09:26 +00:00
d0ed953e84 Every Signal's tile in the folder's accent tint, not one by a hash of its name (#262)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:03:40 +00:00
4f1e7e23f6 The Signals folder's + before its count, so the counts line up (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:56:12 +00:00
b18c92d1f5 A Signal's own icon, from all of Font Awesome Free, searchable (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:36:08 +00:00
9e5dcbb8c4 Signals go straight through their queue, not 25 items every 5 minutes (#260)
unjudged() limits rows, one for each item and Signal, to 50, and the pass took the queue for
empty whenever the items numbered fewer than 50. With two Signals every batch is 25 items, so the
re-judge after #259 crawled at 25 items every 5 minutes. A batch is now full by its rows. A pass
TypeSafe stopped as malformed waits for the next wake however full it was, rather than asking again
at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:15:36 +00:00
ce485a82ac Signals judge one subject, with a sentence of your own, not any one word (#259)
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.

signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:29 +00:00
f8371f255d docs/signals.md: how Signals work, with TypeSafe's requests and answers and the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:57:05 +00:00
1ae91170fd Signal counts kept current on the page: after a read, and when Jev picks something out (#258)
The page fetched the Signals' unread counts only with the feed list, so reading an item, here or
in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said
2 unread over a page with none. The page now fetches them again after a read or unread lands, and
on a new signal_hits event, which the judge sends once a pass for each person it found items for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:54:42 +00:00
20f53183ac Signals: Jev's request in the shape TypeSafe takes, and its reason kept when it refuses (#257)
Every call was refused with 422: a noul question's `criteria` is an object of what true and false
mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log
and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary"
at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status.

A request refused as malformed (400, 422) ends the pass, since every other item's would be refused
the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as
TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the
questions are keyed signal_<id>. Checked against TypeSafe with the corrected request: 0.96 for an
item about the keywords, 0.02 for one that was not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:48:06 +00:00
8d93b58deb Signals folder under All Subscriptions, with its + on the folder's row (#256)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:39:04 +00:00
0fdd609791 Signals in a folder of their own in the feed list, as an OPML's feeds are (#255)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:45 +00:00
13cad18857 Signals' words: what matters out of the noise of your feeds (#254)
The dialog asks what to listen for, the page says what it picked out of the noise of your feeds,
and a new Signal says nothing has come through yet instead of showing an empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:58:27 +00:00
70990c70c7 Signals: feeds made from your subscriptions, picked by Jev (#254)
A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.

A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.

The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:54:50 +00:00
30622fd3e1 CLAUDE.md: ipx reaches the directory at http://ipx-directory:8100
Set 2026-10-16 in Admin, Directory, where it was https://directory.ipxreader.app: every Directory
page and snapshot went out through Cloudflare and back down the tunnel, 170-340 ms a call, and a
Directory page took 150-200 ms; 29-54 ms now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:51:00 +00:00
1949f3e493 Compression across the board: directory answers, uploads, metrics, traces, the page (#253)
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:02:39 +00:00
3d94bce4f5 Directory requests traced whole: the call, its answer's size, the matching (#250)
A Directory page took 983 ms during a scan with 19 ms under spans: the directory's own span ends
when it has answered, and ipx's reading of the 458 KB answer, its subscriptions and catalogue,
and the matching of 1,471 listings had none. call() is a span now, directory_call, with the
answer's size; rows_for has subscriptions, catalogue and match steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:50:30 +00:00
cd7a9b8530 Apple Podcasts' top charts into the directory weekly, and its categories checked (#252)
With apple_country set, the directory fetches Apple's top 100 podcasts and the top 50 of each of
its 19 categories for that country (itunes.apple.com/{cc}/rss/toppodcasts), looks up each
show's RSS address (/lookup, 100 ids a call, three seconds apart, as Apple asks for no more than
about twenty a minute), and stores them as the snapshot apple-podcasts with no subscribers. The
refresher fetches them like any feed; the most-subscribed chart is unchanged. Apple's primary
genre goes in as the snapshot's category, used only while a feed names none.

The same weekly run reads Apple's genre tree (ws/genres?id=26) and warns when it differs from
ipx_feed's CATEGORIES; on 2026-10-15 they were the same, all 19 and their subcategories.

A syncs table keeps when it last ran: at start if a week has passed, again weekly, an hour after
a failure. keep_instances spares apple-podcasts, which no token holds, and no token may take
the name. A trace a sync, a span each answer.

CLAUDE.md: builds one cargo command at a time, the load looked at before and after, after a
build chained with a test compile took Tower past 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:21:48 +00:00
ca73b8136f Grafana: the connections panel shows in use and ready, not a pool that looks full (#251)
Since #241 the daemon keeps all 20 connections open, so the panel's open line sat on the pool's
maximum and read as exhausted; in use has been at most 4 in a week. It draws in use, ready
(idle) and the pool now, and the prod check reads idle the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:25:50 +00:00
6fde4b73cd Currently Listening and Pinned start from the person's own rows (#249)
coalesce(s.position, 0) > 5 and coalesce(s.flagged, false) hid from Postgres that a match needs
a row of entry_state, so it kept the outer join and scanned every item: Currently Listening read
all 574,000 (74,733 blocks from disk), 970 ms of its 1.3 s. s.position > 5 and s.flagged mean the
same, a missing row being neither, and let it start from the person's own rows: 2.2 ms, and
Pinned 32 ms to 2.3 (EXPLAIN ANALYZE, BUFFERS on production, read only). Unread keeps its
coalesce: no row is unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:27:25 +00:00
cdf2c98f9f Currently Listening and the Directory open at once, or with a spinner (#248)
Both fetched on every visit and drew only when the answer came: Currently Listening took 1.3 s.
fetchPage keeps their answers by address, as the item lists are kept, sharing a request already
on its way; both draw from what is kept at once and again only if the refresh changed them, and
show the spinner with nothing kept. They are fetched ahead with the lists when the page loads,
and when the pointer or a finger reaches their row. Removing an episode from Currently Listening
forgets its kept copy, which would otherwise show it again until the refresh. The preload loop
takes a failed fetch as done instead of leaving an unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:24:25 +00:00
6640ca9612 Lists open at once from what the page keeps and fetches ahead, or with a spinner (#247)
A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so
nothing answered the click until the server did. The page now keeps the first page of each list
(the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it
behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty
feeds with the most unread once the page is idle, two at a time, and a feed or filter when the
pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All
Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a
spinner if its answer takes more than 120 ms.

list_shown (#246) is reported when the list first shows, so a kept one reports its few ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:19 +00:00
62a3241139 Database connections kept open; a click's wait is measured (#241, #245, #246)
#241: on production, All Subscriptions' query runs in 31 ms, unread oldest-first in 30, the
feed list's count in 5, generic plans included (EXPLAIN ANALYZE, BUFFERS, read only), against
0.3-1.6 s in ipx. A new Postgres connection takes 80-330 ms to open, and a page load asks for
more than are idle: four of six slow loads came as the pool grew (3 to 6, 2 to 6, 3 to 5, 6 to
8), and 20 requests at once took a median 414 ms while the pool grew from 8 to 20. The daemon
and the directory's server now keep their whole pool open (min = max); a command such as `ipx
status`, and the tests, which share the server's 100 connections, open as they need.

#246: the page reports list_shown, the ms from asking for a list to drawing it, with the view
and filter, under the click's trace; background refreshes are not timed. Timings skip report()'s
once-a-minute limit, which is for errors in a loop. A dashboard panel and table, and the prod
check, read it.

#245: the load test's browse scenario subscribes by OPML and leaves the Directory, which is
ipx-directory's since #204, to it.

CLAUDE.md records the owner's leave to EXPLAIN (ANALYZE, BUFFERS) any query on production,
read only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:52:14 +00:00
8f19d88fc9 Compressed answers, a directory twice as patient, fewer whole re-reads (#239, #242, #243, #244)
Measured on 2026-10-15: the feed list and item lists went out uncompressed at over 100 KB; ipx
read 2.6 GB of feed bodies a day and the directory 3.9 GB.

- ipx-web compresses with zstd or gzip, leaving out /api/events, which a compressor would hold
  back, and /media (#239).
- A body identical to the last one (SHA-256 in feeds.body_hash) is taken as a 304, unless
  something is missing a read would fill or the check is forced; and a feed without a schedule
  of its own whose body has been the same waits a twelfth of that, up to four hours, beside the
  failure backoff (#242). 35 Patreon show feeds sent ~300 KB each hourly, about 250 MB a day.
- The directory checks each feed every twelve hours, not six (#243).
- reqwest accepts brotli and zstd too (#244).

#240 (cache the feed list's counts) is left open: at production's size on the test Postgres
that count takes 12 ms and All Subscriptions 32 ms, against 366 and 300 ms in production, so
the cause is production's database, not the query (#241).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:54:00 +00:00
dbfe7918ac ipx 1.2.1-dev, ipx-directory 1.1.2-dev, ipx-web 1.0.3-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:33:45 +00:00
c957db7f61 ipx 1.2.0, ipx-directory 1.1.1, ipx-web 1.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:25:18 +00:00
4e0b5eed5d Failures and events go out without a private feed's key (#238)
A fetch that failed logged reqwest's words, which name the whole address: Patreon's auth key
went to Loki on 2026-10-15, and, since scan events go to every open page, to everyone's
browser too. #225 had taken queries out of the traces' address fields only.

Every event is redacted where it is emitted, for the log, the live stream and a terminal
alike: address fields lose their query and password, and so does any address in an error's
words (feed::redact_text). The artwork proxy's warnings and the directory's failed checks
the same. The error kept on the feed, shown to its own subscribers, keeps the detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:08 +00:00
a9991545f9 Tests: two people on one file, each with their own DeadAir version, place and playlist
The DeadAir tests named for each person's clock used one account. Now three play one file at
once (DeadAir at two pauses, and the original) and each keeps their own place; and two fetch
the same /media/{id}/deadair.m3u8 at once and each gets their own playlist, no-store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:04:47 +00:00
60007d4e6a Page type-check: strict throughout, a typed $, null checks (#236)
$<T> returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.

isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:33 +00:00
1471b9b9d0 ipx-web without curl, which CVE-2026-92392 affects in caddy:2's 8.22.0 (#237)
Nothing in the image ran it: Caddy is Go, and there has been no healthcheck since #218.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:19 +00:00
ec2b9e87b8 Page type-check: noImplicitAny, every parameter and variable typed (#235)
290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts
(feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run
differently in form only: the seek and volume inputs divide Number(value) where they divided
the string, and esc's table has a name. Null checks are #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:57:05 +00:00
af6eae9baf Page type-check: types for what the API sends, and for S (#235)
web/src/types.ts mirrors FeedRow, EntryRow, EncRow, /api/me and PopularRow; types only, so
swc strips it and it is not in build.mjs's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:52:22 +00:00
78704e40f2 Page type-check: strict's flags that cost nothing, and catch variables as unknown (#235)
why(e) gives a caught error's message; each catch read e.message as any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:51:41 +00:00
f20660a7d5 Unit tests for the page's own functions (#234)
tests/page-unit.js loads the built index.html script into the stub DOM page-smoke.js uses and
calls its helpers directly with node:test: escaping, times and sizes, file kinds, an untitled
item's name, the directory's sorts, filter words, DeadAir's playlist address. Top-level consts
are not properties of the vm context, so each check evaluates its expression in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:47:15 +00:00
a5e64e520d Refresh buttons turn from the press to the end of their scan; quick checks keep the count (#232, #233)
The buttons followed the feeds being checked, so they stopped between one feed and the next;
7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops
it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a
scheduled scan finishing first does not stop a button whose scan is queued behind it. An
error event, a job that failed without a scan_done, stops them all.

A row's spinner stood in for its unread count from the moment its feed was checked; most
checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:00:50 +00:00
7ecbe8293f The refresh buttons keep turning between one feed and the next (#231)
A scan checks its feeds one at a time; in the gap between two the body class came off, the
icon snapped back upright and the spin restarted, many times a second with fetches ahead.
The class now comes off only after 600 ms with nothing being checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:49:55 +00:00
8d7a30d1f9 Faster scans and directory rounds, and fetches named by feed (#227, #228, #229, #230)
From the slow traces of 2026-10-13: the directory checked a round's 25 feeds one after
another, so two libsyn feeds timing out at 30 s made a round 70 s; a forced scan looked up
62 site icons in turn, 22 s of 62; mjg59.dreamwidth.org takes the connection and never
answers, so it ran the full 30 s in every scan it was in; and since #225 strips queries,
every YouTube fetch span read the same address, outside any span naming its feed.

The directory now checks five at a time (SQLite stays on one connection, so its writes
queue). A forced scan looks up the icon in the fetch-ahead task, which already runs six
deep, and hands it to scan_one with the body. Both feed clients give up after 15 s of
silence (no successful fetch took over 8 s in a day of traces); FEED_TIMEOUT stays 30 s
for a feed still sending. The directory's client had no connect timeout; it has ipx's 10 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:27:19 +00:00
cece114d98 Grafana: a panel querying Loki and Prometheus both is Mixed, so Downloads works (#226)
Each panel took its first query's data source, Loki for Downloads, whose at-once and torrent
lines are Prometheus's; Grafana runs another data source's query only on a Mixed panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:09:32 +00:00
f00adf5da5 ipx and ipx-directory 1.1.1-dev, ipx-web 1.0.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:25:30 +00:00
d787bd26ec ipx 1.1.0, ipx-directory 1.1.0, ipx-web 1.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:19:28 +00:00
382f51a480 Traces keep feed and file addresses without their query or password (#225)
The fetch span and the download span recorded the whole address, so a private feed's
credential (Patreon's ?auth=) went to Tempo with every scan, readable by anyone with
Grafana. feed::redact drops the query, fragment and user:password; a token in the path
would still go, noted where it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:54:08 +00:00
45c9f9252b mimalloc for ipx and the directory, and metrics and traces for memory (#224)
ipx's resident memory climbed from about 40 MB to 500-850 MB each night while scans ran five
times as often, and stayed there until a restart; the directory sat at 1.25 GiB idle. glibc's
allocator keeps what each thread's arena freed. mimalloc gives freed pages back, so resident
memory now follows what is in use. jemalloc was the first choice for its in-use figures, but its
upstream was archived in 2025.

Declared once in the directory crate, which ipx builds in, so both binaries get it.

What was missing to see it: the directory served no /metrics (now on metrics_bind, a port apart
from the tunnel's), nothing counted the feed bytes fetched, and the fetch span had no body size.
The feed crate's spans never reached Tempo either: both trace exporters filtered to their own
crate, so ipx_feed's fetch and parse spans were dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:15:01 +00:00
e4b505564c Docs: a page each, and API references for ipx and the directory laid out like docs.rs (#223)
site/gen.mjs writes them from site/api.mjs, which holds each endpoint and type as the handlers
have them, and site/docs.mjs, the guides. A misspelt type in api.mjs fails the build rather than
leaving a name that links nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:03:01 +00:00
4cf568b263 Home page: a Docs page, for installing ipx and ipx-web and using their APIs and the directory's (#222)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:21:49 +00:00
724764c8df Page: catch background fetches, no HTML in errors, a trace ends after a quiet minute (#219)
The page reported NetworkError rejections from the live update's list refresh and the first
feed load, which had no catch, whenever the network was gone (a laptop asleep). A Cloudflare
502 page became a request's error message, since api() used whatever body came back. And
the trace id lived until the next tap, so background requests nine hours apart shared one
trace. The id now ends after a minute without a request; a play's position saves, every ten
seconds, keep it going.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:16:50 +00:00
cc155274e9 No Docker healthchecks: the daemon keeps /metrics' counts itself (#218)
Docker runs a healthcheck with exec, and containerd puts each exec's state in XDG_RUNTIME_DIR,
/run/user/0 on Tower, which elogind removes when root's last session ends. Every check then
failed and every container showed unhealthy while all of them answered. The checks are gone from
the sample compose file and production's; ipx's library counts for /metrics, which only the
healthcheck's status call refreshed, are counted every 30 s by the daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:08:22 +00:00
02bfa9571b ipx, ipx-directory and ipx-web 1.0.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:50:57 +00:00
1b137d6969 Dockerfile: copy in the logo files the directory crate builds in (#217)
ipx depends on ipx-directory, whose site.rs include_bytes! three files from web/ since #215;
Dockerfile.directory copied them and Dockerfile did not, so ipx's image stopped building.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:45:00 +00:00
25f4d23112 Release ipx 1.0.0, ipx-directory 1.0.0 and ipx-web 1.0.0, as Gitea releases
Each release is now a Gitea release as well as an image: scripts/release.sh takes the binary (or
the page's files) out of the deployed image, checks its version, and attaches it as a tarball with
the changelog section as the notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:41:47 +00:00
69958d6926 Home page: the Gitea logo in the header, linking to the source (#216)
The logo is Gitea's own, from git.sdf1.net, with its inline styles
turned into fill attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:37:55 +00:00
0051590c71 Home page: Since 2004 without the tour of the window (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:17:35 +00:00
83e7ee817b Home page: every feature on one row, and the copy in plain words (#216)
The player was the one feature with its picture under its text; it is a
row like the rest now, its bar cut short of the volume so it stays
legible in the narrower column. The feed list is cut to the first two
shows, so its row is no taller than its text, and the directory to its
first two rows, which the old crop cut through. On a phone the directory
shows its left column.

The copy loses its insiders' words: feeds are podcasts, the swarm and
seeding ratio are sharing back for as long as you choose, OPML is said
once and explained, Rust is gone. Headings say what you get ("New
episodes, ready to play", "Cut the dead air", "See what others listen
to"), and the hero opens with iPX by name and says it runs on your own
server, which the tagline used to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:15:39 +00:00
68ac180c9a Home page: DeadAir's text beside its animation, on one row (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:31 +00:00
0875bb9e39 Home page: a DeadAir section, with its silences shrinking (#216)
A waveform in the dial's navy frame: the talk keeps its width, each
silence lights in the needle's orange and shrinks to a short pause, and
the episode ends sooner. CSS only, as the page's CSP allows no script of
its own; with reduced motion it shows the shortened episode, still. The
player section no longer mentions DeadAir, which has its own now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:09:25 +00:00
2dd6e50065 Home page: the tab title is just iPX (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:37 +00:00
91125700d9 Home page: the accounts feature said plainly (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:05:35 +00:00
c0f8424209 Home page: no tagline, no dial caption, nothing about one copy of each file (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:04:40 +00:00
50ffc5c81b Home page: no phone row, the player bar unsquashed, Since 2004 full width (#216)
The player crop had class "bar", which is also the header's class, so the
image took the header's padding and flex layout and was drawn squashed.
It is "player" now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:59:57 +00:00
0045396efd Home page: each feature beside the part of the app that shows it (#216)
The whole-window screenshot is cut into the parts that show each feature: the feed list, the
episode table with its Files pane, the player, the phone, and the directory's most subscribed,
each in the dial's navy frame at no more than its own size. On a phone the wide ones show their
left part, larger. Features with nothing to show sit in a short list below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:06:49 +00:00
8d5a86d02a Home page: the screenshot's panel frames it evenly on all four sides (#216)
With no padding at the bottom, so the window would seem to rise out of the panel, it read as a
border missing a side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:02:19 +00:00
4cb2426b49 Home page: screenshots in the dial's navy panel, the phone in a row of its own; analytics (#216)
The desktop window was a 1440px window shown at 690px, unreadable, with the phone over the
corner that says the most. It is now a 1200px window shown near its size, in a navy panel like
the dial; the phone sits beside its own text, which leaves the feature list for Sign in your way.
On a phone the window shows its left part, larger. The CSP lets Cloudflare Web Analytics run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:59:23 +00:00
700037c368 CLAUDE.md: the home page, where it is and how it is published (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:56:07 +00:00
579fe27b15 Home page: the reader's typeface, screenshots, no source or run sections (#216)
Inter, the same file ipx-web serves, in place of Archivo. Screenshots of a scratch iPX subscribed
to public podcasts (99% Invisible playing; Ologies on a phone), never production's. The Run it
section and the links to the source are out for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:54:59 +00:00
2ee2382c9b Home page: BitTorrent and OPML subscriptions get their own entries (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:33 +00:00
0cb6c84abe ipxreader.app: a home page for iPX (#216)
A static page in site/, built into site/dist by site/build.sh with the logo and icon from web/,
for Cloudflare Pages. The hero's dial is the iPodderX logo's tuning scale at full size, its
stations shows from the directory; the needle sweeps once on load (not with reduced motion) and
lights the one it stops on. One typeface, Archivo, self-hosted, its width axis doing the work.
No script; _headers sets a CSP that allows only the site's own files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:27:31 +00:00
f23274284c docs: ipx-dir.sdf1.net is retired; the directory's pages are public (#214, #215)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:20:30 +00:00
1c6f4eb321 A public, read-only web view of the directory (#215)
The directory renders its own pages from the store: the most subscribed, categories, A to Z,
search, and a page per feed with its address and a Copy button. No token, nothing that writes; /v1
keeps its token check. The publisher's HTML is shown as text, item links only when http(s), with
a strict Content-Security-Policy; covers load from their publishers with no referrer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:16:41 +00:00
bc650153ce The directory is at directory.ipxreader.app (#214)
The zone is in Cloudflare and the rays-unraid tunnel routes the name to ipx-directory, as it
does ipx-dir.sdf1.net, which keeps answering until every server using it has moved: a redirect
to another host would drop the Authorization header. Production ipx shares with the new name.
CLAUDE.md, docs/directory.md, the prod-check skill and the dashboard name it; the admin page's
placeholder and the settings' hint use directory.example.net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:06:06 +00:00
954399ed5d Administer the directory from iPX with an rwa token (#206)
A Directory feed's page offers Check now and Remove to an admin whose token is rwa (the page
learns it from can_admin on the feed's page, asked of the directory only for admins). Admin,
Directory lists the servers sharing, from a new GET /v1/instances, and the blocked feeds by
title: a feed is often blocked because its address carries a key, so the address stays in the
directory. Unblocking or dropping a snapshot sends this server's snapshot again at once; since
directory's 403 reaches the page as 403, not 502.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:40:15 +00:00
10a78efcc3 docs: the three services, their versions and traces (#207, #213)
architecture.md opens on the three services, how a trace crosses them, and the directory's
modules; sso.md's diagram and config show ipx-web in front, trusting Caddy and passing headers
only from TUNNEL_PEER; configuration.md has ipx-web's variables and the Directory as the
service's; cli.md and users.md stop describing the old local Directory; directory.md has its
traces and version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:40:08 +00:00
38c1abba1d Each service its own version: ipx, ipx-directory, the page (#207)
The workspace stops holding one version. ipx keeps 0.12.1-dev and its vX.Y.Z tags; the directory
starts at 0.1.0-dev, tagged directory-vX.Y.Z, and the page at 0.1.0-dev in package.json, tagged
web-vX.Y.Z, which web/build.mjs gives the script for the logo's tooltip beside ipx's. crates/feed
is a library, not released. CLAUDE.md's release steps are for one service at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:35:41 +00:00
fa949f467d A changelog for each service: ipx, the page, the directory
CHANGELOG.md moves to crates/ipx/CHANGELOG.md with its history, which until 0.12.0 was all of
ipx's; web/CHANGELOG.md and crates/directory/CHANGELOG.md start with this release's lines for the
page and the directory. The dashboard's lines go: monitoring is not a service anyone uses.
CLAUDE.md says which file a change goes in, and that a release covers each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:29:10 +00:00
4eab53bf5d prod-check: all three send traces now (#213)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:24:43 +00:00
44e75df99e ipx-directory traces, and ipx's trace follows its calls there (#213)
The exporter setup and the traceparent parser move from ipx into ipx_directory::otel, which ipx
already depends on, so both binaries share them. The directory makes a span a request, a child of
the caller's traceparent, and a trace a refresher round with a span a feed; ipx sends the current
span's traceparent on every call to the directory and wraps each share in a span. The dashboard's
Traces row and the prod-check skill cover all three services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:19:32 +00:00
a3708769a4 Traces first (CLAUDE.md); ipx-web traces its requests (#213)
Caddy's tracing directive, switched by WEB_TRACING so a server without a collector does not log
a failed export every few seconds. It continues an incoming traceparent (the page starts one per
tap, #190) and passes it to ipx, so a request is one trace from the browser through Caddy into
ipx, and Caddy's access log carries its traceID. Checked against Tempo with a probe container:
service.name and deployment.environment.name come from the OTEL_ variables, over OTLP/HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:13:22 +00:00
ee7ab36730 prod-check: every container, Tower and the monitoring, each with its checks
ipx-web logs every request now (#210) and the directory a line a refresher round, so each gets
its own queries; Tower's node metrics and the monitoring's own health get sections after
2026-10-12, when load passed 200 and Prometheus did not come back after Docker restarted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:04:26 +00:00
42f983f81a Phone: a Directory feed's buttons keep their 44px (#212)
The #146 rule lets icon buttons shrink so a feed header's seven fit one row at 390px. It
matched the Directory feed page's row too, where nothing sets the row's width, so the buttons
shrank to their icons: 18px wide, 44px tall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:02:56 +00:00
34a0cdf914 Dashboard: plain row names (#210)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:49:47 +00:00
7ff73f1a8b Dashboard laid out by container: overview, Tower, ipx-web, ipx, directory (#210)
The Daemon row was named for when ipx was the only thing running. Rows now follow how a
request goes: an overview of all three containers, Tower under them (the node exporter, so a
night like 2026-10-12's shfs stall shows up), ipx-web, ipx's API, worker and process, then
the directory, traces and logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:43:56 +00:00
cd099380d1 Dashboard: leave Caddy's healthcheck and start-up notices out (#210)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:36:34 +00:00
751a45f132 Dashboard rows for ipx-web and ipx-directory; Caddy logs requests (#210)
Caddy logs one JSON line a request, filtered: no ?token= in the address and no request
headers, which hold the session cookie and Access's token. Its default log, errors included,
has the same filter, since an error line carries the request too. The directory logs a line a
refresher round, so the dashboard can show its progress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:31:24 +00:00
7ff6f86e7b Share with the directory only when the snapshot changed, or half-hourly (#211)
A scan wakes the sharer, and a scan runs every half minute or so since each checks only the
feeds that are due: Tower sent the same 1,471 feeds every 33 seconds and the directory rewrote
its rows each time. The snapshot, with the address and token it goes to, is compared with what
last went; the same again waits for SHARE_EVERY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:31:24 +00:00
77a414d8df docs: where ipx keeps the directory's token, and replacing it (#205)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:26:02 +00:00
3bab6210c5 CLAUDE.md and prod-check: ipx-web is what serves port 8099 now (#203)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:21:53 +00:00
0c820bbde1 Revert the ::ffff: peer match: #209 was not a bug
Caddy reports an IPv4 peer as plain IPv4 even on its dual-stack socket, so remote_ip
192.168.64.1 matched all along. The stripping seen came from testing on Tower itself, whose
requests reach the published port as 192.168.1.130, rightly not the tunnel. From
cloudflared's network a forged Access token reaches ipx's check and is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:20:02 +00:00
3e58447f12 ipx-web: pass the tunnel's Access headers when it arrives as ::ffff: (#209)
Caddy listens dual-stack, so the ipx_default gateway connects as ::ffff:192.168.64.1, and
remote_ip 192.168.64.1 does not match it: every Cf-Access-* header was stripped, the
tunnel's too. The browser suite binds Caddy to 127.0.0.1, IPv4 only, so it never saw it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:17:22 +00:00
f9870938bf ipx-web: the page in its own container, behind Caddy (#203)
web/build.mjs builds a static site, web/dist, with the icons and typeface
beside the pages; Dockerfile.web serves it with Caddy, which passes /api
and /media to ipx on the same origin, so cookies, live updates and
Cloudflare Access work as before. ipx serves no page any more: build.rs
and every include_str! of the web files are gone, and its image is built
without node.

What ipx did to the page as it served it is kept another way. Caddy asks
ipx before serving a page (forward_auth): /api/page-check sends a browser
not signed in to /login and a non-admin away from /admin, and refuses them
/admin.js; /api/login-check sends someone Access vouches for past the
form; a page asked for with ?token= goes to /api/signin, which sets the
cookie. The theme and the admin link, once written into the page per
person, are set by web/boot.js in <head> from the theme cookie and the
last visit, before the first frame, and /api/me brings the version and
the account's theme from before.

Caddy strips the Cf-Access-* headers from every peer but TUNNEL_PEER, the
address cloudflared's requests come from, which is the boundary
trusted_proxies drew; ipx then trusts only Caddy's address.

The browser suite runs Caddy in front of its daemon, so it tests what
production would run. Not deployed: production's cutover is on #203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:03:22 +00:00
c47aa57909 The Directory comes from the directory service (#205)
ipx asks ipx-directory on its people's behalf, with the token an admin set
in the new Admin, Directory tab, so the page never talks to it and never
sees the token or a feed's address. /api/directory, /api/popular and a
feed's Directory page answer from it; subscribing takes the address from
the directory and goes through Add feed's steps. With no directory set
up, the Directory says so. The local directory code is gone; its privacy
checks are the snapshot builder's now, and their tests moved with them.

With sharing on, ipx sends its snapshot after every scan, whenever the
config changes (set_cfg) and every half hour: each public feed and how
many here subscribe, never an OPML or Patreon creator itself or anything
private. A feed can be marked private in its settings, for a paid feed
whose address does not give it away; anyone subscribed may mark it, only
an admin unmark it. The admin's category for a feed that names none goes
with it, which the directory uses only while the feed names none, so the
category an admin gives a blog still reaches the Directory.

The directory lists only feeds it has read as a feed: the first snapshot
after a start went before the first scan, when an OPML was not yet known
to be one, and the directory listed the OPML. It now fetches what a
snapshot brings in at once instead of within the minute.

The browser suite runs its own ipx-directory on SQLite; the Directory's
tests match feeds by title, since the directory names them by address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:31:32 +00:00
ed82166745 Dockerfile: build ipx alone now the workspace has three crates (#204)
The dependency layer stubs every member, since cargo reads them all, and
builds -p ipx so the directory is not compiled into ipx's image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:51:52 +00:00
83bda97cce ipx-directory: the directory of public podcasts ipx servers share (#204)
A new crate and service, crates/directory, live at ipx-dir.sdf1.net. ipx
servers send the public feeds their people subscribe to (POST /v1/snapshot,
replacing what that token sent before); the directory fetches each feed's
title, artwork, category, description and latest twenty items every six
hours with ipx-feed, and lists them A to Z and by subscribers summed over
every server. It holds metadata only, never a file or a file's address.

Tokens are in its config file as SHA-256 hashes, each ro, rw or rwa, and
the file is read again within a minute of a change, so adding and revoking
one is an edit and nothing else; a revoked token takes its server's counts.
At first they were to come from environment variables, which would have
meant recreating the container to add one. A file that does not load keeps
the tokens as they were.

Every address sent is checked with ipx_feed::url_looks_private, the check
ipx makes before sending, so one server's miss does not publish a paid feed
to every other; an rwa token can remove and block what both miss.

Deployed: ipx-directory in the ipx Arcane project, database ipx_directory
(its own login) on the databases project's Postgres, which db-backup
already covers, and a tunnel route with no Access in front. The store's
tests run on Postgres as well as SQLite when IPX_TEST_DATABASE_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:47:24 +00:00
beded23530 Cargo workspace: crates/ipx and a shared crates/feed (#202)
The first step of the split (#207). The daemon moves to crates/ipx unchanged;
feed.rs becomes crates/feed (ipx-feed), which the directory service (#204)
will share for its metadata refresher and its privacy check.

To stand alone, fetch takes the feed's address and its credentials instead
of ipx's config::Feed (config::Feed::auth gives them), collect_outlines
moves in with the OPML parsing, and the address half of looks_private is
feed::url_looks_private. ipx imports the crate as `feed` at its root, so
every crate::feed path is unchanged. The version is the workspace's, in the
root Cargo.toml.

Checked: cargo test on Postgres (118 + 29), tsc, page smoke, native bridge,
contrast and Playwright (72) pass; the image built from the new Dockerfile
is deployed and healthy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:30:06 +00:00
c2cea0fc5b ipx runs in an Arcane project of its own; docker-compose.yml is a sample (#208)
Production moved from the content project, beside audiobookshelf, immich and
Plex, to a project called ipx, where the frontend and directory of the split
(#207) will join it. The move changed ipx's network, and sign-in through the
tunnel trusts that network's gateway: the new compose file pins the subnet to
192.168.64.0/20 so the gateway stays 192.168.64.1 if the network is remade,
and config.toml's trusted_proxies names it instead of 192.168.16.1. Checked
by sending a forged Access token from a Docker bridge: it reached the token
check and was refused, which only a trusted peer does.

The repo's compose file was a copy of production's that nobody else could
run. It is now a sample: ipx alone, SQLite, folders beside the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:13:00 +00:00
fca704f7ff Add a feed no longer asks for a folder (#201)
A feed's settings stopped showing its folder in 0.10.3; the dialog that adds one
still asked for it. The API keeps its folder for scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:52:22 +00:00
e459d1474c Start 0.12.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:38:34 +00:00
7c3d36b29a Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:37:55 +00:00
78eb255a78 Artwork panel: call the hit rate the hit rate (#198)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:31:48 +00:00
afd3cf448d One Artwork panel: full, hit rate and evictions together (#198)
Evictions are bars on a right-hand axis of their own, since they are images,
not a share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:29:19 +00:00
c28accb8e4 Twenty database connections on Postgres, and a panel that does not cry wolf (#199, #200)
The Database connections panel drew idle connections as their own line, which
sits on the max for ten minutes after any burst and read as the pool maxing out,
while at most two were ever in use. It now draws in use against the max, with
open dashed behind them. The pool itself goes from sqlx's ten to twenty: the
server allows 100 and had about 13 open across every app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:27:21 +00:00
8dc6dd4ce6 Artwork panels: how full, the hit rate and evictions (#198)
Kept bytes against the limit was a flat line once the cache filled. Now a
percentage of the limit beside the share served from disk, and the images each
sweep evicts. The counter shipped minutes ago as ipx_art_trimmed_total is
renamed ipx_art_evicted_total before anything came to read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:17:56 +00:00
f032d50ed3 Graph the artwork thrown out, not the artwork kept (#198)
The sweep trims the cache to its limit, so the kept bytes it reports are the
limit every time once the cache has filled: the panel was two lines flat at
500 MiB. A counter of images thrown out says whether the limit is too small.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:15:11 +00:00
12fadbd0f8 Drop the spent DeadAir clean-up and keep not-due skips off live updates (#196, #197)
The clean-up of DeadAir's old copies probed for its table on every open, and on
Postgres each failed probe is an ERROR in the server's log: 120 an hour from the
healthcheck alone. Production ran it already and is the only database.

A scan sent a not-due skip for each of ~1,535 feeds into a broadcast of 1,024,
so a page slow to read lagged and dropped events: 443 counted in five hours.
Nothing listening uses them; the terminal and the log still get them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:12:22 +00:00
0d930e75ee Prod check reads the page's reports, today's trace steps and DeadAir's events
A seventh check groups the browser's errors and lists play starts and
stalls (ipx::client, #190); the trace check names the steps of an item
list, the feed list, a reap and DeadAir's per-file trace (#187), and says
a trace with no name began in the page (#192), which query.py now labels;
the events list has deadair_done and DeadAir's own lines; and "since the
last deploy" is worked out from the container's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:17:34 +00:00
29f1cbce29 Metrics for the worker's job, the queues and the work in flight (#195)
The healthcheck answers while the worker is stuck on one job, and nothing
showed which job or for how long. STATS, one static of atomics, is kept as
the work happens: the worker's job and since when (a guard, so a failed or
stopped job is counted too), jobs and their seconds by kind, when a scan
last finished, web requests, feed fetches and downloads in progress, bytes
downloaded, live updates dropped for a listener that fell behind, DeadAir's
backlog and how long it has been on its file, and the artwork kept. With
the event queue, the DNS retries and the healthcheck's last counts, /metrics
still reads memory only. The dashboard's Daemon row and the prod-check
skill take them up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:14:54 +00:00
a985cfafef Prod check reads the daemon's metrics from Prometheus (#194)
query.py prom asks Prometheus as it asks Loki, and a sixth check looks for
a pool at its max, a worker or runtime queue that only grows, memory, open
files or tasks that only climb, and restarts by the uptime's resets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:58 +00:00
973eeb67c7 Metrics on a thread and port of their own, from memory alone (#194)
/metrics shared the web server's threads, and answered after a token
lookup and three counting queries, so a pool run dry, one of the things it
is there to show, would have stalled it, and a slow scrape held a web
thread. Now [web] metrics_bind starts a listener on its own OS thread with
a current-thread runtime, reading only what is in memory: pool counts,
channel capacity, the daemon runtime's metrics through its handle, /proc.
No sign-in, as exporters have none; publish the port to the LAN only. The
feed, pending and downloaded counts stay with the healthcheck's log lines,
which the dashboard already reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:04:51 +00:00
9fc1e7e248 Prometheus metrics for the daemon's state between requests (#194)
Logs and traces show requests, scans and downloads; nothing showed what
the daemon holds meanwhile. /metrics, behind sign-in, gives database
connections in use and idle against the pool's maximum, commands waiting
for the worker, live-update listeners, free torrent slots, episodes
playing through DeadAir, the library's counts, the tokio runtime's
workers, tasks and queue, and the process's memory and open files, in
Prometheus's text format, written by hand rather than with a client
library. Not logged or traced: Prometheus asks every 15 s. The iPX
dashboard gains a Daemon row of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:56:54 +00:00
b51df33bb6 Clear dead feeds from the Directory once a day, not before every scan (#193)
stale_unsubscribed takes the newest item of every unsubscribed feed, 2-5 s
warm and up to 12 s in production, and reap ran it before each scan, every
few minutes, holding up the sequential worker's scans and downloads. Its
thresholds are a month dead and a year quiet, so a day is plenty. Kept in
memory: a restart looks again at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:51:14 +00:00
cd9aef36a1 A play's trace holds the playlist, audio and saved place it fetched (#192)
hls.js sends traceparent on its own requests (xhrSetup); a beacon, which
cannot set a header, and Safari's player, fetching the playlist itself,
carry it in the query, which the server reads where there is no header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:28 +00:00
35fca0a6a2 Page an item list from an index, then read only its rows (#188)
All Subscriptions took 130-170 ms when used a minute before, and 0.5-1.9 s
the first time after a quiet spell. Its sort needs each item's date, which
was only in the item's row, so every one of a person's 13,700 rows was
read, show notes inline and all, and cold that came off disk; count_in,
which needs no date, took 20 ms throughout. entries_by_time holds the keys
and dates, the page's keys are sorted and cut from it, and only the rows
shown are read. On a copy of the shape in the test database: 619 pages
read against 4,174, with no visit to the table. Neither change alone
helped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:27 +00:00
3d005da847 Changelog: each past release says what changed since the one before (#191)
Following the rule CLAUDE.md now gives for cutting a release: a feature
added and reworked in one release is one line saying what it became, and a
fix for a bug no release had is gone. 0.11.0's DeadAir fixes join DeadAir,
0.9.1's JSON log, website icons, adding a website and logo are one line
each, 0.10.0's Directory, sort and Popular one, 0.4.0's toolbar, item table
and icons fold together, and 0.2.0 and 0.3.0 lose the fixes to the web UI
and accounts they introduced. The [unreleased] link compares from v0.11.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:18:41 +00:00
6c931d0485 The page reports its errors and playback timings, and its requests carry a trace (#190)
Nothing the page did in the browser reached the logs: a DeadAir playback
failure on the phone was a toast and nothing more. POST /api/client takes
page errors, unhandled rejections, hls.js and media errors, the time from
play to sound and the length of a stall, and logs them as ev lines under
ipx::client, inside the request's span. Only those kinds are taken, the
text cut to 500 characters, and the page sends each at most once a minute.

The page's API requests send traceparent, a new trace id for each tap or
key, and the server's request span takes it as its parent, so one action's
requests are one trace in Tempo, the report included. The stub DOM the
smoke and native-bridge tests run in gains crypto and performance, which
every browser the page runs in has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:15:20 +00:00
9648a79f3f Keep media out of shared caches (#189)
Played from ipodderx.sdf1.net, DeadAir's audio reached ipx as one request
with no Range header: Cloudflare caches by extension, deadair.mp3 has one,
and /media said nothing about caching, so Cloudflare fetched the whole file
and served ranges from its own copy, to anyone Access let in, without the
subscriber check (#129). /media/{id} has no extension and was never cached.
Cache-Control: private on every media response, the save included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:23:27 +00:00
e5b9b0cfdb Steps in the traces of the disk sweep, episode lists and DeadAir (#187)
A 47 s reap had a trace of one span, so whether the time went on checking
every file on disk, the database or deleting could not be told. The reap's
steps are spans now, with what it removed on the reap span; an episode
list's query, sanitising, DeadAir and count are steps; and DeadAir's work
on a file is a trace of its own (probe, silencedetect, frame_bytes,
store_plans), only for files it looks at, not the idle passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:04:59 +00:00
ba9759f44f Gesture tests send a drag's touches at once, not one by one (#184)
Each touch event was awaited before the next went, so on a busy machine a
drag's ten messages took longer than the 800 ms a swipe has, and the page
took it for a slow drag. Reproduced by spacing the messages 110 ms apart:
the swipe back stayed on Second Episode, as the failing run did. The page
is right to want a swipe quick; a finger does it in well under that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:58:26 +00:00
c214b3e79a CLAUDE.md: a release's changelog says what changed since the last release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:41:41 +00:00
e56bc9e8dd Save an episode without its dead air when DeadAir is on (#186)
The save button fetched the original while the player played it shortened.
/media/{id}/deadair sends the original's ID3 tag and then the pieces of the
person's plan one after another, read as it goes: MP3 and ADTS frames each
stand alone, so that is a file any player takes, and nothing is stored, as
with the playlist. The tag stops at the first kept frame, since what can
follow it, the Xing frame, gives the original's length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:38:30 +00:00
b2fe1c725d DeadAir in the box with a show's download settings (#185)
Below the box it read as a setting about something else. It sits after
the download options' fold, not in it, since DeadAir applies whether or
not the show downloads on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:31:28 +00:00
7bec82d053 DeadAir's pause is its breathing room, greyed out while DeadAir is off (#183)
"Pause it leaves" said how DeadAir works, with an "it" to decode, not
what you hear. Greyed out rather than hidden, so the choice can be seen
before DeadAir is turned on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:22:21 +00:00
4ccf2e5511 DeadAir's pause on a slider, shown only while DeadAir is on (#183)
The four segmented buttons gave no sense that 0.2 to 0.5 s is one scale,
and stayed on screen with DeadAir off, where they did nothing. A range
input with a dot at each stop and Brisk/Relaxed at its ends says both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:14:50 +00:00
089202f72b DeadAir plays an HLS playlist of the original, with a pause of your own (#181, #182)
DeadAir made a shortened copy of each episode with ffmpeg: as much disk
again as the original, one pause length for everyone, and a minutes-long
wait after the download. Seeking past silences instead was tried on an
iPhone and left about 1.3 s of nothing at every cut (#178).

Now it finds a file's silences once (silencedetect, plus ffprobe for
each frame's byte position) and stores a plan for each pause a person
can choose, 0.2 to 0.5 s: the cuts, and the frames kept as byte ranges
of the original. /media/{id}/deadair.m3u8 hands this person's plan out
as an HLS playlist of ranges of /media/{id}/deadair.mp3, the original.
Tested on iOS Safari first (#180): no gap at the cuts, plays locked,
seeks exactly, and needs no ID3 timestamp in front of each piece.

Other browsers get hls.js (1.6.19, the light build), loaded only when
needed. Not where canPlayType says it can play HLS: Chromium answers
"maybe" and then fails on MP3 ranges with "stream parsing failed", so
only Apple's browsers play the playlist themselves.

The pause is per person and per show, on deadair_prefs.pause_ms, 0.3 s
unless chosen; 0.2 s, what the copies left, made speech sound rushed.
AAC in MP4 has no ranges a playlist can use, so only MP3 and ADTS AAC
are shortened (the library is 803 MP3s and no .m4a). The copies, their
table and #179's expiry go: the first start drops the table and deletes
the files it names, only those named name.deadair.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:46:01 +00:00
f29d493a0e Remove DeadAir copies unplayed for a week, and remake them on play (#179)
A copy is nearly the size of its original and was kept as long as the
original. Now `deadair.played_at` notes when one was last served (at
most once an hour, since a player asks again at every seek), and each
pass of the maker removes copies unplayed, or unmade, for 7 days. Only
the file goes: the row keeps its cuts with its path cleared, which
tells an expired copy apart from a file that cannot have one (no cuts)
and keeps the background queue from making it again straight away.
Serving the original of such an episode deletes the row, which puts it
back on the queue; the copy then comes back within a pass, though the
page does not switch to it mid-play (#178).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:51:17 +00:00
9ea2a9250c Build the container on Debian trixie for ffmpeg 7.1 (#177)
Bookworm's ffmpeg is 5.1.9, two major versions behind, and DeadAir runs
everything through it. Both stages move, since the binary built in the
first has to find the same glibc in the second.

Checked in the new image on techpod-358 with a 643-term drop expression,
the size that failed with a bsf_list error on 5.1 today: the copy is
352.73s shorter, as 643 cuts of 21 frames at 26.122ms predict, and
decodes without errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:17:42 +00:00
95202ec411 Start 0.11.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:21:49 +00:00
17fbf79db8 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:10:20 +00:00
d6c0e3e7a6 Show that DeadAir is still working on an episode (#176)
A new download in a show with DeadAir on plays at full length until its
copy is made, and nothing said so. Each file now carries
deadair_pending while DeadAir is on for its show and the copy is still
to come (not for one heard through, which it passes over), and the
episode's line and details say "Cutting dead air…". The maker sends a
deadair_done event when it finishes a file, and the page takes the list
again, so the line becomes how much shorter the episode is without a
reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:05:18 +00:00
527a90ed2d Keep a file's buttons on one row in the Files pane (#175)
The pane is 245 px wide above 820 px, and a downloaded file's size and
four buttons did not fit, so delete wrapped under the rest. The row no
longer wraps, its gaps and the buttons' padding are tighter, and the
size gives way with an ellipsis if anything still has to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:05:18 +00:00
a67b949960 DeadAir copes with hundreds of silences, and retries a failure (#174)
Production's ffmpeg, Debian's 5.1, refused the copy of Brad & Will's
359: "Error initializing bitstream filter: bsf_list". The frames to drop
went to it as between(...)+between(...)+..., which nests a level a term,
and 5.1 takes 100 terms and refuses 200; the 6.1 the tests ran on took
all 520. Summed as a balanced tree the same 520 nest about 10 deep, and
5.1 takes 4000 that way, checked in the production container.

The failure was also stored as the file having no copy, so nothing would
have tried it again after this fix. Failures are now remembered until
the next start instead. The cap on cuts drops to 3500, so the argument
stays under Linux's 128 KiB with frame numbers in the millions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:52:28 +00:00
845448a754 DeadAir shortens episodes opened but not played (#173)
The copy maker skips an episode everyone who would hear the copy has
finished, and took finished to be the read mark. Opening an item sets it
as well, so 359: Potatoes From the Sky, opened and downloaded with
DeadAir on for its show, got no copy. Finished is now a place at 90% of
the length or past it, as Currently Listening has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:48:25 +00:00
00bc039964 DeadAir's wording: cut the dead air (#172)
The switch, the button's tooltip, the toasts and the time saved now say
what the name already pictures, the dead air cut, instead of explaining
it as silences shortened each time. The caption says the voice keeps its
own pace, which is the part people doubt, and names the shows to leave
out: stories whose pauses are part of the telling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:32:16 +00:00
111a0e29a4 DeadAir's icon: the silence open when off, closed up when on (#172)
Outlined bars against solid ones read the wrong way round at 20 px: the
outlines ran together and the off icon looked the heavier. The off icon
now has the gap in its middle, the on icon has it closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:27:42 +00:00
827cc7489f DeadAir in the page: a switch per show, one beside the speed, and the time saved (#172)
A DeadAir switch in each show's settings, and the same switch beside
the speed for the show that is playing, drawn as a waveform with its gap
closed up, outlined when off and solid when on, with aria-pressed. A
file the server gives a copy for plays from /media/{id}/deadair. An
episode's line says how much shorter it is, and Settings says how much
DeadAir has saved you.

Turning it on or off mid-episode saves the place with a request it waits
for, not a beacon, then asks for the entry again from the new
GET /api/entries/{feed}/{guid}, whose position is in the clock of the
version it now plays, and carries on there. A copy not made yet plays
from the next time. Turning a show on wakes the copy maker, so the copy
is there in seconds rather than at its next pass.

The browser test plays a real MP3 with long silences, quiet.mp3, from a
show it adds and removes itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:18:41 +00:00
16b0686aab DeadAir is off for every feed until turned on for it (#171)
There was one switch for everything, with a feed kept out by hand; any
feed already subscribed would have been in it the moment the switch was
turned on. Now each feed, or the group it came in, is turned on by
itself, and a feed of a group turned on can still be kept out. The
general switch and /api/me's deadair are gone; /api/me keeps
deadair_saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:49:24 +00:00
63e192767d DeadAir on the server: copies with long silences shortened (#171)
For anyone who turns DeadAir on, a background task makes each episode a
copy with every silence quieter than -38 dB for over 0.35 s shortened to
0.2 s. Nothing is re-encoded: ffmpeg copies the original's frames and
leaves the silent ones out, about 11 s for an hour of MP3 in a release
build. On Brad & Will's episode 359 that is 520 cuts and 3.3 minutes
saved, the copy decodes clean, and it has no isolated spikes in its
silences that the original lacks.

Cuts are made from the middle of each silence, keeping 0.1 s at each
end, so the frames either side of a cut are silent: decoding blends a
frame with the one before it, and an MP3 frame can begin in the bytes of
the frame before, so the first frame after a cut decodes against the
wrong neighbour. Inside a silence nobody hears that; at a word they would.

Positions stay in the original's time and the server converts them both
ways. The iOS and Android apps save positions themselves from their own
player, even while the page is asleep, so converting in the page would
have stored the copy's times as the original's. /media/{id}/deadair
serves the copy at its own address and records that this person is
playing it; a position saved meanwhile is converted from the copy's
clock, as is the length a player measures.

The copy is made by a task of its own, not the worker: a first pass over
a library is hours of ffmpeg, and scans must not wait behind it. It
checks every two minutes, which covers new downloads, files already
downloaded, a switch turned on and a file downloaded again with one rule.
Episodes everyone who would hear the copy has finished get none.
download::remove deletes a copy with its original, and the quota counts
copies.

Settings: the switch is PATCH /api/me {deadair}, a feed kept out is
PATCH /api/feeds/{id} {deadair: false}. GET /api/me gives the switch and
deadair_saved, the seconds saved on episodes heard 75% or more on a copy,
each counted once. Three new tables, so no ALTER.

The image gains Debian's ffmpeg, about 480 MB, in a layer that stays
cached until the base image changes. The player's switch, the feed
setting and the stat come next, after mockups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:49 +00:00
8687e4ff1b Turn Nagle's algorithm off on the web server's connections (#170)
axum 0.7 set TCP_NODELAY on every accepted connection; 0.8 stopped, and serve
never set it, so each response sent in parts held its last small segment
until the browser's delayed ACK came back. On production's light traffic
nothing showed: small responses go out in one write. Under load it did.
tests/load, Nagle on then off: media range requests median 41.8 ms to
10.3 ms, p95 44.4 to 13.2, throughput 39 to 149 MB/s. Browse and sign-in
unchanged within noise; flag saves vary 80-195 ms either way, behind the
scan's writes, not the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:40:46 +00:00
1e7a858e9c Show artwork up to 20 MB, and remember and log what failed (#169)
Five Glass Cannon Network feeds name Patreon covers of about 7 MB, over the
5 MB limit on /api/art. Each page load fetched them all again, refused them,
and logged a 502 per cover, about 84 a day, with nothing in the log to say
which image or why: the reason went only to the browser. The limit is now
20 MB, a failure is answered from memory for an hour, and the fetch failure
is logged with its address and reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:10:18 +00:00
c37bcf816b Start 0.10.4-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:00:01 +00:00
4ac1e6dbbc Release 0.10.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:59:46 +00:00
22a99110e8 Show the accent, not words, past the last item when swiping
Asked for: swiping on from the last item in the reader showed "No more items" in faint text beside
it; the pane is now the theme's accent colour and says nothing, the colour saying it is the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:52:41 +00:00
cb92d5e355 Say nothing in an empty list or an empty reader
Asked for: "Nothing here yet — try Scan now." and "Pick an item to read it." are gone. A search
that finds nothing still says so, as the words typed are the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:50:22 +00:00
e3f0267814 Box the API tokens in Settings
Asked for: the API tokens section of Settings sits in the same rounded box as a feed's download
settings, set apart from the rest of the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:43:35 +00:00
f4a8ba4ea8 Box the download settings instead of hanging them from a rule (#167)
Asked for after the redesign: the switch and what depends on it sit in one box with rounded
corners, in place of the rule down their left side. Drawn in --line rather than --edge, so it shows
on a phone too, where --edge draws no outlines; the indent the rule needed goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:38:26 +00:00
4c1d230044 Put a feed's address at the top of its settings, and drop the shared box (#167)
Asked for after the redesign went out: the box of settings the same for everyone (address, folder,
Directory category) held what a person reading the feed cannot and should not change. The address
moves to the top, under the feed's name, with its copy button, read-only except to an admin, who
can still follow a feed that moved from its failing banner. The folder and the category leave the
dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:32:29 +00:00
ecbc253894 Redesign the feed settings dialog (#167)
One column of label, box and grey paragraph, eight times at the same weight, three screens long on a
phone with Save at the bottom of the third. Approved from the mockups on #167.

- Download automatically comes first, as a switch; how many, the keywords and explicit, which only
  ever applied to automatic downloads, hang beneath it and fold away when it is off.
- How many is Default, Newest or All, said as a sentence with the number in it, over a strip of the
  feed's own newest items, each as tall as it is long and lit where it downloads; dragging along
  it sets Newest. All is 0, which now means all (#166).
- Keywords and hidden words are chips: Enter or a comma adds one, its x or Backspace removes it.
- The feed's own settings, the same for everyone and an admin's to change, sit apart in a box.
- The body scrolls and Cancel and Save stay at the foot.
- Theme colours only; iOS switches, but checkboxes in Classic. openModal takes a class for a
  dialog with a frame of its own, set whole each time so it never outlives it.

A browser test covers the strip, chips, the fold and a save that holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:25:57 +00:00
b4c115d79a Show the iPodderX icon in the top bar in the Classic theme
Classic is drawn after the 2004 iPodderX app, so its top bar shows that app's icon (/icon.png) in
place of the iPX logo, on the app and admin pages. The favicon stays the iPX one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:14:54 +00:00
4e417389fe Catch the feed list up when the page comes back into view (#168)
All Subscriptions said 1 unread when the server had none. The badge is the page's own sum of its
feed list, loaded once and patched only by events the server sends when a scan or a download
changes a feed. Reading an item, marking everything read or unsubscribing sends none, so another
device's page kept its old counts, and an iPhone stops the event stream while the app is in the
background, missing everything meanwhile; the reconnect did not catch up.

The page now takes the feed list and the open list again when it comes back into view and when the
stream reconnects after a drop. A browser test reads an item behind the page's back and checks the
badge follows once the page is shown again; it fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:13:16 +00:00
19f77a21d9 Take 0 as every item wherever the download limit is set (#166)
The feed settings dialog says 0 means every episode, the whole back catalogue, and as the server's
default it does. But merge_policy's cap() made only the server-wide 0 unlimited: a 0 set on a feed,
or on one person's subscription, became a budget of 0, hold_back held every file, and nothing
downloaded. Now 0 is unlimited at every level, as the dialog has always said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:29:19 +00:00
65ca481525 Index the file rows with a file on disk (#165)
Counting a person's files through enclosures_entry, once the count was of their own feeds only, read
every file row of their 146 feeds to find the few with a file: 58 ms of a feed list, more than the
37-50 ms the count over every row of the table had taken. Production holds 15 files among many
thousand rows. A partial index on the rows with a path holds just those, and serves the healthcheck's
count and the Downloaded filter too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:15:17 +00:00
92cc0cf4ec Count only the person's own feeds in the feed list (#165)
The traces #164 added showed feed_list as nearly all of GET /api/feeds, and in it the item count at
184-316 ms, about 70% of the request: it counted every item of every feed in the database, 1534
feeds, for someone reading 146, on every feed list and every live update of one. The file count did
the same over every file row, 37-50 ms.

Both now join the person's subscriptions, as the unread count already did, and so read only their
feeds' rows, through the entries key and the enclosures_entry index, both on feed_id first. The
Directory also reads feed_list, for every feed's title and artwork, and uses no count; a feed the
person does not read is still listed, with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:12:05 +00:00
6a69818ef0 Trace each step of the feed list (#164)
GET /api/feeds took 250-300 ms in production with nothing under it in its trace but feed_summary
spans of a millisecond, so there was no telling where the time went. feed_rows now wraps the
catalogue, the person's subscriptions, the subscriber counts, the pins and feed_list in a step
span each, and feed_list each of its five queries.

db::step makes a span only inside one already: the healthcheck's status every 30 s and the page's
live updates run the same code outside any trace, where each span would be a trace of its own, as
feed_summary's already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:04:45 +00:00
2de5f81202 Ask for the subscription once when saving a feed's settings (#163)
patch_feed asked whether you subscribe, then asked again for the subscription, with a fallback for
none that could never run, since it only got there when there was one. One if-let does both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:55:33 +00:00
4db5ee5c44 Answer a file id that does not exist with 404, not 500 (#162)
POST /api/enclosures/{id}/download and DELETE /api/enclosures/{id} raised a missing row with
anyhow!, which ApiError makes a 500 and logs as an api error. A page left open after a feed was
forgotten asks for exactly that: the caller's mistake, a 404 like /media/{id} already answers, and
as a 500 it turned up as a server error in the production check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:54:17 +00:00
6ddd6444f9 Add a feed through one function, from the CLI and the page alike (#161)
`ipx add` and the page's Add feed were the same steps written twice: find the feed, look for it in
the catalogue by same_feed, add_one, store. They had drifted, and #153's lock had to be put into
both by hand. add_to_catalogue() now does the shared part, under the lock, and answers the id and
whether the feed was there already; each caller still finds the feed itself, so the page can call a
bad address the caller's mistake (400), and then does its own part: the CLI lists or categorises,
the page subscribes and scans. add_one is private to it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:53:40 +00:00
e710103ba6 Check one feed is in the Directory without building the Directory (#160)
A feed's Directory page and subscribing from it checked the feed may be listed by building the whole
Directory, every feed's listing, every file row for Podcasts and Blogs, every subscription, and
looking for the id in it: 0.57 s for a feed's page in production, nearly all of it that.

The rule (not an OPML or creator itself, neither the feed nor its group private) is now listable(),
which the Directory filters by and the two ask of the catalogue directly. One rule, so the
Directory and the check cannot drift apart; a test covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:51:36 +00:00
dc340dd4e9 Look a group's title up once, not once per feed in it (#159)
subscriptions() names each feed derived from an OPML or a Patreon creator after its group when the
group has no folder, and asked feed_summary, three queries, for the group's title once for every
feed in it: the same title each time. Production's 32 Glass Cannon Network shows made that about 96
queries each time it ran, which is every feed list, every live update the page receives, every scan
and every wait between scans. It now asks once a group per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:49:54 +00:00
ebecc12193 Play and delete a torrent that came as a folder of files (#158)
A torrent of several files is recorded as its folder. /media could not serve a folder, and the
reaper's remove_file failed on it, warned "could not delete" on every pass and freed nothing, so it
counted against the quota for good; Delete in the page failed the same way.

download::remove deletes a file or a whole folder, for the reaper and Delete alike, and the reaper
counts a folder by its recorded size, as a directory's own size is not its contents'.
download::playable answers the path itself, or for a folder the largest file in it, which is the
episode, and /media serves that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:49:03 +00:00
33e031dd13 Leave a removed account's feeds as unsubscribing does (#157)
Removing an account deleted the user, and the foreign keys took their subscriptions with them, but
nothing did what unsubscribing does next: a feed nobody subscribes to any more leaves the catalogue
(or stays, if listed, for the Directory). A feed only that person read stayed with no subscribers,
and a feed with none downloads by its own settings, so it went on fetching each new episode, daily,
for no one, until it had been quiet for a year.

remove_feed's body is now leave(), and remove_user() leaves each of the person's feeds with it before
deleting the account, from the page and from `ipx user rm` alike; a feed inside a group of theirs
goes with the group. `ipx user rm` now tells a running daemon to reload, as add and rm do (#120),
since it can change the catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:47:26 +00:00
60cbb7aff6 Count only subscribers' read marks and pins when freeing disk space (#156)
The reaper counted read marks and pins in entry_state from anyone and compared them with the feed's
subscribers. Unsubscribing leaves your rows behind, and the flags endpoint writes them for feeds you
do not subscribe to as well. So an episode read by someone who had left and by one subscriber of
two counted as heard by everyone, and went first under the quota though the other had not played
it; and a pin of a person who had left kept a file, and its item, for good, which can leave the
quota impossible to meet.

Both queries, the reaper's candidates and prune_entries, now count only the marks of people who
subscribe to the item's feed. The old prune test's pin had no subscriber behind it; it has one now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:45:14 +00:00
ec55682c13 Record an episode that turns out to be a torrent, and run it off the worker (#155)
An enclosure advertised as audio whose body is a .torrent was handed to torrent_one from inside
fetch_one, which returned without mark_downloaded, and in a scan nothing else recorded it. It
stayed pending: the page never showed it downloaded, and every scan of its feed fetched the
.torrent again and ran the torrent again, on the worker, for up to the stall limit and then the
seeding time (30 and 60 minutes in production), with no other feed checked meanwhile.

It now goes the way an advertised torrent does: in the daemon, marked downloading and handed to
spawn_torrent, which records it when it finishes; in a one-off CLI run, run there and recorded.
fetch_one answers None for the first, which a scan counts as a torrent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:43:04 +00:00
15291298aa Queue up to 1024 commands, so Download latest 100 does not hang (#154)
The worker's queue held 64 commands, and a web request that queues work waits inside itself for a
free place. Download latest lets you ask for up to 100 and sends one command a file, so past 64
the request waited for the worker to finish whole downloads, one after another, an hour for 100;
Cloudflare gave up on it after 100 s and the page said it failed, though most were queued. Until
it finished, adding a feed, Refresh, Download and socket commands all waited for a place too. A
command is a few bytes; the queue now has room for any one request's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:41:15 +00:00
c426ab6314 Keep two changes to the catalogue made at once from undoing each other (#153)
Each change to the catalogue or the server settings stored the whole thing from a copy it took
earlier, and nothing kept two from overlapping. Adding a feed copied it first and then spent up to
a minute finding and reading the feed, so another person's new feed, an unsubscribe, an admin's
settings, or the scan's own changes (a moved feed's address, a category) made in that minute were
stored over when it finished, with no error. A feed dropped that way left its subscriber's list.

Every change now takes its copy through Ctx::edit_cfg, which holds a lock until store_cfg keeps it,
and store_cfg takes nothing else, so no writer can skip it. Adding a feed takes its copy once it
has found the feed, and before the duplicate check, so two people adding one feed add it once.
reload takes the lock too. `ipx add`, `rm` and `import`, in a process of their own, are outside it,
as the comment on the lock says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:40:17 +00:00
d5edead011 Give up a download after two minutes without data (#152)
The download client had a connect timeout and nothing else, so that a long download is never cut
off (#108). But nothing bounded a read either: a server that stops sending partway without closing
the connection left the download waiting for good, on the one worker, with every scan and download
behind it, while the healthcheck, which the socket answers itself, went on reading healthy. A read
timeout fires only when no bytes arrive, so a long download is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:37:13 +00:00
c7eef3e516 Make Glass's top bar solid on a phone, so the strip above it matches (#147)
In a home-screen app iOS paints the strip behind the status bar and the Dynamic Island itself, in
one flat colour, from theme-color. Glass's bar is see-through, so theme-color fell back to Glass's
page background, #0b0d14 (#eef1f7 light), above a bar that showed #191e34 on average, bluer at the
left and purple at the right: a near-black band, or a white one in light mode.

No flat strip can match a see-through bar, so on a phone the bar is solid, in what it showed on
average (--glass-bar, measured in Chromium off the bar's top edge), and theme-color carries the
same colour. Everywhere else Glass is as it was. The browser test checks the bar is solid and
theme-color equals it in both modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:10:14 +00:00
a12aa59970 Shadow the feed list on a phone only while it is open (#151)
On a phone the feed list is a drawer slid off the screen to the left, and it kept its shadow
there, which fell back across the page: the left 30px or so of the top bar and everything under
it were darker, in every theme (Glass light 231,233,243 at the edge against 246,247,255). Beside
the flat strip iOS paints above the page it made a seam at the top-left corner (#147).

Hiding the closed drawer instead (visibility) would also have done it, but every phone test
starts by waiting for the feed list to be visible, which off the screen it still counts as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:10:07 +00:00
a553931c76 Run the top bar's colour up behind the Dynamic Island on iOS 26 (#147)
46f9370 set theme-color, but Safari 26 ignores it. iOS 26 paints the strip behind the status bar
and the island in the background colour of a sticky or fixed element at the page's top edge, and
failing that the root's. The top bar was neither, and the root had no colour of its own (only
body did), so iOS used black or white: a band of another colour above the bar.

The bar is sticky now, though nothing scrolls under it, so iOS reads its colour; the root has the
page's background for screens with nothing at the top edge; Classic's metal bar gets a colour
under its gradient, the top of the metal, since iOS reads no gradient. theme-color stays, set to
the bar's colour, for iPhones before iOS 26 and for Android. Glass's bar is see-through, so iOS
frosts what is under it there, as the bar itself does.

Taken from developers' reports of iOS 26, not Apple's documentation, and not yet seen on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:50:28 +00:00
46f9370018 Paint the bar around the Dynamic Island in the theme's background (#147)
The page named no theme-color, so the status bar iOS draws around the Dynamic Island was its own
black or white, a band across the top of every theme. The page and the admin page now carry one,
set to the page's background whenever the theme or mode changes; Android's toolbar takes it too.
Not seen on a phone yet: the browser test checks the colour matches, not what iOS does with it.

A browser test at 360px covers this and #146's row of buttons. The stub DOM gains
getComputedStyle, which every browser has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:19 +00:00
70c49921ab Keep a feed's and an item's buttons on one row (#146)
A feed's page has seven 44px buttons, which fit a 390px phone with nothing to spare: on a 360px
one, Unsubscribe went onto a second row by itself. Below that width an icon button now narrows
rather than the row breaking. The reader's buttons were items of its meta line itself, so a long
feed name took them onto the next line one at a time; they are one group now, which moves as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:11 +00:00
0f93d1bd0d Fill a pinned pin button with the accent (#148)
The reader's pin, a feed page's pin and the toolbar's showed pinned only by turning the glyph
solid, in the same grey as the button around it, and at a glance it read the same as not pinned.
Pinned, they are now filled with the theme's accent and the pin drawn in the ink the theme pairs
with it, the pair a pinned feed's disc and a pinned item's row already use, so no palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:51:05 +00:00
4f4f2e8d95 Try a feed again in minutes when its host could not be looked up for the moment (#143)
205 of the Directory's 208 feeds without a category had never been read: on 2026-10-05 at 03:24
UTC every one failed with "dns error: ... Temporary failure in name resolution" within a minute,
during an outage on Tower that also stopped iPX's log reaching Loki until Tower rebooted at 11:56.
They are working podcasts, NPR's, Megaphone's, Acast's, which resolve now. A feed nobody
subscribes to is read once a day and a failing one backs off up to a day (#99), so each sat
failing, unread and so uncategorised (#117) until the next day, and explain_failure called every
DNS error "This address no longer resolves; the site is gone.", on its page and since #142 on its
Directory page. A forced check of the 208 read 205; each has its items, its description and its
own category now.

A temporary lookup failure, EAI_AGAIN, is not recorded on the feed at all: no last_error, no
error_since to back off from or call it gone by. The daemon notes it in Ctx::retry and the feed
is due again in ten minutes, which the scan's due check and the sleep until the next scan both
honour; a restart forgets it, which only makes the retry sooner. A name that does not exist is
still the feed's error, explained as "may be gone", and the Directory page, like the feed's own,
names a cause only once a feed has been failing a day; before that its last check failed.

The page also drops "Nobody here subscribes yet", which said nothing worth a line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:18:42 +00:00
c1b73b3dbe Between releases, the version says a release is in progress: 0.10.3-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:53:35 +00:00
fc0d6bf98b Release 0.10.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:32 +00:00
a44e2b4235 Bring the docs up to what changed: reload, corrections, flags, durability
An audit after 0.10.1 found the docs behind the code. architecture.md named no reload command
(#120) and still said a changed title was picked up, false since #96 until #141; it now says
what a scan writes again, and what the Directory page's endpoint sends (#142). cli.md lacked
add's --list and --category, and that add, rm and import tell a running daemon to read the
catalogue again. users.md did not mention Currently Listening or its search (#127).
configuration.md says that ipx no longer waits for the disk on each write, on SQLite or Postgres
(#135, #140), and what that can lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
2098343621 Say on a feed's Directory page why it is failing, and who subscribes (#142)
daily-quests, one subscriber and no items, showed "Nothing read from it yet. A feed nobody here
subscribes to is checked once a day." on its page in the Directory: the second sentence was
written whatever the count, and the feed had been failing since #119 said so, which the page
never showed, /api/directory/{id} sending no error.

It sends `failing` now, the reason a feed's own page gives (explain_failure), or that the last
check failed, never the error itself, which can name the feed's address, and the Directory names
none. The page shows it under the header in the error colour, and says a feed is checked once a
day only when nobody subscribes and nothing is wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
5684a02e79 Write again a stored item the feed now tells differently (#141)
"APPOFENIACS - A Technology Movie about Human Behavior" in Ain't It Cool News showed 66,170
characters: the feed's own HTTP headers and then the whole feed, tags stripped, and every other
item of the feed carried the text of the one before it. The publisher's feed had served it that
way, as only its XML could make a shift like that, and has since been fixed; ipx had read the
fixed feed and kept the broken text. Since 57ab419 (#96) a scan called record_entry only for a
guid it had not stored, so its UPDATE of title, description, image and duration was never
reached, and no correction, retitled episode or fixed length arrived.

Db::changed_items reads back what is stored for the items the feed lists, not the archive, and
names those it now gives a different title, text, artwork, length or number, a field it leaves
out counting as unchanged; the scan writes those again too. A corrected item keeps its read
state. A test serves a feed broken and then fixed from a socket of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:50:15 +00:00
50c3b9db64 Between releases, the version says a release is in progress: 0.10.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:58:40 +00:00
39ae43c80e Release 0.10.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:42 +00:00
c9964778cb Give SQLite several connections, so requests are answered at once (#136)
SeaORM gives a SQLite pool one connection unless told otherwise, and connect() did not say, so
every request, every scan write and every read went through it in turn. Twenty-five people
browsing in the load tests (#133) got 50 requests a second out of 12 cores, the fastest answers
in 3-30ms and the medians ten times that.

SQLite now gets eight. In WAL readers run beside the one writer. Every transaction here writes
first, so it takes the write lock or waits out the busy timeout for it; SQLite refuses at once
only a transaction holding a snapshot a write has moved past, and none here does. Db::memory
switches its file to WAL too, as Db::open does, so the tests share it as the daemon does. A test
holds a write open and reads beside it, and fails with one connection.

The same browsing: a feed's items at p95 10ms where it was 133, a search 22 where 119, the feed
list 89 where 242. The load tests' SQLite budgets are tightened to match; the Rust tests take
2.8s where they took 7.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:22 +00:00
a553d050ca Have ipx add, rm and import tell a running daemon to read the catalogue again (#120)
`docker exec iPX ipx rm make` printed "removed make" and took it out of the catalogue table, but
the web UI went on listing it. add, rm and import run in a process of their own and write the
whole catalogue there; the daemon kept its own copy in memory, and its next change of its own --
an admin's edit, a feed categorised (#117), a moved feed followed (#115) -- wrote the removed
feed back and dropped any the command line had added.

A new socket command, reload, has the daemon read the catalogue and settings again from the
database. The socket answers it at once, as it answers status, with the counts after it: queued
behind a scan, the web UI could write the old copy back in the meantime. add, rm and import send
it when a daemon is live, and say so if it does not answer. User commands need nothing: the
daemon reads accounts from the database on every request.

A browser test runs the real `ipx add --list` and `ipx rm` against the test daemon, with an
admin's edit after each, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:22 +00:00
9c2f3eaf1b Search Currently Listening from the search box (#127)
Currently Listening's search box said "Search items…" and did nothing: loadEntries returns early
for a place with its own list. Typing now redraws the list with the search sent along, as
/api/entries already takes one, title and text, and says so when nothing in progress matches.
The keys list says / searches it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:21 +00:00
c63f70ad0c Show a feed that answers with nothing, having never posted, as failing (#119)
daily-quests and hots-dots had 0 items and no error, so their subscribers saw shows that had not
posted yet rather than feeds that are gone. Both domains have lapsed, and this network's DNS
filter answers for a parked domain with its block page, over http a 200 with an empty body.
scan_one took a body of nothing but whitespace for "nothing yet" and recorded no error.

It still does for a feed that has stored items, which is what the Antarctic Survey's empty 202
means, but a feed that has never stored one now fails with "the feed's address answered with
nothing at all", which the feed's page explains as an address that answers with nothing, the
site maybe gone. The next real read clears it. A test serves both from a socket of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:55:21 +00:00
bbdcbe213f Run the load tests on Postgres too, as production runs (#139)
node tests/load/run.js --postgres puts the scratch daemon in IPX_TEST_DATABASE_URL, the database
the Rust tests already use on Postgres, ipodderx_test on the databases project's server, in a
schema of its own, ipx_load, dropped and made again each run as /tmp/ipx-load is wiped. The URL
is in /src/.envrc, taken from ipx.env. It is production's server and role, so the harness refuses
a database whose name does not end in _test, and hands psql the password in its environment,
where no other process on Tower can read it. psql comes from /src/install.sh.

After seeding it runs ANALYZE: a schema filled seconds before has no planner statistics until
autovacuum gets there, which it did partway through a test, and the search's p95 swung between
40ms and 166ms from run to run. Analyzed first, three runs gave 37-38ms.

Each budget now has a value for each database, about twice what it measures on Tower: Postgres
answers the feed list at p95 54ms to SQLite's 242, having no one connection to queue for (#136).
Running it found #140.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:31:18 +00:00
8c4a5f396b Don't wait for the Postgres server's disk on every commit (#140)
Running the load tests on Postgres (#139) found a scan reading 1.2 feeds a second: after ten
minutes the first of 1,500 had reached 1,136, where SQLite does them in 27s since #135. On the
databases project's server, production's, 200 single-row inserts took 2,565ms as commits of their
own, 12.8ms each waiting for the WAL to reach the disk (synchronous_commit=on), 56ms with
synchronous_commit=off, and 67ms in one transaction. A feed's entries and enclosures are written
a row at a time, about fifty commits for 20 new items, and production's scans paid the same.

ipx's own sessions now set synchronous_commit=off, through sqlx's connect options, so it holds
on top of whatever options a URL sets, the tests' search_path included. A crash of the Postgres
server can lose the last commits, about the last 0.6s, and never corrupts anything; a crash of ipx
loses nothing; other databases on the server keep their setting. The same first scan takes 20s,
and the Rust tests on Postgres 3.3s instead of 7.1s. The test of SQLite's setting checks this one
too when it runs on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:31:18 +00:00
b43ba89778 Load tests with k6, for what many people at once do to the server (#133)
The browser tests drive one person against a handful of feeds, one request at a time, and cannot
show what production's load does. node tests/load/run.js builds a release binary, serves 1,500
generated feeds from itself, starts a daemon of its own under /tmp/ipx-load, seeds a hundred
listeners with thirty feeds each and four downloaded files, and runs four k6 scripts, with
`ipx status` -- the healthcheck -- run every second and failing the run at its 5s timeout:

  browse     25 people at once: feed list, All Subscriptions, a feed, a search, the Directory,
             a feed's page in it
  listening  100 players saving positions every second and marking items read while scans write;
             each reads its own state back, which must be as it left it
  media      50 listeners seeking: every range checked byte for byte against the served file
  signin     a flood of wrong passwords while others browse, and the gap between refusing a
             known name and an unknown one

Each budget is about twice what it measures now. Getting here found #135 (SQLite waiting for the
disk after every write, a first scan of 1,500 feeds estimated at 50 minutes, now 27s), #136
(SQLite's single connection, left open), #137 and #138 (fixed in the commit before this). k6 is
installed from its own signed apt repository by /src/install.sh, outside this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:38:20 +00:00
f1d360420c Check passwords a few at a time off the async workers, and check unknown names too (#137, #138)
The load tests (#133) sent forty clients' wrong passwords to /api/login, 54 attempts a second,
which takes no account. Everyone else's requests took 4s (median 3.96s for /api/feeds, about 20ms
otherwise) and `ipx status`, the healthcheck, 1.3s (#137): each attempt was an Argon2id check,
tens of milliseconds of CPU, run inside the handler on one of the runtime's workers, so a handful
at once held every worker the rest of the server answers on. And a wrong password for an
account's name was refused a median 31ms later than one for a made-up name (#138), since only a
name with a hash was checked: the answer read the same, the time said which names are accounts.

auth::check_password runs the check on the blocking pool, at most half the cores at once, so a
flood waits on itself, and checks a name with no account, or no password, against a fixed decoy
hash, false in the same time. Under the same flood the rest of the site answers at p95 57ms,
`ipx status` at most 90ms, the gap is 0.2ms, and twice as many attempts are answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:37:35 +00:00
697e907c86 Keep the feed list's icons when it redraws, rather than load each again (#134)
Checking every feed made all the feeds' icons in the feed list flash while it ran. A check sends
each feed's new row as it reads the feed, and renderFeeds, run once a frame while they come in,
emptied the list and built every row anew, every <img> with it: each icon went blank and was
loaded and drawn again, many times a second through a scan of 150 feeds.

The rows are still rebuilt, but the images already drawn go into the new ones in place of the
fresh copies, matched by their markup, so only an icon that has changed is made anew. A browser
test checks the rows are new and the images in them the same elements, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:37:35 +00:00
894dbbe31d Between releases, the version says a release is in progress: 0.10.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:12:53 +00:00
552d20443e Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:09:51 +00:00
367f431e1f Sync SQLite at checkpoints, not after every write (#135)
The load tests (#133) found a daemon on SQLite scanning 1.3s a feed of 20 items served from the
same machine: 1,500 feeds would have taken about 50 minutes. On Tower's /tmp, a loop device, one
synchronous 4 KB write takes 52ms, and SQLite was in WAL mode with its default synchronous=FULL,
which waits for the disk on every commit; nearly every statement is a commit of its own, a
feed's entries and enclosures being written a row at a time. Subscribing the first admin to an
imported catalogue of 1,500 feeds took 39s, before the web server listened.

Every SQLite connection now sets synchronous=NORMAL, SQLite's own advice for WAL: it syncs at
checkpoints. A crash of ipx loses nothing; a power cut can lose the last transactions, and never
corrupts the file. The same scan takes 27s and the same subscribing 0.75s. Postgres, which
production runs on, is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:09:29 +00:00
ef8e2526f2 Show what a feed says it is on its page in the Directory (#130)
A feed's page in the Directory (#128) had its cover, category and latest items, but not the
feed's own description, which Apple's show page leads with: iPX read every item's description
and threw the channel's away, and the feeds table had nowhere to keep it.

It is parsed now, RSS's <description>, or iTunes' summary when that is empty, or Atom's
subtitle, kept in feeds.description (kept when a later read has none, as title and image are),
and sent, sanitized, with the items from /api/directory/{id}, which is now an object, not a
list. The page shows it as plain text under the header, three lines of it, with More when there
is more. A description that only repeats the title is left out.

feeds.description is the first column added to a table that already exists. create_missing
looks for it with a SELECT and runs the ALTER only when it is missing, since it runs on every
open, the healthcheck's included, and an ALTER's lock is what made that time out before. The
same once-only step forgets every feed's ETag and Last-Modified: a feed is read whole only when
it has changed, so its description would otherwise wait for its next item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:22 +00:00
5058adc93e Open a Directory category's fan of covers as one smooth motion (#132)
Hovering a category tile turned its outer two covers further out over 0.2s, and it looked
choppy. Traced in Chromium, the covers were given layers of their own only once the transition
began, so the browser drew them again on its first frames and again on the way back, 22 raster
tasks for a hover in and out, and the main thread produced 34 frames for 0.4s of motion.

Each cover now has its layer before the pointer arrives (will-change), all three move, rotate
and translate are animated as properties of their own rather than as one transform list, and
the fan takes 0.45s on an ease that settles. The same hover is 5 raster tasks, and the
compositor presents 116 frames to the main thread's 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:22 +00:00
9e39f2d756 Draw every Directory category tile on the panel, untinted (#131)
Each tile took a faint tint from a hash of its category's name, as an initials tile does, so
neighbours differed. Five of them, Comedy, Government, Kids & Family, Leisure and Music, drew
--good, and green means yours everywhere else on the page, the subscribed tick and a downloaded
file among them: those tiles read as holding something you subscribe to, when the tint meant
nothing. The fanned covers already give each tile its colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:33:57 +00:00
b86062b97a Keep a feed's items and files to the people who subscribe to it (#129)
Routes that take a feed or an enclosure id did not check who was asking. Anyone signed in could
read any feed's items through GET /api/feeds/{id}/entries, a paid feed's included, with the
addresses of its files, which can carry the subscriber's key: the Directory leaves such feeds
out for that reason, and this route handed them back to whoever guessed the id, a slug of the
title. In production it answered 25 items of a feed the asking account does not subscribe to.
/media/{id} served any downloaded file by its sequential id, POST /api/enclosures/{id}/download
and /api/feeds/{id}/download-latest queued any feed's downloads, and DELETE
/api/enclosures/{id}?force=true deleted any file.

Each now answers 404, "you do not subscribe to that feed", unless the person subscribes to it.
A feed inside an OPML has a subscription row of its own for everyone subscribed to the OPML, so
that holds for those feeds too. Found while adding the Directory's feed page (#128), which has
its own route that answers only for listed feeds and carries no files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 +00:00
95acde3046 Lay the Directory out as Apple's is, with the most subscribed in it (#124, #126, #128)
The Directory was one grid of every listed feed, 1,472 in production, under Podcasts and Blogs
tabs, a wall of about 30 category buttons and a sort menu, with a picked category's
subcategories appearing as a second row of buttons that looked like the first. It read as a
list to scroll, and the two rows were hard to tell apart.

It opens now on the ten most subscribed feeds, ranked, and the categories as tiles, each with
three of its shows' covers fanned in its corner on the tint its initials would get, so the
shows colour it and no theme's palette changes. A category has a page of its own: its most
subscribed, its subcategories with how many each holds, and its grid. See all is every feed,
as the grid was. Podcasts or Blogs holds across every page. Popular, the ten most subscribed,
had a place of its own in the feed list; it is the Directory's first section instead, and its
key, g p, is gone. /api/popular stays for scripts.

The search box finds a feed in the Directory by name (#126). It said "Search items…" there and
did nothing, since loadEntries returns early for a place that lists feeds.

A feed you do not subscribe to opens a page of its own (#128): its cover, category (a link to
that category's page), how many here subscribe, a subscribe button and its latest twenty items,
each with its title, linking to the post where it has one, its first lines, date and length.
Before, a click on it did nothing; only its + did. The items come from GET /api/directory/{id},
which answers only for a feed the Directory lists, so a guessed id reaches nothing private, and
carries no file or its address. The feed's own description would belong at the top, but the
database does not keep one.

Every tile and chart row takes the keyboard, as the feed list's rows do. Feed text made plain
keeps a space at a line break or a paragraph's end, which glued "2010)Recorded" together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 +00:00
0b4f5443b7 File Apple's Football and Soccer under Sports in the Directory (#125)
The Directory listed Football (6 feeds), Soccer (7) and "Soccer," (2) as categories of their
own beside Sports, and Software How-To (2) beside Technology. CATEGORIES spelt two of Apple's
Sports subcategories American Football and Football (Soccer), names no feed uses: Apple's list
says Football and Soccer, so a feed naming either fell through as a category of its own.

They are Apple's spelling now. The old spellings are aliases, since Jev picked from the list
while it had them and its answers are stored, and so is Software How-To, Apple's Technology
subcategory before its 2019 list. A comma or full stop at the end of a feed's category, as in
"Soccer,", is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:12 +00:00
e699baa22a Say plainly when a feed is given a category, in the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:18:18 +00:00
00bd58ac9d API tokens a person makes for scripts and agents to act as them (#123)
The API took a session cookie, a proxy's word or the shared admin token, so a script or an
agent working for one person had to sign in with their password and carry the cookie, or be
given the admin token. Settings now makes named tokens, ipx_ and 256 random bits, sent as
Authorization: Bearer. A token is its owner and no more. Only its SHA-256 is kept, in the
new api_tokens table, with when it was made and last used; it is shown once and revoked from
the same list. An unknown or revoked one gets a 401 rather than falling through to a cookie.

Cloudflare Access still stands in front of the tunnel, so from outside a token needs an
Access service token beside it; docs/sso.md says how.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:15:17 +00:00
cc17b1ddab Sort the Directory by name either way or by subscribers (#122)
The Directory always listed A to Z, as the server sends it. A menu beside its filters now
sorts it A to Z, Z to A or by most subscribers (then by name), in the page, without asking
the server again, and the choice stays while the pane is redrawn, as the filters do. The
blurb no longer says A to Z, since it may not be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:54:32 +00:00
119336a20d Give a pinned item the pinned feed's disc (#121)
A pinned feed has a disc in the theme's accent, its pin in the ink the theme pairs with it;
a pinned item only turned its pin solid in the text colour. The item's pin button now draws
the same disc, as a background inside its 22px button so the row does not move when an
item is pinned. The colours are the ones .fpin already uses; no palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:50:48 +00:00
02a46bb500 Browse the Directory by category, then subcategory, as Apple does (#118)
The Directory had one row of chips holding whatever each feed's category was, a category
(Technology) or a subcategory (Tech News, Video Games) side by side: a podcast's own
<itunes:category> is stored as its subcategory, and Jev's answers (#117) are often
subcategories too, so after the first forced scan the row held 22 chips. /api/directory now
gives each feed's Apple category and subcategory, worked out from Apple's list, and the
page shows the categories, then a picked one's subcategories on a line of their own. A
category that is not Apple's, one an admin typed, stands as a category of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:33:26 +00:00
3e384f34f9 Give a feed with no category of its own one of Apple's (#117)
Most blogs and news sites name no <itunes:category>, so they sat under no chip in the
Directory unless an admin set one by hand. When TYPESAFE_KEY is set, a feed's first read, or
a forced refresh, asks TypeSafe's Jev to pick one of Apple's categories or subcategories from
the feed's title and up to 15 item titles, for a catalogue feed with neither its own category
nor an admin's. The answer is stored as the catalogue's category, so a feed is not asked
twice, and an admin can change it. Only on a first read or a forced refresh to keep the calls
down: feeds already subscribed are categorised by one `ipx fetch --force`.

Tried on eight subscribed feeds on 2026-10-04: XDA, Daring Fireball, Ars Technica and The
Verge as Tech News, The Old New Thing as Technology, John D. Cook as Mathematics,
BoardGameGeek as Games, Pluralistic as News Commentary (0.55). About 2,000 input tokens a
feed at $0.042 a million. The likeliest answer is kept however unsure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:20:00 +00:00
d238681ec1 Name an item without a title from its own text, not "(untitled)" (#116)
RSS 2.0 makes an item's title optional, and some blogs leave it out on purpose: Scripting News
titles almost none of its posts. Fifty rows of "(untitled)" said nothing about any of them.

entryName gives an item its title, or the opening of its text (HTML read through DOMParser, an
inert document that loads nothing; cut at a word near 120 characters), or its file's name, or
its show and date, with a flag for a name that is not a title. The list sets that one in the
regular weight, as the text it is rather than a heading; the reader leaves out the heading so
the post starts with itself; the player, the lock screen, Currently Listening, the native shell,
Share and the queued toast use the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:46:47 +00:00
0395717c14 Put the bytecode cache's ignore line on a line of its own
The previous commit appended it to a file without a final newline, joining it to
.claude/settings.local.json and un-ignoring both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:27:19 +00:00
8ce68a881a Ignore Python's bytecode cache from the prod-check script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:27:07 +00:00
e7c59489ee Announce a followed move as a feed_moved event (#115)
A feed moved to its new address was only logged by follow_move. It is now an event, feed_moved
with the feed and its old and new addresses, so it goes where every other event goes: the log,
with from and to as fields, the admin page's Scans view, `ipx fetch`, and the page, which gets
the feed's new row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:22:28 +00:00
f7f4b466dc Follow a feed that has moved for good to its new address (#115)
A feed whose address answered with a permanent redirect was read through it on every check,
and the catalogue kept the old address: 28 of 147 feeds in production, most http to https, some
to a new path or domain.

Feeds are now fetched with a client of their own that follows no redirects (Ctx::feed_client),
and feed::fetch follows them itself, up to 10 hops, so it sees each one. When every hop was
permanent (301 or 308) it says where the feed ended up, and the scan moves the feed there in the
catalogue (follow_move). A temporary hop (302, 307) anywhere moves nothing. A feed an OPML lists
is left alone, as the OPML would put the old address back, and so is a move onto an address
another feed has. A password goes only to the feed's own host, never to a redirect elsewhere;
reqwest's own following dropped it the same way. Ten hops is a loop, worded as reqwest worded
it so it still reads as redirect_loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:05:43 +00:00
ec8fd5dd86 Sleep until the next feed is due instead of scanning every minute (#114)
The daemon ticked every 60 s and ran a scan pass each time: the sweep, then a check-state query
per feed (about 180) to find which were due. In the six hours before, 293 of 362 passes found
nothing due. Now, after each pass, it works out when the earliest feed is due (due_at, shared
with the scan's own check, over Db::http_states, one query) and sleeps until then: at least
30 s, so a feed that never gets a check time cannot spin it, and at most 10 minutes, so what no
command announces, ipx add or a shorter schedule, is picked up. Commands still wake it at once,
and the first pass after starting runs straight away, as the tick's did. The scan reads every
feed's state in one query too.

The prod-check skill says what to expect now: tens of scans in six hours, and pending as the
real queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:56:13 +00:00
a387012c69 Count as queued only what a scan will download on its own (#113)
Every file in state 'pending' counted as waiting to download: 4420 in production, across 12
shows. Since #97 a scan only downloads among a feed's newest max_new_per_check items, so those
were back-catalogue episodes no scan would take; the real queue was 0.

A new state, 'held': listed and downloadable by hand, but outside the feed's newest items, or of
a feed nothing downloads automatically. Db::hold_back moves a feed's waiting files between
'pending' and 'held' each time the feed is due, changed or not, and again after its items are
stored, so a new episode, a raised limit or auto-download turned on or off moves them. A held
file keeps its item's place among the newest, as a downloaded one does. 'pending' now means
queued, so ipx status, /api/status and the dashboard read true without changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:47:52 +00:00
a00516687a Keep artwork on disk and serve every image from iPX (#111)
The page loaded artwork from each publisher's server, or through /api/art, fetched every time,
for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when
a publisher's server did.

- The page draws every image a feed or item names from /api/art. The first time, iPX fetches
  it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout)
  and keeps it in art/ beside the database, under a hash of its address with its type beside
  it (src/art.rs). Later it comes from disk, which marks it as used.
- art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the
  sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and
  artwork is fetched through iPX each time. Settings saved before it get the default.
- Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP
  with sandbox, so an SVG opened on its own runs no script as iPX.
- The fixture server sends .jpg as image/jpeg, which /api/art requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:50:46 +00:00
38ebc7b02b Move old items' http artwork to https too, for hosts the feed no longer names (#110)
The first pass only asked the hosts the feed's current body names. IGN's feed kept five 2009
items with artwork on assets1/assets2.ignimgs.com, which its feed no longer mentions, so they
stayed on http. A scan now also asks, once per host, the hosts of the feed's stored http
artwork (Db::http_images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:35:51 +00:00
54d827f655 Time out a hung feed, serve the precomposed touch icon, and store http artwork on https (#108, #109, #110)
#108: the HTTP client had no timeout, and scans handle feeds in order, so a hung server held
every scan. Dreamwidth answered 504 after 60-67 s for a day and each scan took 70-80 s instead of
15. A feed fetch, and a Patreon creator's show list, now gets 30 s from connecting to the last
byte (feed::FEED_TIMEOUT); the client gets a 10 s connect timeout, which bounds a download's
start but not a long download.

#109: iOS asks for /apple-touch-icon-precomposed.png first when the site is added to a home
screen; it was a 404 and the only non-feed warning in the log. It serves the same icon.

#110: the page is https and loads no http. Artwork on http came through /api/art (#90) even
when its host serves https too. A scan now tries each http artwork host on https once per feed
(feed::prefer_https) and stores the https address where the host answers with an image,
rewriting that feed's stored items from the same host (Db::secure_images). 4 of the 5 hosts in
production do; cdn.thesecretcabal.com presents another name's certificate and stays on
/api/art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:50 +00:00
e2593eaa50 Put a pinned feed's pin on the corner of its artwork
The pin was a small accent-coloured icon before the feed's name. It is now a disc on the
artwork's corner, where a failing feed's mark is, in the accent and the ink the theme already
pairs with it for primary buttons (checked by tests/contrast.js), so no palette changes. A feed
both pinned and failing keeps the error mark at the bottom corner and the pin at the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:23:40 +00:00
d52ecfcbd7 A listed feed nobody subscribes to downloads nothing (#107)
With no subscribers a feed falls back to its own settings, where auto_download is on, so a
listed feed with audio would have downloaded files for no one. The seeded news feeds carry
only images, which media_types already skips, so nothing was downloaded. Files skipped for it
are judged again, by the new subscriber's settings, on the next scan after someone subscribes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:20:09 +00:00
142610e8b8 ipx add --list or --category updates a feed the catalogue already has (#107)
It refused one already there ("already subscribed as ..."), so a feed added before listing
existed, such as CBC's, could not be put in the Directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:17:49 +00:00
43acc62259 List feeds in the Directory before anyone subscribes, and clear out dead ones (#107)
The Directory showed a catalogue feed only once someone subscribed, and a feed left the
catalogue with its last subscriber, so nothing could be put there for others to find.

- A feed has a listed flag, set by ipx add --list (with --category for the Directory's chip).
  The web page keeps a listed feed in the catalogue when its last subscriber leaves.
- The Directory lists every catalogue feed; Popular still only what people subscribe to.
  popular() reads titles, artwork and categories through Db::feed_list, not three queries a
  feed. Subscribing from the Directory scans the feed at once.
- A feed nobody subscribes to is checked once a day at most.
- clean_directory, in the sweep before each scan, removes from the catalogue and the database
  a feed nobody subscribes to, with no file on disk and not from an OPML, that has failed for
  30 days or published nothing in a year. Run against production first: it removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:13:13 +00:00
65fdab8b13 Between releases, the version says a release is in progress: 0.9.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:52:00 +00:00
4d312a87ec Release 0.9.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:48:16 +00:00
ef5bdb4244 Say what guards the web UI, and warn only without Cloudflare Access (#106)
Every start logged WARN "web ui is reachable off this machine; the token is all that guards
it". A container has to bind 0.0.0.0 for its port to be published, so it fired on every start
of production, and it was out of date: signing in takes an account's password or the admin
token, and through the tunnel Cloudflare Access. It was the only warning in a healthy log. Now
it names what guards it, at info when Access is configured and a warning otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:46:43 +00:00
c7f13eea2f Send a changed feed's row to the page instead of it reloading the list (#105)
After a feed was checked, failed or downloaded a file, and after every item read, the page
fetched /api/feeds whole, about 60 ms for 160 rows, though one row had changed. The live event
stream now knows who is connected and, after an event that changes a feed, sends that person
its row (feed_row), built by the same code as the list (feed_rows, with Db::feed_list asked for
one feed). Marking an item read answers with the feed's row. The page puts the row in place
and redraws once a frame. A routine skip of a feed not due sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:41:43 +00:00
7a134b810f Fetch feeds six ahead in a scan; reload the list only after a scan that checked something (#103, #104)
Fetching was 65-90% of a scan, each feed waiting for the one before: 13 s of fetches in a 20 s
refresh of 32 feeds. The scan now works out which feeds are due, fetches their bodies up to six
ahead in tasks of their own, and handles each in order as before, so database writes,
downloads and OPML syncs stay one at a time. A Patreon creator still fetches in scan_one.

The page reloaded /api/feeds, and /api/settings with it, on every scan_done: the scheduler
scans every minute, so each open page reloaded the list once a minute, 169 times an hour. It
now reloads only when the scan checked a feed, and asks for settings once, on first load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:27:51 +00:00
79bc006a30 Add only what is a feed or links one; refuse the rest (#102)
Adding an address looked for the feed a web page links and, finding none, added the address
as it was: every check then failed, and the sidebar called it a feed that had moved. cnn.com
is one; its page links no feed. find_feed replaces feed_behind_page: the address is added if
it is a feed or an OPML list, the feed its page links if it is a web page that links one (and
that is a feed), and otherwise the add is refused with the reason, from the web page (400, the
dialog stays open) and from `ipx add`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:06:04 +00:00
9084b61bb6 Read an address typed without a scheme as https (#101)
cnn-com was added as 'cnn.com', stored as typed, and every check failed with "relative URL
without a base" before it reached the site to look for its feed. expand_input, which both the
web page and `ipx add` pass the address through, now makes one without a scheme https, and a
protocol-relative //host/path https too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:46:10 +00:00
0cc956b7c6 Forget a failing feed once nobody subscribes to it (#100)
Unsubscribing leaves a feed's row and history, which suits one that worked. One that never did
stayed with its error for good and was never scanned again: cnn-com, added as a bare 'cnn.com'
(#101), sat there failing with no subscriber. The reaper, before each scan, now deletes a feed
that is failing, has no subscriber, is not in the catalogue and has no file on disk, with its
items, file rows, read state and block list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:40:18 +00:00
db87bc0f42 Back off a failing feed exponentially, up to a day (#99)
A feed that failed was tried again on its usual schedule however long it had been failing:
gizmodo's 404, pelgrane's 403, daily-quests' 503 and toddstashwick's redirect loop every hour,
each a request to a site that had said no, a warning and scan time. A failing feed now waits as
long as it has been failing, from error_since to its last check, never less than its usual
interval and never more than a day: 1h, 1h, 2h, 4h, 8h, 16h, then daily on an hourly schedule.
No new column: error_since already marks the run's start and the first success clears it. A
forced refresh skips the due check, so it still tries at once. The feed list's next check
follows the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:33:57 +00:00
527777efdf A download limit means a show's newest episodes, not a pace (#97)
pending() took the newest files still pending, up to the limit, so once a show's latest three
were down, each full read of its feed took the three before them, working back through its
whole history. In production 4420 files (about 310 GB) were queued this way across 12 shows,
all on the default limit of 3, which is meant as "the latest three". It now takes only from the
feed's newest `limit` items with a file. 0, unlimited, still takes the whole back catalogue:
that is how the shows kept as an archive are set, along with limits of 100 and 10000.

The settings' wording followed the old behaviour ("The rest wait for the next scan"); the field
is now "Newest episodes to download", and says what 0 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:50:11 +00:00
4a26c73b82 Clamp an unlimited download queue's LIMIT for Postgres (#98)
With max_new_per_check at 0 and no per-subscription limit, the budget is usize::MAX, and
pending() bound it `as i64`: -1. SQLite reads LIMIT -1 as no limit; Postgres refuses it, so a
feed's downloads failed. The new test fails with "LIMIT must not be negative" on Postgres
without the clamp and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:37:39 +00:00
98ed9b6498 ignore local claude settings 2026-09-29 18:26:33 +00:00
57ab419718 Insert only a feed's new items and files on a scan (#96)
The spans added in 2799704 showed it: in a full scan of 134 feeds (trace da9a419b...,
2026-09-29 17:31, 315 s), storing items took 117 s, fetching 44 s and every other database call
about 2 s together. A scan inserted every item and file the feed listed, stored or not, one
round trip of about 10 ms each; Clarkesworld's 1200 items took 13 s. It now reads the feed's
stored guids and file URLs once (Db::stored_items) and inserts only the rest. A file URL not
among the feed's own may still be another feed's, so that one still goes to the insert, which
finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:40:52 +00:00
2799704d30 Look at a feed's artwork only when it may have changed, and trace a scan's database work (#95, #96)
A feed read in full checked its own artwork and, without one, asked its website for an icon,
every time; a feed without validators is read in full every scan, so looking-for-group spent
2 s of every scan loading lfg.co's home page. Now the check runs when the feed names different
artwork from what is stored, or the scan was asked for, which keeps #80's point: a refresh
still picks up an icon the site changes or fixes.

Feed spans ran seconds past their fetch with nothing to say where (#96). The artwork lookup,
the loop that stores each item, and the per-feed database calls (feed_summary, record_feed,
subscribers, adopt, skipped_by_filter, rehide, pending) now have spans of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:27:31 +00:00
e8fd3fe9ed Load the feed list in five queries, not six per feed (#94)
GET /api/feeds called feed_summary, http_state, blocklist and unread_count for every feed:
about 950 round trips to Postgres for 160 feeds, 320 ms on every page load. Db::feed_list asks
for the feed rows, entry counts, download counts, the person's unread counts and block lists
once each, and the handler reads from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:24:09 +00:00
448e557272 Trace ids, failure kinds and one line per event in the JSON log (#91)
From Dash0's structured logging guide, what applies here:

- Each JSON line inside a traced span ends with its trace_id and span_id, so a line in Loki leads
  to its trace in Tempo; the access log is written inside its request's span so it has one too.
  The JSON formatter takes no extra fields, so WithTrace appends them to the object it writes.
- A feed or download failure carries error.type (the HTTP status, or dns, redirect_loop,
  timeout, ...) and http.response.status_code, from failure_kind beside explain_failure, so
  failures group by kind without a regex over msg.
- Each event was logged twice: words under ipx::scan and fields under ipx::io. It is now one
  line under ipx::scan with both; the wire copy is at debug, for the admin page's Daemon I/O tab,
  and out of production's log. The healthcheck's status reply stays under ipx::io.
- The access log's ms is duration_ms. The dashboard and the prod-check skill follow.
- error fields are Display with the anyhow chain everywhere, not a mix of Debug and Display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:25:53 +00:00
36b16c7f5d Fetch artwork offered only over http for the https page (#90)
Through ipodderx.sdf1.net the page is https, the browser upgrades an http:// image to https,
and a host with no https, such as The Secret Cabal's CDN, answers nothing, so no artwork. On an
https page, the page now asks /api/art for those, and ipx fetches them. It only fetches an
address some feed or entry names as its artwork, and only an image, up to 5 MB, so the route
cannot be pointed at anything else on the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:19:06 +00:00
928cbaf8f4 Show each feed's id labelled in ipx list (#82)
The id led the title's line unlabelled, so antirez.com's, "feed" with no title beside it, read
as a heading; 'ipx fetch antirez' was tried instead and failed. The title now heads the entry and
the id has its own row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:16:25 +00:00
0945f3a9f4 Remove ipx copy-db (#85)
It was the one-off copy from SQLite to Postgres (#18), run once on 2026-09-18. Production has run
on Postgres since; rolling back needs only the old state.db, which is kept, not this command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:15:51 +00:00
e9f2832e1e Show a failing feed on its artwork, in words, and grey (#93)
The mark was a 12px "!" in the sidebar's margin, told apart by --bad alone; a dark theme's --bad
is a pale pink, and at that size it vanished. It is now a solid disc on the artwork's corner, the
subtitle says what is wrong in place of the counts, and a feed failing for a day or more has its
artwork greyed out. Lightness and words carry it, so no theme's palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:17:46 +00:00
d4e00be085 A feed's own artwork has to be there before it is used (#89)
A feed's itunes:image or <image><url> was stored without being asked
for, so a dead one stood in the way of the site's icon. Ken and Robin
Talk About Stuff names http://kenandrobin.wpengine.com/.../kartas_podcast.png,
a 404, while its site's apple-touch-icon works. The feed's artwork now has
to answer as an image, as the site icon already did, when the feed is
read in full; otherwise the site's icon is looked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:16 +00:00
66e40e3c71 A skill for checking production from Loki and Tempo
.claude/skills/ipx-prod-check: what production's JSON log and traces
carry, the queries that find trouble (warnings grouped, failing feeds and
downloads, 5xx and slow routes, whether the worker keeps up, slow and
failed traces), how to tell a publisher's dead feed from an ipx bug, and
filing what is found as issues per CLAUDE.md. query.py beside it runs the
LogQL and TraceQL through a throwaway container on the monitoring
network, since Loki and Tempo publish no query port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:07:24 +00:00
4f8b3d6a1d Log as JSON when IPX_LOG_FORMAT=json (#91)
The log was text, so the Grafana dashboard picked lines apart with
regular expressions, and a change of wording would have blanked its
panels. With IPX_LOG_FORMAT=json each line is one JSON object: the
access log carries method, path, route, status and ms as fields (the
route passed from the routing layer in the response's extensions), and
each wire event its ev, feed, new, downloaded, failed, bytes, msg and
the rest (log_wire), beside the old message. The two startup lines that
were println! are logged, so no line breaks the JSON. Text stays the
default, for a terminal. The dashboard reads the fields with Loki's json
parser, and groups requests by route rather than path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:55:50 +00:00
8ce0a4cb27 A Grafana dashboard for iPX
Built on what the monitoring project already collects: the container's
log in Loki (through Alloy) and the traces in Tempo. It parses the
access log and the event log's wire JSON, so there are no metrics to
add to ipx: what is waiting and downloaded (from the healthcheck's
status), new items, downloads and bytes, failing feeds and downloads,
requests by status and response time, the slowest and busiest paths,
recent and slow traces, and the log. Provisioned from a file, so it is
regenerated here, not edited in Grafana.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:38:29 +00:00
549ae33f06 Keep test daemons out of production's traces and database (#87)
The browser suite's daemon took its environment from the shell running
it, so a shell with OTEL_EXPORTER_OTLP_ENDPOINT set would have sent its
fixture scans to production's Tempo, and one with IPX_DATABASE_URL set
would have run the suite against production's database. Both are now
blanked for it. Production's traces carry
deployment.environment.name=production, which the dashboard filters on,
so a daemon run by hand stays out as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:35:43 +00:00
9e7c93e149 Name request traces by route, and no colour codes off a terminal (#87, #88)
A request's trace was named by its path, so every item's GUID in
POST /api/entries/{feed_id}/{guid}/flags made a trace name of its own and
nothing grouped in Tempo. A route layer now renames it once routing has
matched. It renames the OpenTelemetry span directly: tracing-opentelemetry
drops a recorded otel.name once the span has been entered, and access_log
enters it before routing runs.

tracing-subscriber's fmt layer writes ANSI colour by default, so docker
logs and Loki (through Alloy) carried escape codes on every line, which
each query had to strip. Colour is now for a terminal only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:33:11 +00:00
1724346da7 Send OpenTelemetry traces over OTLP (#87)
ipx had no spans, only log lines, so there was no way to see where a
slow scan, download or request spent its time. With
OTEL_EXPORTER_OTLP_ENDPOINT set, the daemon now exports traces over
OTLP/HTTP (Tempo on Tower): a scan, each feed in it, the feed fetch and
site icon lookup, downloads, torrents, reaps, and web requests. Log lines
inside a span ride along as its events.

Only the daemon exports: the healthcheck runs ipx status every 30s and
would bury everything else. The web event stream and the log view's
polling get no span, for the same reason. The exporter shares ipx's
reqwest 0.13, so no second HTTP stack comes in.

The stderr log now prefixes lines inside a span with it, as
tracing-subscriber's fmt layer does (scan{only=None force=false}: ...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:22:45 +00:00
8c5eddd783 Drop the start-up pass that folds files WordPress listed twice (#83)
Before 0.6.0 the parser took WordPress's numbered player URLs (?_=2) for
separate files and downloaded some episodes twice. Since then it drops
the repeats while reading (same_file_key), and merge_repeated_enclosures
cleaned up what was already stored. Production has run it; on every
start since it has only cost a query that finds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:12:10 +00:00
469467bf04 A site icon is looked up again when the feed is read in full (#80)
The icon standing in for a feed's missing artwork was looked up once and
kept, so a site that changed or fixed its icon, or a feed that dropped
its own artwork, kept whatever was found first. A dead icon stored
before #79 would have stayed dead. It is now looked up whenever the
feed is read in full: when it has changed, or on a refresh someone asks
for, which reads in full since #77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:51:19 +00:00
95a8633877 A site icon that is missing is not used (#79)
site_icon took the icon a site's page names in its <link> tags without
asking for it, so a dead one was stored and /favicon.ico never tried.
antirez.com names /images/favicon.png, which is a 404, while its
/favicon.ico is there; the feed showed no artwork, and since the lookup
happens once, never would. The named icon now has to answer with an
image, as /favicon.ico already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:41:57 +00:00
00f6293b95 The refresh button turns while its feed is checked (#78)
The feed events already put a spinner on the sidebar row, which a phone
hides. The same state now sets scan-this (the open feed, or a feed in the
open folder) and scan-any (any of your feeds) on <body>, and the refresh
icons turn under them. On <body> because the feed page is redrawn as items
come in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:50:41 +00:00
bae22e553e A refresh someone asks for reads the feeds in full (#77)
Check every feed, a feed's refresh, pull to refresh and ipx fetch --force
all send force, which only skipped the not-due wait: the request still
carried the stored ETag and Last-Modified, so an unchanged feed answered
304 and was not read. anil-dash got no site icon from a refresh for this
reason. A forced scan now drops the validators; the scheduled scan keeps
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:47:08 +00:00
7d55d99fac A feed's refresh button looks like its neighbours (#76)
The check-now button on a feed's page, a folder's page and All
Subscriptions was class primary, drawn filled in the accent colour among
plain buttons. Primary stays for a dialog's confirm button; the rules
that only the feed pages used go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:42:58 +00:00
a5de4ae06c Toggle state in the icon's shape, not the theme's colours (#74)
4ed2d59 drew a pressed toggle in each theme's accent colour; the themes'
colours were not to change. Back as they were, pinned rows included. The
read button carries its state in its shape instead, as the pin does with
outline and solid: a tick when read, the envelope when not, where before
it showed the action (the envelope on a read item).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:38:59 +00:00
4ed2d59311 Toggles show their state the same way everywhere (#74, #75)
A sweep of all 24 theme palettes, measuring each icon's drawn colour,
found pinned in three colours: accent in the feed list, the text colour
on an item's row, and uncoloured on the toolbar, beside the title and on
the feed page. The read button showed the action (an envelope on a read
item) beside a pin showing the state. Now each toggle shows what is, with
aria-pressed, and a pressed one is the accent colour; Classic needs its
own rule, as its buttons set their colour at higher specificity. The
contrast test checks the accent on the button grounds, where it now draws.

Directory's Subscribe button carried the Subscribed tick; it is a plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:36:23 +00:00
2004da3459 Feeds from before site icons get one, without waiting for a post (#73)
The icon lookup ran only when a scan got the feed's body, and most feeds
answer 304 to their stored validators, so anildash.com and 68 others
stayed blank until their next post. A feed whose image was never looked
for is now refetched once without validators, as an empty one already
was. A miss is stored as "" (drawn as no art), so neither the refetch nor
the site lookup repeats on every scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:27:00 +00:00
c6980c355d Adding a site's address subscribes to the feed it links (#71, #72)
Both add paths, the CLI's and the web's, look behind the URL first: a web
page that names its feed with <link rel="alternate"> is swapped for that
feed, before the duplicate check so it finds a feed someone already has.
Before, the page itself was added and every scan failed on it.

alternate_feed_link found tags in a to_lowercase() copy and sliced the
original at those offsets; Unicode lowercasing changes some characters'
length, so a page with one before its <link> tags lost the href or
panicked off a char boundary. ASCII lowercasing keeps offsets aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:21:23 +00:00
ce221cee18 A feed with no artwork takes its site's icon (#70)
When a feed names no image and none is stored, the scan fetches the
channel's site link (RSS <link>, Atom rel=alternate) and uses the
apple-touch-icon or icon it names, falling back to /favicon.ico when that
answers with an image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:18:46 +00:00
f57f824535 Each browser keeps its own theme, in a cookie (#69)
The theme was kept on the account, so every browser signed in as the
same person got the same one: no Glass on the phone with Dracula on the
desktop. It is now the ipx_theme cookie (<theme>.<mode>), written by
theme.ts, and read by the server to draw the page in it from the first
frame as before. /api/me no longer reports or takes a theme, and
set_theme is gone.

A browser with no cookie yet is sent the theme the account kept, and
takes it as its cookie on that first load, so nobody loses their choice
in the move. users.theme and theme_mode are only read now, for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:27:20 +00:00
7490a3a9ac A spinner after pulling to refresh (#68)
Letting go of a pull removed its note at once and showed nothing else;
the sidebar's scanning spinner is hidden on a phone. With no sign the
check had started, people pulled again, and again. A "Checking for new
items" pill with a spinner now sits under the top bar until the request
is sent and two seconds have passed, and a pull meanwhile does nothing.
It lives outside #list, which a feed's render rebuilds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:23:31 +00:00
5be629427a /api/status, for Homepage's dashboard (#67)
The iPX tile on Homepage was a bare link: nothing in ipx gave a summary a
customapi widget could read. /api/status serves what `ipx status` prints
(feeds, items pending, files downloaded), from the same function the
control socket answers with, plus the version. It sits behind sign-in
like the rest of /api; Homepage sends the shared [web] token as the
ipx_token cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:05 +00:00
bf785299b0 No logo in the phone's top bar (#66)
The logo moved into the top bar beside the add-feed button (#60). On a
phone that bar is tight: the logo squeezed the search box down to a few
letters, and with no hover there its version tooltip showed nothing.
Below the phone breakpoint it is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:53:34 +00:00
5967aa1e57 Drop Outline from the SSO notes; it is gone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:21:39 +00:00
9e238bfc75 Say in the SSO notes that Authentik's tile cache needs clearing after an edit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:20:14 +00:00
10b7ccd424 Name the Authentik tile iPX in the SSO notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:18:32 +00:00
d8db785681 The tab's icon follows light and dark mode (#64)
The logo on the page switched with the mode (#63), but the tab's icon
was always favicon.png, the light logo. web/favicon-dark.png is
logo-dark.svg at 128px, served beside it, and the theme script points
the icon link at whichever matches data-mode, so it follows the theme
the account chose, not only the system. The sign-in page, with no
account, picks by the system's with two media-bound links.
/favicon.ico, which a browser asks for on its own, stays the light one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:12:11 +00:00
f9c9c2b7cc Modern in the logo's colours, and the logo in the page's mode (#63)
Modern's palette was sampled from the 2004 iPodderX icon: a neutral navy,
its screen blue and amber EQ bars. It now takes the new logo's colours,
the dark half from logo-dark.svg (navy ground, #8fc2ea scale, #ff6a1a
needle) and the light half from logo.svg (sky ground, #2f6aa0 scale, the
needle taken down to #c43e00 so white on it clears AA). The pending amber
and the error red moved apart from the needle's orange, and the sign-in
page's copy of the palette follows.

The pages always showed logo.svg, the light variant, even in a dark
theme; logo-dark.svg was never served. It is now, and the app and admin
pages show whichever matches data-mode, dark until the script says light,
as the palette is. The sign-in page, which has no account's theme, picks
by the system's with <picture>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:05:44 +00:00
3cb36ab8b7 Between releases, the version says a release is in progress (#62)
Production ran four commits past v0.9.0 while the logo's tooltip said
0.9.0, because Cargo.toml's version only moved at a release. It is now
0.9.1-dev, and CLAUDE.md's release steps end by moving to the next -dev
version. No commit hash: the name says there is newer work, and git says
which.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:54:26 +00:00
65b5eacdb4 Settings no longer shows the server's download folder (#61)
The Settings dialog ended with the server's download_dir, read-only and
the same for everyone: it is set in config.toml, so nobody can act on it
from there. The admin page still shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:49:54 +00:00
4769a2ebe1 The logo beside the add-feed button, with the version on hover (#60)
The logo sat at the top of the feed list with "iPX" written beside it,
and the page showed the version nowhere. It is now in the top bar just
before the feed buttons, alone, and its tooltip names the app and its
version.

The version is filled in by the server as it sends the page, not by
build.mjs: build.rs reruns only when web/ or package-lock.json changes,
so a release that bumped only Cargo.toml would have kept the page naming
the one before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:45:35 +00:00
70e0341348 A more vivid orange for the logo's needle (#59)
The needle was #f7931e (#ff9f2e dark), a soft orange close enough in
lightness to the tallest blue bar that the two ran together where they
touch, and weak at favicon size. It is now #ff5500 (#ff6a1a on the dark
background), fully saturated and pushed towards red, away from the bars'
blue. favicon.png and apple-touch-icon.png are re-rendered from logo.svg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:32:07 +00:00
e16dace9c0 On the Unread tab, a swipe back goes to the item just read (#50)
selectEntry took each read item out of the list the moment you moved on
from it, so the item was not there for the back swipe (or k) to reach: it
went to the one before, or to the list if the item had been first.

Items read while turning from one to the next (a swipe, j and k) now stay
in the list until the reader closes or another item is picked from the
list, and a background refresh keeps them as it keeps the open one.
Picking a row still drops the item left behind at once, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:36:29 +00:00
9f56436033 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:42:21 +00:00
ae900b82ca Name the icons by their contents in the pages (#57)
/favicon.png, /apple-touch-icon.png and /logo.svg are kept a day under
fixed names, so after the new logo went out, curl through the tunnel and
browsers still got the old one. The pages now ask for them as
/favicon.png?v=<hash>, the way they already ask for app.js and app.css,
so a changed icon is a new URL for every cache on the way. /favicon.ico
cannot carry a query, as browsers ask for it on their own; it keeps the
day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:11:03 +00:00
40c92ad08d Rename iPodderX to iPX (#56)
The app, the repository (rays/ipx), the image, the compose service and
container, and the data folders on Tower take the new name. What stays:
the 2004 iPodderX and ipodderx-core, which are history; the Postgres
database and login, and ipodderx.sdf1.net with its Access and Authentik
apps, which would each need moving outside this repo; and the first-start
password, as ipx is shorter than the eight characters a password needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:57:02 +00:00
7796566dfc A logo drawn from the radio's screen, to app icon guidelines (#55)
The 2004 icon is a whole radio on a transparent background and not
square, so the tab icon was padded and iOS painted the home-screen icon
on white. web/logo.svg is the radio's screen alone, its tuning scale and
orange needle, laid out as Apple's app icon guidelines ask: opaque and
full-bleed (the system cuts its own corners), a gradient background and
flat foreground layers with hard edges, no highlights or shadows of its
own, nothing thin enough to vanish at 32px. Each layer is a <g>, ready to
split out for Icon Composer. web/logo-dark.svg is the same layers
recoloured.

favicon.png (128) and apple-touch-icon.png (180) are renders of it. The
pages show it from /logo.svg, served outside the auth layer for the
sign-in page, with an app icon's rounded corners. The 2004 icon stays at
/icon.png for anything outside that links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:16 +00:00
162665b010 Phone layout drawn the way iOS draws its apps (#53), clear of the Dynamic Island (#54)
On a phone every control was outlined, hairlines ruled off the bars, corners were 7-9px and
dialogs were centred cards: a desktop page, shrunk. Now, below 820px and in every theme, controls
are filled and round (40px circles and capsules, pills for groups), the feed's name is a 26px
large title, the tabs are a segmented control, rows are 44px with 17px text and an inset hairline,
the reader's type is 17px, and dialogs are sheets from the bottom. --edge is the one switch for the
outlines, left on for the high-contrast theme and for prefers-contrast:more. Glass's desktop radii
moved into a min-width query, because at their specificity they outranked the phone's shapes, and
on a phone its #51 rim only catches the top edge: all round a small capsule it read as an outline.

Text boxes were 13.5px, and Safari zooms the page in on focus below 16px, so tapping search zoomed
it; they are 16-17px on a phone now.

Nothing read safe-area-inset-top, so opened from the home screen the top bar, the reader's back
button and the drawer's head sat under the Dynamic Island. --safe-t pads them, and the phone's own
top-bar rule, a shorthand, had also been discarding the side insets for the notch in landscape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:48:15 +00:00
f2fde8cc75 Swipe between items like turning pages (#52)
On a phone the reader is fixed over the item list, and the swipe from #49 moved it alone and
faded it to 40%, so the list showed through it and in the strip it uncovered, and again as the
next item slid in from the far side. Now a layer beside the reader, #dpeek, holds what is really
there: the next or previous item, drawn by the same detailHtml the reader uses, with 16px of the
page's background between them; "No more items" past the last; or, swiping right from the
first, a dimmer over the list that lifts as the reader, shadowed along its edge, is drawn off.
On release the swipe carries on from where the finger left it, over 120-250ms by how far is
left, and the neighbour becomes the reader in place; with reduced motion it switches at once.

A touch starting within 14px of the left edge is kept from Safari, which otherwise takes it as
Back and leaves the page mid-swipe. 14px because the back button starts at 16.

Offsets are rounded to whole pixels: at a fractional offset the seam between the reader and its
neighbour drew a stray light line.

The design had the list shift a third of the way across as it is uncovered, as iOS does. #detail
lives inside #shell, and a transform there makes it the containing block for the fixed reader,
so that part is left out; the dimmer and shadow carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:46:07 +00:00
a573a07ed5 Glass panels catch the light at their edges (#51)
The glass surfaces had one 1px highlight along the top, which read as flat. They now have a rim
lit from the top-left and a fainter one on the far edges, as box-shadows, and a sheen from the
top-left corner as a background layer. A pseudo-element would have been the usual way, but the
detail pane, file list and dialogs scroll, and an absolutely placed layer in a scroller scrolls
away with the content.

Refraction was looked at and left out again: bending the live page needs backdrop-filter: url(),
which Firefox and Safari lack, and the WebGL libraries (liquid-glass-js, liquidGL) bend a
snapshot of the page that goes stale on a list that scrolls in its own pane.

The sheen lightens a dark panel under its text, so tests/contrast.js now checks every text colour
on a panel under the sheen at full strength. That capped it at 7% in dark mode; 9% put --faint,
--accent and --bad under AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:25:24 +00:00
380a552913 Share a feed, an item or a file (#48)
A share button in the feed's header, beside an item's "Open the original", and on each file.
It uses the Web Share API where the browser has it, which is the share sheet on a phone, and
copies the link where it does not. A file is shared by the publisher's address, not /media/,
which only someone signed in here can open.

Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one
gives the subscription away. An address that looks like that asks first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:17:52 +00:00
bdda9b3d2e Block lists: words that hide items and keep them from downloading (#47)
Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:03 +00:00
868dd673f3 Swipes take a longer drag and slide the reader across (#49)
At a flat 60px, a thumb scrolling slightly on the diagonal moved on to the next item by
accident. A swipe now has to cover a quarter of the reader's width, and never less than 100px,
and the reader follows the finger while it is down, so it is plain before letting go whether it
will move on. It slides off on a swipe and the next item slides in from the other side; a short
one springs back. The CHANGELOG also gets back the [0.8.3] link a stray edit had broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:04:30 +00:00
2930be37ad Release 0.8.4
The Glass theme (#43), playback handed to a native shell (#45), and the
bottom bar kept clear of the home indicator (#46), which had no changelog
entry of its own. The unreleased compare link had been left at v0.6.1 since
0.7.0; it points at the new tag now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 23:29:10 +00:00
Ray Slakinski
f9225f0d21 Keep the bottom bar clear of the home indicator (#46)
body is a grid of topbar / main / status / player, and nothing in the page
accounted for a display's own intrusions. Mobile Safari hides that by
insetting the layout viewport to the safe area, so the site was fine in a
browser -- but a full-screen shell, the native app or the site added to an
iOS home screen, hands the page the whole display, and the last row landed
under the home indicator with its seek bar and times half cut off.

viewport-fit=cover asks for the whole screen deliberately, and the bars
along the edges now pay for the insets in padding: the bottom for the
indicator, left and right for the notch in landscape. A browser with its
own chrome reports nought and nothing moves.

The padding has to be longhand, and there is a comment saying so, because
the minifier drops the space between a calc() and the value after it in a
shorthand -- padding:7px calc(12px + var(--safe-r))7px ... -- and a browser
then throws the whole declaration away. The bars lost all their padding,
which moved the item list far enough that the pull-to-refresh browser test
stopped finding it; nothing reported an error, and the page still loaded.
buildStyle now fails the build on a calc() run into its neighbour rather
than trusting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:10:39 -04:00
Ray Slakinski
9d1492b388 Hand playback to a native shell (#45)
CarPlay and Android Auto cannot render a web view. Both are template
surfaces, and the only audio they will control is the host's own AVPlayer
or ExoPlayer -- so an app that is "the web UI plus CarPlay" is really "the
web UI whose audio engine is native", and the page had no way to give
playback away.

web/src/native.ts replaces the playback surface of the page's media element
with one that forwards to the host and synthesises the events back. Nothing
in player.ts changes: it only ever speaks to the element, so the player bar,
the row buttons, the EQ bars and the keyboard shortcuts keep working as they
did. Video stays in the page, since CarPlay is audio-only and a native video
layer under a web view buys nothing. In a browser none of it installs.

Position and read are the host's to write. player.ts has been bitten before
by a stale position -- one left paused in another tab saved its older place
over where you had got to -- and a backgrounded web view is exactly that
tab: frozen, holding a time from minutes ago, while the host plays on. So
the beacon becomes a request for the host to save its own clock.

tests/native-bridge.js is what holds the two ends together, and it earned
its place immediately: the src setter called removeAttribute('src'), which
the shim's own override turned into a stop() that switched it back off one
line after enabling it. Silent, and only visible in a car. The stub DOM
moved to tests/dom-stub.js so that test and page-smoke share one harness
rather than two copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:49:53 -04:00
ad15ae3dfe Glass theme, after Apple's Liquid Glass (#43)
Translucent panels with backdrop-filter blur and saturation over a soft
coloured wash, light and dark, going solid under prefers-reduced-transparency
and prefers-contrast: more. The sticky filter bar and column headings are
frosted, since the list scrolls under them.

Text on a see-through panel lands on whatever the wash is behind it, so the
palette's hex values alone no longer say whether it clears AA. contrast.js
now samples the wash as the browser composites it on three viewport shapes.
It caught the first light palette at 3.7:1 for faint text, and a tinted
selection that failed everywhere; both were changed.

Left out: SVG displacement-map refraction, which Chromium alone applies to a
backdrop and only on fixed-size shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:45:01 +00:00
aedbe89654 CLAUDE.md: the tea example uses the token issue's real number, #40
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:16:33 +00:00
321c9e014e CLAUDE.md: run tea with a closed stdin and a timeout (#39)
tea comment, run without a terminal, waits on stdin and never exits. The example this file gave had the same problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:15:52 +00:00
73092e6ad7 Stop a hand-run daemon by its PID, not pkill -x ipx (#38)
On Tower the host sees the processes inside containers, so the
pkill -x ipx that CLAUDE.md recommended would have killed production's
daemon in the iPodderX container along with the test one. CLAUDE.md now
says to stop a hand-run daemon by its own PID; docs/cli.md keeps
pkill -x for a plain install and warns about the container case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:12:08 +00:00
1a3c8a6d4f CLAUDE.md: every problem found gets a Gitea issue, closed with a comment once fixed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:11:47 +00:00
b81d44cfb6 docs/sso.md: production checks Cloudflare's token
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:00:19 +00:00
8223cd4445 Release 0.8.3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:49:35 +00:00
c1187a7926 Verify Cloudflare Access's signed token before trusting the proxy
The proxy sign-in believed Cf-Access-Authenticated-User-Email from any
address in trusted_proxies. On Tower that address is the Docker gateway,
so any container there could name itself anyone (docs/sso.md said as
much, and CLAUDE.md listed it as a known gap).

With [web] access_team and access_aud set, a proxied request must also
carry a Cf-Access-Jwt-Assertion that verifies against Cloudflare's keys
(RS256 only, this application's audience, the team's issuer, not
expired), and the name comes from its email claim. The keys are fetched
at start and again when a token names an unseen key, at most once a
minute, so made-up key ids cannot make every request a request to
Cloudflare. While the keys cannot be had, proxied sign-in is refused;
password and token sign-in are unaffected. Both settings empty, nothing
changes.

jsonwebtoken does the checking, on the aws-lc-rs backend already in the
tree through rustls. Tests sign with throwaway keys in tests/data: a
valid token, another app's audience, expired, a forged signature, HS256,
alg none, the refetch limit, and keys that cannot be fetched. Checked
live on a scratch daemon: the header alone and a forged token got 401,
the admin token still signed in.

vouched_name takes the peer and headers rather than the request: a
&Request held across the new await made the auth middleware's future
unsendable, as a body is not Sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:38:25 +00:00
f1f605e180 Keep a Substack subtitle above the post
Substack puts a post's subtitle in <description> and leaves it out of
content:encoded, and body() took content:encoded alone, so the subtitle
was lost (a known gap in CLAUDE.md). A description is now shown above the
body, as <p><em>, when it is short plain text the body does not already
contain. Podcast feeds that repeat their notes in both, whole or cut short
with an ellipsis, are unchanged; the comparison is by words, since a tag
taken out of the body leaves stray spaces around punctuation.

Checked against Experimental History's feed (subtitles appear) and The
Daily's (notes in both fields, shown once). Entries are inserted with ON
CONFLICT DO NOTHING, so only posts first seen from now on get it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:29:50 +00:00
3625cf48fb Keep the web token out of the startup log
The daemon printed http://<bind>/?token=<token> at every start. The token
signs in as the admin, and in the container that line lands in docker
logs, readable by anyone with Docker access on Tower. It now says where
the token is kept instead; config.toml already has it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:27:06 +00:00
145 changed files with 31057 additions and 9423 deletions

View File

@@ -5,5 +5,18 @@
"Bash(env)",
"mcp__outline__read_document"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.rs) rustfmt --edition 2024 \"$f\" 2>/dev/null || true;; esac"
}
]
}
]
}
}

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(rtk grep *)",
"Bash(rtk read *)",
"Bash(rtk git *)"
],
"additionalDirectories": [
"/config/.claude/skills/security-audit",
"/config/security-audit-skill",
"/config/.cargo/registry"
]
}
}

View File

@@ -0,0 +1,414 @@
---
name: ipx-prod-check
description: Look for problems in production ipx (the ipx-web, iPX and ipx-directory containers on Tower, Tower itself and the monitoring that watches them) from their logs in Loki, their traces in Tempo and the metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
---
# Checking production ipx
Production is three containers in the Arcane project `ipx` on Tower
(`/mnt/fast/arcane/projects/ipx/compose.yaml`), one request passing through them in this order:
| container | what | logs, in Loki | traces, in Tempo | metrics, in Prometheus |
|---|---|---|---|---|
| `ipx-web` | Caddy: serves the page, passes `/api` and `/media` to ipx (#203). Port 8099, the LAN and the tunnel | `{container="ipx-web"}`, JSON: a line a request (`http.log.access`), its own errors | service `ipx-web`: a span a request, passing `traceparent` on to ipx (#213) | none |
| `iPX` | the daemon: API, scans, downloads, DeadAir | `{container="iPX"}`, JSON (`IPX_LOG_FORMAT=json`) | service `ipx`; passes `traceparent` to the directory | `job="ipx"`, its `/metrics` |
| `ipx-directory` | the directory of public podcasts, `directory.ipxreader.app` (#204, #214) | `{container="ipx-directory"}`, JSON (`DIRECTORY_LOG_FORMAT=json`) | service `ipx-directory`: a span a request, joining ipx's trace when ipx called; a trace a refresher round (#213) | none |
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
dashboard `iPX` draws all of it in that order (`grafana/dashboard.py`). A trace without the
production tag (`resource.deployment.environment.name="production"`, on all three) is a daemon run
by hand, not production. Since #213 a request is one trace across the services it touched: a
Directory page is ipx-web, then ipx, then the directory, so start from the trace, not the logs. Alloy ships the logs; Loki, Tempo and
Prometheus publish no query port to this machine, so use the script beside this file:
```sh
Q=.claude/skills/ipx-prod-check/query.py
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
$Q metric '<LogQL metric>' [since] # $range becomes `since`
$Q traces '<TraceQL>' [since] # slowest first
$Q spans '<TraceQL>' [since] # the matching spans, each with its own time, slowest first
$Q trace <trace id> # one trace as a tree, with its log lines
$Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `since`
$Q flame '<TraceQL>' [since] [out.svg] # where the matching spans' time went, summed over up to
# 100 traces: a flame graph, and out.svg.folded for speedscope
```
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
"Since the last deploy" is since that container started (each deploys on its own):
```sh
for c in iPX ipx-web ipx-directory; do S=$(docker inspect $c -f '{{.State.StartedAt}}'); echo "$c $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m"; done
```
Start every check by seeing that all three run, since Loki cannot see a container that is not:
```sh
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
```
All three should be `Up`, with no recent restart; they have no Docker healthcheck (#218). Then work through ipx (the checks below), the page, the
directory, Tower and the monitoring itself, in that order.
## ipx
### What the log carries
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
`ipx::scan` and fields under `ipx::io`.)
| target | fields | what |
|---|---|---|
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
updated), `download_done` (bytes), `download_error` (msg, url),
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
downloaded: the healthcheck's, every 30s), `deadair_done` (feed, enclosure, `saved_s` at the
default pause), `error` (msg). DeadAir also logs `DeadAir found the silences` (`cuts`, `saved_s`,
`took_ms`) under `ipx::deadair`, and warns `DeadAir could not read a file` when ffmpeg fails on
one, which it then passes over until the next restart.
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
`| json | __error__=""` drops them.
### What Prometheus carries
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves
`/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
port of its own, from memory alone, and Prometheus scrapes it every 15 s as `job="ipx"`. Kept
from 2026-10-11 15:15 UTC. All gauges:
| metric | what |
|---|---|
| `ipx_db_connections{state="in_use"\|"idle"}`, `ipx_db_connections_max` | the database pool: in use reaching the max means requests and scans wait for a connection |
| `ipx_worker_queue` | commands waiting for the sequential worker (scans, downloads, adds) |
| `ipx_event_listeners` | open pages and socket clients on the live-update stream |
| `ipx_torrent_slots_free` | of 2 |
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets; mimalloc gives back what is freed, so memory follows use and a rise that never comes down is a leak (#224) |
| `ipx_feed_fetches_total`, `ipx_feed_bytes_total` | counters: feeds fetched (304s included) and the bytes of their bodies, read whole, so what scans allocate |
| `ipx_uptime_seconds`, `ipx_build_info{version}` | since the daemon started; what runs |
| `ipx_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; the healthcheck passes while it is stuck |
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
| `ipx_last_scan_age_seconds` | since a scan last finished; absent until one has since starting |
| `ipx_http_requests_in_flight`, `ipx_scan_feeds_fetching`, `ipx_downloads_active` | work in progress |
| `ipx_download_bytes_total` | counter; `rate()` is the download speed |
| `ipx_event_queue`, `ipx_events_missed_total` | live updates unread by some listener; counter of those dropped for one that fell behind |
| `ipx_deadair_backlog`, `ipx_deadair_busy_seconds` | files of shows with DeadAir on not yet looked at (episodes everyone heard through stay counted); how long it has been on its file |
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
| `ipx_signals_judged_total{user,key}`, `ipx_signals_hits_total{user,key}` | counters: items Jev judged for Signals, and Signals they fit, by person and whose key paid (`server` or `own`, #278) (#254) |
| `ipx_jev_requests_total{result="ok"\|"key_refused"\|"malformed"\|"rate_limited"\|"error"}`, `ipx_jev_seconds_total`, `ipx_jev_paced_seconds_total` | counters: calls to TypeSafe's Jev, Signals' and icons', by how they ended; their seconds, the wait for a turn included; and that wait alone. `malformed` is ipx's request at fault (#257); `rate_limited` is a 429 from TypeSafe, whose limits are 80 calls and 100,000 input tokens a second, which ipx paces itself under at 60 and 80,000 (#277) |
| `ipx_jev_tokens_total{kind="input"\|"output",user,key}` | counter: tokens TypeSafe counted, as its answers say, by person and whose key paid: what Signals, icon picks and key checks cost (#275, #278). A call shared on the server's key is split by each person's share of its questions |
| `ipx_signals_last_pass_age_seconds`, `ipx_signals_keys_refused` | since the Signals judge last finished a pass (at most 5 minutes when well; absent until one has); people whose key TypeSafe refused |
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info` | the directory's, `job="ipx-directory"`, from its `metrics_bind` (production `192.168.1.130:9188`, LAN only) |
| `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
### The checks
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
a count says something happened, the lines and traces say why.
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
```
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
```
Feed and download failures name the feed in the message; group them in the next check instead.
2. **Failing feeds and downloads.**
```
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
```
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
3. **Server errors and slow requests.**
```
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
```
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
daemon should not be restarting on its own.
```
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
$Q logs '{container="iPX"} |= "daemon started"' 7d
```
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
and the lines just before it. A pending count that only grows means downloads are not
keeping up or not running.
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
the real queue (#113): files a scan will download on its own. Back-catalogue files are
`held`, listed but not counted, so it is usually 0 or a handful.
5. **Slow and failed traces.** Look at ipx's requests as spans, not traces: a trace started by a
tap in the page also holds that page's live updates and any audio it streamed, minutes long,
so a list of traces over a few seconds is nothing but those. `spans` lists each matching span
with its own time and route, slowest first:
```
R='resource.deployment.environment.name="production" && resource.service.name="ipx"'
$Q spans "{$R && span.http.route != \"\" && span.http.route != \"/api/events\" && span.http.route !~ \"/media/.*\" && duration > 1s} | select(span.http.route)"
$Q spans "{$R && name=\"step\" && duration > 500ms} | select(span.of)"
$Q traces '{resource.deployment.environment.name="production" && status = error}'
$Q trace <id>
```
Group the first by hour and by route (`awk '{print substr($3,1,2), $NF}' | sort | uniq -c`),
then lay the hours beside Tower's (the Tower section): load over 100 with processes blocked
and memory under 4 GB is the host stalling, and every request of those minutes is slow with
it, ipx's queries included. On 2026-10-18 that was 120 of 130 requests over a second, up to 43 s.
What is left at quiet hours is ipx's own: open its trace and see which step holds the time. A
request far longer than its steps added up waited outside them, for a connection or a thread.
The second line finds steps over half a second wherever they ran; a step slow only once, first
thing after hours idle, is Postgres reading from disk what it no longer had in memory (All
Subscriptions' `entries_in`, a second where it is 60 ms warm).
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
`feed` or `fetch` span holding most of it (`$Q spans '{... && name="feed" && duration > 10s}'`),
or a `download` far slower than its size explains.
Steps are `step` spans named by `of` (#187): an item list's `entries_in` (with the list asked for: `filter`, `sort`, `dir`, `offset`, `limit`, `search`), `sanitize`,
`deadair`, `count_in`; the feed list's `catalogue`, `feed_list` and its queries; a `reap`'s
`missing_files`, `prune_abandoned_failures`, `reap_candidates`, `clean_directory` (once a day
since #193, so a reap is about a second), `trim_artwork`, with what it removed on the `reap`
span. DeadAir's work on a file is a `deadair` trace (feed, enclosure, cuts) with `probe`,
`silencedetect`, `frame_bytes`, `store_plans`: seconds each, more is a file ffmpeg struggles
with. A trace with no name in `$Q traces` started in the page (#190, #192): its root is the tap
in the browser, which sends no span, so it holds the requests that tap made (API calls, the
playlist and audio of a play, position saves, the page's own report) side by side.
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
hint at.
```
$Q prom 'up{job="ipx"}'
$Q prom 'max(max_over_time(ipx_db_connections{job="ipx",state="in_use"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_db_connections_max{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_worker_queue{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_runtime_queue{job="ipx"}[$range]))' 24h
$Q prom 'max(resets(ipx_uptime_seconds{job="ipx"}[$range]))' 24h
$Q prom 'ipx_process_resident_bytes{job="ipx"} - ipx_process_resident_bytes{job="ipx"} offset $range' 24h
$Q prom 'ipx_process_open_fds{job="ipx"} - ipx_process_open_fds{job="ipx"} offset $range' 24h
$Q prom 'ipx_runtime_tasks{job="ipx"} - ipx_runtime_tasks{job="ipx"} offset $range' 24h
$Q prom 'max(max_over_time(ipx_worker_job_seconds{job="ipx"}[$range])) by (kind)' 24h
$Q prom 'max(max_over_time(ipx_last_scan_age_seconds{job="ipx"}[$range]))' 24h
$Q prom 'sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_http_requests_in_flight{job="ipx"}[$range]))' 24h
$Q prom 'sum(increase(ipx_events_missed_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
$Q prom 'sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$range]))' 24h
$Q prom 'max(max_over_time(ipx_signals_last_pass_age_seconds{job="ipx"}[$range]))' 24h
```
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
above 0 for more than a scrape or two means every thread is busy: something is blocking a
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
is a leak (a file or socket not closed, a task never ending); a step after a deploy is not. A
reset of the uptime is a restart: match it with a deploy, or it is a crash (check 4).
The worker: one job over 30 minutes (`ipx_worker_job_seconds`), or a scan last finished over
15 minutes ago, is the worker stuck, which the healthcheck does not notice; its trace and the
log lines of that time say on what. A kind whose share of `ipx_job_seconds_total` jumps is
where the worker's time went (reap was most of it until #193). Web requests in flight near
the pool's max mean the pool is what they wait on. Live updates missed mean a page showed stale
counts until reloaded. DeadAir over a minute on one file is a file ffmpeg chokes on.
Any `rate_limited` Jev call means the pacing is not holding ipx under TypeSafe's limits (#277); each
`jev` span has `paced_ms`, its wait for a turn. Any `malformed` Jev call is ipx asking wrongly (#257); `key_refused` is someone's key; the Signals
judge over 10 minutes since a pass is stuck on Jev: its `signals` trace shows which `jev` call.
7. **What the page saw.** The browser's own errors and timings, which no server line shows.
```
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
```
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
## ipx-web, the page
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
`request_uri`, `request_method`, `request_remote_ip`, `status`, `duration` (seconds, not ms), `size`.
The request's headers and a `?token=` are filtered out before it is written. Requests from
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx and
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
```sh
W='{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
$Q metric "sum by (status) (count_over_time($W [\$range]))"
$Q metric "sum by (request_uri) (count_over_time($W | status >= 500 [\$range]))"
$Q metric "topk(10, quantile_over_time(0.95, $W | request_uri !~ \"/(api|media)/.*\" | unwrap duration [\$range]) by (request_uri))"
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"'
```
A 502 is ipx not answering Caddy: a restart (match the deploy) or ipx down; Caddy's error line
names it (`dial tcp ...:8099: connect: connection refused`). The page itself (`/`, `/admin`)
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: look it
up in ipx's access log. 401 and 303 on `/` are people signed out being sent to `/login`, not a
problem. Caddy's own notices at each start (no admin endpoint, no HTTPS or HTTP/3, its goodbye on
SIGTERM) are by design and filtered out above.
Each access line carries the request's `traceID` (`| json` gives `traceID`): give it to `$Q trace`
to see where the time went, Caddy's span with ipx's inside it. A slow page with ipx's span short is
Caddy or the network; a long one is ipx.
```sh
$Q spans '{resource.service.name="ipx-web" && span.url.path != "/api/events" && span.url.path !~ "/media/.*" && duration > 2s} | select(span.url.path)'
$Q traces '{resource.service.name="ipx-web" && status = error}'
```
Who reaches it: `request_remote_ip` is `192.168.64.1` for the tunnel and anything else on Tower
(Docker's gateway), `192.168.1.130` for Tower itself, a LAN address for the LAN. Caddy passes
Cloudflare Access's headers on only from `192.168.64.1` (`TUNNEL_PEER`), and ipx trusts them only
from Caddy, with a valid signed token. Testing sign-in from Tower proves nothing about the tunnel
(#209): send it from a container on another network.
## ipx-directory
It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `status`,
`duration_ms`, `token`, the token's name and never the token), a line a snapshot (`message`
`snapshot`: `instance`, `accepted`, `refused_private`, `refused_blocked`, `refused_invalid`), a
line a refresher round (`feeds checked`: `checked`), a line a failed feed check (`feed check
failed`: `feed`, the directory's id, and `error`), the admin's actions (`feed removed and
blocked`, `feed unblocked`, `snapshot dropped`, each with `by`), and `config read again` when its
token file changes. It has no metrics. Its traces (#213): a span a request (`http`), a child of
ipx's when ipx called with a `traceparent`, and a trace a refresher round (`refresh`, `feeds`)
with a `feed` span for each check. Its log lines carry no trace id; find the trace by time or by
the feed.
```sh
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
$Q traces '{resource.service.name="ipx-directory" && name="feed" && duration > 10s}'
$Q traces '{resource.service.name="ipx" && name="directory_share"}'
```
A slow Directory page in ipx is usually its `http` span in the directory: the trace says whether.
A refresher round far over a minute is one publisher's feed holding it, the slow `feed` span.
```sh
D='{container="ipx-directory"} | json | __error__=""'
$Q metric "sum by (status, token) (count_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" [\$range]))"
$Q metric "topk(5, max_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" | unwrap duration_ms [\$range]) by (path))"
$Q logs "$D | level=~\"WARN|ERROR\""
$Q logs "$D | message=\"snapshot\"" 7d
$Q metric "sum(count_over_time($D | message=\"snapshot\" [\$range])) by (instance)"
$Q metric "sum by (feed, error) (count_over_time($D | message=\"feed check failed\" [\$range]))" 7d
$Q metric "sum(sum_over_time($D | message=\"feeds checked\" | unwrap checked [\$range]))" 24h
```
Expect a snapshot from each server when what it shares changes and every half hour, so about 48 a
day each; one every half minute is ipx resending what has not changed (#211, fixed). A burst of
401s with an empty `token` is someone without a token trying the public address, expected now and
then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A snapshot whose
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
in that server's log, since the directory refuses it but the sender should not have sent it. Many
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
`databases` project's Postgres). A config that does not load (`config changed but does not
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
`crates/feed`.
## Tower
The three share Tower with everything else, so a slow or failing ipx can be the host. The node
exporter (`job="node"`) has the history; twelve threads.
```sh
$Q prom 'max_over_time(node_load1{job="node"}[$range])' 24h
$Q prom 'min_over_time(node_memory_MemAvailable_bytes{job="node"}[$range]) / 1e9' 24h
$Q prom 'sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$range]))' 1h
$Q prom 'max by (device) (rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$range]))' 1h
```
For when, step back with `offset`: `max_over_time(node_load1[10m] offset 60m)`. Load far above
twelve with little CPU busy is processes waiting, on a disk or on the user shares: on 2026-10-12
debug builds linking through `/mnt/user` (shfs, FUSE) took load to 248 with mostly system time and
stalled everything on the shares, ipx's downloads included. Available memory near nothing makes
it worse. Disk busy near 100% on `nvme1n1` is `/mnt/fast`, appdata and Postgres's data; on `md*`
the array behind `/mnt/user`, the downloads. Say what was running at the time if you can tell
(`docker stats --no-stream`); a host problem is the user's, not an ipx issue, unless ipx caused it.
## The monitoring itself
A quiet dashboard can be a dead exporter. Check what Prometheus scrapes and that each
container's lines are reaching Loki:
```sh
curl -s http://192.168.1.130:9090/api/v1/targets | python3 -c 'import json,sys
for t in json.load(sys.stdin)["data"]["activeTargets"]: print(t["health"], t["labels"]["job"], t["scrapeUrl"], t.get("lastError","")[:80])'
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [1h]))" 1h | tail -1)"; done
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
```
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
restarts, one can be left `Exited (255)` though its policy is `unless-stopped` (Prometheus, on
2026-10-12, when Docker lost its exit during an overload): `docker start` it and say so. blocky
and nut on 192.168.2.21 are another machine, not ipx's; leave them. A container with no lines in
an hour while it runs means Alloy is not shipping it; Alloy can take a few minutes to pick up a
container after a deploy.
## Rules
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
other query, ask the user first.
## What to do with what you find
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
twice; comment on the existing issue with the new evidence instead.
```sh
R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues list $R --state open
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
```
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
Finish with a short report to the user, a line for each of ipx-web, ipx, the directory, Tower and
the monitoring: what is healthy, what is wrong (with the issue numbers), and anything you could
not tell from logs, traces and metrics alone. Do not fix things unless asked; the
check is for finding them.
## When the checks come back empty
Check that there is data before concluding all is well (the monitoring section's per-container
count): iPX should have hundreds of lines an hour or more, ipx-web at least its healthchecks'
120, ipx-directory a few dozen. Nothing at all means Alloy is not shipping it, or the container
is down.

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Ask production's Loki, Tempo or Prometheus a question, from anywhere that can run docker on Tower.
query.py logs '<LogQL log query>' [since] lines, newest first
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
query.py traces '<TraceQL query>' [since] matching traces, slowest first
query.py spans '<TraceQL query>' [since] the matching spans, each with its own time
query.py trace <trace id> one trace's spans, as a tree
query.py prom '<PromQL query>' [since] one value per series, now; $range is `since`
query.py flame '<TraceQL query>' [since] [out.svg] where the matching spans' time
went, summed over up to 100 traces, as a
flame graph (and out.svg.folded)
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
host, so each call runs a throwaway alpine container on the monitoring project's network.
"""
import base64, html, json, subprocess, sys, time, urllib.parse, zlib
NET = "monitoring_default"
def fetch(url):
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
capture_output=True, text=True, timeout=120)
if out.returncode:
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
return json.loads(out.stdout)
def fetch_many(urls):
# One container for them all: a docker run apiece is about a second, a hundred traces a minute and a half.
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "sh", "-c",
'for u; do wget -qO- "$u"; echo; echo @@; done', "--", *urls],
capture_output=True, text=True, timeout=600)
return [json.loads(part) for part in out.stdout.split("\n@@\n") if part.strip()]
def trace_spans(r):
return [sp for b in r.get("batches", r.get("resourceSpans", []))
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
def span_hex(sid):
# A trace's span ids are base64 and the search's are hex.
return sid if len(sid) == 16 and all(c in "0123456789abcdef" for c in sid) else base64.b64decode(sid).hex()
def label(sp):
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
return f"step:{attrs['of']}" if sp["name"] == "step" and attrs.get("of") else sp["name"]
def fold(spans, roots):
"""Self time in µs for each stack under the spans in `roots` (hex ids), identical stacks summed.
shortcut: only as fine as the spans; a route's time outside its `step`s is its own frame. Add
steps in ipx where that is large. Children run at once (try_join) can add up past their
parent, so a frame's self time is floored at 0.
"""
kids, stacks = {}, {}
for sp in spans:
kids.setdefault(span_hex(sp.get("parentSpanId") or "0" * 16), []).append(sp)
def walk(sp, path):
path = f"{path};{label(sp)}" if path else label(sp)
dur = lambda s: (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) // 1000
cs = kids.get(span_hex(sp["spanId"]), [])
stacks[path] = stacks.get(path, 0) + max(0, dur(sp) - sum(dur(c) for c in cs))
for c in cs:
walk(c, path)
for sp in spans:
if span_hex(sp["spanId"]) in roots:
walk(sp, "")
return stacks
def check_fold():
def sp(sid, parent, name, start, end, of=None):
attrs = [{"key": "of", "value": {"stringValue": of}}] if of else []
return {"spanId": sid, "parentSpanId": parent, "name": name, "attributes": attrs,
"startTimeUnixNano": str(start * 1000), "endTimeUnixNano": str(end * 1000)}
a, b, c, d = (f"{i:016x}" for i in range(1, 5))
spans = [sp(a, "", "GET /x", 0, 100), sp(b, a, "step", 0, 60, "q"), sp(c, a, "step", 0, 60, "q"),
sp(d, b, "inner", 0, 10)]
assert fold(spans, {a}) == {"GET /x": 0, "GET /x;step:q": 110, "GET /x;step:q;inner": 10}, fold(spans, {a})
assert fold(spans, {b}) == {"step:q": 50, "step:q;inner": 10}
# base64 ids, as a trace sends them, match the search's hex.
assert span_hex(base64.b64encode(bytes.fromhex(a)).decode()) == a
print("fold: ok")
def flame_svg(stacks, n, title):
tree = {} # name -> [total, children]
for path, us in stacks.items():
node = tree
for part in path.split(";"):
node = node.setdefault(part, [0, {}])
node[0] += us
node = node[1]
total = sum(v[0] for v in tree.values()) or 1
width, row, rects = 1200, 18, []
def draw(node, x, depth, trail):
for name, (us, sub) in sorted(node.items()):
w = us / total * width
stack = f"{trail};{name}" if trail else name
hue = 10 + zlib.crc32(name.encode()) % 45
tip = f"{stack}\n{us / 1000:.0f} ms in all, {100 * us / total:.1f}%, {us / 1000 / n:.1f} ms a request"
text = html.escape(name[: int(w / 7)]) if w > 30 else ""
rects.append(f'<g><title>{html.escape(tip)}</title><rect x="{x:.1f}" y="{30 + depth * row}" '
f'width="{max(w - 0.5, 0.1):.1f}" height="{row - 1}" fill="hsl({hue},85%,62%)"/>'
f'<text x="{x + 3:.1f}" y="{30 + depth * row + 13}">{text}</text></g>')
draw(sub, x, depth + 1, stack)
x += w
draw(tree, 0, 0, "")
depth = max(p.count(";") for p in stacks) + 1 if stacks else 1
head = html.escape(f"{title}: {n} request(s), {total / 1000 / n:.1f} ms each on average")
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{40 + depth * row}" '
f'font-family="monospace" font-size="11"><rect width="100%" height="100%" fill="#fff"/>'
f'<text x="4" y="18" font-size="13">{head}</text>{"".join(rects)}</svg>')
def seconds(since):
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
def main():
if sys.argv[1:] == ["flame", "--check"]:
return check_fold()
if len(sys.argv) < 3:
sys.exit(__doc__)
kind, q = sys.argv[1], sys.argv[2]
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
now = time.time()
start = now - seconds(since)
enc = urllib.parse.quote
if kind == "logs":
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
for ts, line in sorted(lines, reverse=True):
print(line)
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
elif kind == "metric":
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
for s in rows:
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr)
elif kind == "traces":
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
for t in traces:
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
# No root name: its root span is a tap in the page, which the browser never sends.
name = t.get("rootTraceName") or "(from the page)"
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {name}")
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
elif kind == "spans":
# The spans that matched, each with its own time: a trace's length says nothing about a
# slow request in it when the trace also holds a page's live updates or an audio stream.
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=200&spss=20")
rows = []
for t in r.get("traces", []):
for ss in t.get("spanSets") or [t.get("spanSet") or {}]:
for sp in ss.get("spans", []):
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])}
what = attrs.get("http.route") or attrs.get("of") or attrs.get("url.path") or sp.get("name") or ""
rows.append((int(sp.get("durationNanos", 0)) / 1e6, int(sp["startTimeUnixNano"]), t["traceID"], what))
for ms, ts, tid, what in sorted(rows, reverse=True):
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(ts / 1e9))
print(f"{ms:>8.0f}ms {when}Z {tid} {what}")
print(f"-- {len(rows)} span(s)", file=sys.stderr)
elif kind == "prom":
# ipx's /metrics (#194), scraped as job ipx; through the network like the others.
r = fetch(f"http://prometheus:9090/api/v1/query?query={enc(q.replace('$range', since))}&time={now}")
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
for s in rows:
labels = {k: v for k, v in s["metric"].items() if k not in ("job", "instance")}
print(f"{float(s['value'][1]):>14g} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr)
elif kind == "flame":
out = sys.argv[4] if len(sys.argv) > 4 else "flame.svg"
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100&spss=20")
found = r.get("traces", [])
roots = {sp["spanID"] for t in found for ss in t.get("spanSets") or [t.get("spanSet") or {}]
for sp in ss.get("spans", [])}
stacks = {}
for tr in fetch_many([f"http://tempo:3200/api/traces/{t['traceID']}" for t in found]):
for path, us in fold(trace_spans(tr), roots).items():
stacks[path] = stacks.get(path, 0) + us
n = len(roots) or 1
with open(out + ".folded", "w") as f:
f.writelines(f"{path} {us}\n" for path, us in sorted(stacks.items()))
with open(out, "w") as f:
f.write(flame_svg(stacks, n, q))
print(f"-- {len(roots)} span(s) in {len(found)} trace(s): {out}, {out}.folded", file=sys.stderr)
elif kind == "trace":
spans = trace_spans(fetch(f"http://tempo:3200/api/traces/{q}"))
kids = {}
for sp in spans:
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
def show(sp, depth):
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
for e in sp.get("events", []):
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
# A scan logs a skip for every feed not due; they bury what happened.
if '"ev":"feed_skip"' in (msg or ""):
continue
print(f"{' ' * depth} - {msg}")
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
show(k, depth + 1)
ids = {sp["spanId"] for sp in spans}
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
show(root, 0)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()

3
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,3 @@
# Formatting only: `git config blame.ignoreRevsFile .git-blame-ignore-revs` makes blame skip these.
# rustfmt adopted
692a5102179b958b5d15cecc4f5e6f16b2955b76

26
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/sh
# Refuses a commit whose Rust is not as rustfmt writes it, or that clippy warns about. Clippy only
# checks and never links, so it is light beside a test build (CLAUDE.md, Tests). It checks the
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-directory,
# and both use ipx-feed. A commit to the page alone runs nothing here.
staged=$(git diff --cached --name-only --diff-filter=ACMD)
echo "$staged" | grep -q -e '\.rs$' -e 'Cargo\.toml$' -e '^Cargo\.lock$' || exit 0
if ! cargo fmt --all --check >/dev/null 2>&1; then
echo "pre-commit: the Rust is not formatted; run cargo fmt --all" >&2
exit 1
fi
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
pkgs="ipx-feed ipx-directory ipx"
elif echo "$staged" | grep -q '^crates/directory/'; then
pkgs="ipx-directory ipx"
elif echo "$staged" | grep -q '^crates/ipx/'; then
pkgs="ipx"
else
exit 0
fi
args=$(for p in $pkgs; do printf ' -p %s' "$p"; done)
# shellcheck disable=SC2086 # one word a flag
if ! cargo clippy $args --all-targets -q -- -D warnings; then
echo "pre-commit: clippy has warnings in $pkgs; fix them, or allow one where it is wrong, with a comment saying why" >&2
exit 1
fi

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@
/test-results
/playwright-report
/web/dist
.claude/settings.local.json
__pycache__/
site/dist/

View File

@@ -1,558 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.8.2] - 2026-09-19
### Added
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
contrast, black and white with every colour at 7:1 or more.
### Changed
- The themes in Settings are listed in alphabetical order.
### Fixed
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
read comfortably. A failed-action message is easier to read in every theme.
## [0.8.1] - 2026-09-19
### Changed
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
"Downloaded" toast is only for a file on your screen.
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
- The Classic theme is listed in Settings as just "Classic".
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
### Fixed
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
drawing at desktop width.
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
- In Nordic's dark half, button and toolbar borders show.
- A zero unread count on the selected feed no longer disappears into the selection.
## [0.8.0] - 2026-09-18
### Added
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
across, everything in one go.
- The catalogue of feeds and the server settings the admin page edits are kept in the database
rather than config.toml, which keeps where things are, the torrent settings and who may sign
in. The first start takes them from config.toml and trims it, keeping the original as
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
### Changed
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
be opened by a 0.7 release first, which brings its tables up to date.
## [0.7.0] - 2026-09-18
### Added
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
A theme chosen before this carries over.
- Your theme is kept on your account rather than in the browser, so it follows you to another
browser or computer, and the page arrives in it with no flash of the default. The theme a
browser already had is saved to your account the first time you load the page.
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
- Touch gestures: pull the item list down from its top to check the feed for new items, and
swipe the item you are reading left for the next one and right for the one before, or back
to the list from the first.
### Changed
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
Settings is now yours alone: your theme and your subscriptions.
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
them, where long notes left them looking missing.
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
`web/src`, type-checked, and built with swc; building ipx needs node.
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
between visits and fetches it again only when an update changes it.
### Fixed
- Switching tabs straight after marking everything read no longer shows the previous tab's
items: of two lists asked for at once, only the later one is shown.
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
their own, and on white for an iPhone's home screen.
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
higher than a downloaded one's.
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
now show: ipx asks for them without saying it is the page showing them.
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
pause, as the player bar's does, and pause it when pressed.
- An item you open stays read. A list refresh that crossed with marking it read could put its
unread dot back until the next refresh.
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
one, rather than a few read items lingering until a refresh cleared them.
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
leaves it out of their page.
- An image or link in a post given relative to the post, such as The Observation Deck's, now
points at the post's site rather than at ipx, and shows.
## [0.6.1] - 2026-09-15
### Fixed
- Time left, and when an episode counts as finished, go by the length your player measured
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
- A player left open in another tab or on another device no longer saves its older place over
where you have got to since, which could drop an episode out of Currently Listening.
## [0.6.0] - 2026-09-15
### Added
- Each episode in Currently Listening has a cross that takes it off the list. It forgets where you
got to, so playing it again starts from the beginning.
- An admin can give a feed a Directory category in its settings (`category` in config.toml), for
the blogs and other feeds that name none of their own. A feed's own iTunes category still wins.
- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds,
g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list.
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
both filter in place.
### Changed
- Currently Listening marks the episode in the player with the EQ bars, as the item list does,
and its progress and time left move as it plays. Each row says how much is left.
- Directory shows each feed as its cover art in a grid, title and subscriber count underneath,
instead of a list. Popular and the Add a feed dialog keep their rows.
- The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande.
- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin
in place of Keep, Kept and Stop keeping. A pinned item is still never deleted.
- Currently Listening is its own place in the feed list, below Popular, instead of a section at
the bottom of the Popular page.
- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each
picks up its category without waiting for the publisher to change something.
### Fixed
- An episode that fails to load, or is paused before it has, no longer forgets where you left off
in it, and so no longer drops out of Currently Listening.
- Currently Listening lists the episodes you have started. It left out anything marked read, and
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
once 90% of it has played.
- A WordPress post that embeds the file it encloses no longer lists, and downloads, that file
twice. Items that already had it twice are folded into one at startup, and the spare copy
deleted.
- The pinned column's heading lines up with the pins under it, and every heading sits a pixel
further right, over its column.
- The feeds left behind by an OPML subscription removed before ipx retired them are cleared at
startup: forgotten if nothing was downloaded, kept as orphaned if something was. Feeds from it
that have since been given their own settings stay as they are, with their items. Removing an
OPML or Patreon subscription no longer deletes the items of a feed inside it that has its own
settings.
- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text:
"Meta&#8217;s" reads "Meta’s". Titles already stored are corrected the next time their feed
changes.
## [0.5.5] - 2026-09-14
### Changed
- A feed whose site sends a message instead of the feed, such as "Unable to establish a DB
connection", now shows that message and is flagged as the publisher's problem, instead of two
parser errors about reaching the end of input.
### Removed
- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
### Fixed
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
"Loading…", and no longer cuts the Directory behind it down to ten.
## [0.5.4] - 2026-09-14
### Added
- Currently Listening, below Popular: episodes you started and have not finished, across every
feed you subscribe to. Tap one to pick up where you left off.
- Theme has an Auto option, alongside Dark, Light and Classic, that follows your system's
light/dark setting. All four are now also in Settings, as a dropdown next to the header
button's one-click-at-a-time toggle -- the same setting either way.
### Changed
- The feed (or Directory/Popular/All Subscriptions) and the tab you had open are remembered
across a reload or a new visit. A feed you no longer subscribe to, or a first visit with
nothing remembered yet, lands on All Subscriptions instead of the first feed alphabetically.
### Fixed
- On iOS, the topbar (the hamburger menu included) could stop responding to taps until a hard
refresh. The page sized itself with `100vh`, which iOS Safari measures against the address
bar's collapsed state rather than what is actually visible; `100dvh` tracks the real viewport
as the bar shows and hides.
## [0.5.3] - 2026-09-14
### Added
- A feed that has been failing for a day shows a plain-English reason in the sidebar and on its
own page, sorted from a 404, a 401/403, a 402, a name that no longer resolves, or a web page in
place of the feed -- with Unsubscribe or, when the page links its new feed, Use the new address.
A feed that fails once and reads fine again within a day is never flagged.
### Changed
- Unsubscribing from the last person's OPML or Patreon subscription now retires the feeds it
listed, the same as a feed the list itself drops: removed if nothing was downloaded, kept and
marked orphaned otherwise. Until now they stayed in the database and kept being scanned hourly
with auto-download on, which is how 922 defunct `davewiner` feeds outlived the OPML that listed
them.
### Fixed
- A feed whose XML uses a bare `&` instead of `&amp;` (kcpw, both feedland feeds) is now read
instead of refused.
- A feed URL that now serves a web page says so, and names the feed the page links to when it has
one, instead of a raw XML parser error.
- A publisher answering with an empty body (British Antarctic Survey's 202) is read as nothing new
to report, not a parse failure.
- A link in an item's show notes opens in a new tab instead of navigating away from ipx.
- A video file plays as video, in a small floating pane above the player bar, instead of silently
as sound only.
- On the Unread tab, opening an item no longer makes it disappear from the list -- it stays until
you open a different one, even if a scan finishes and refreshes the list while it is open.
- Subscribe and Unsubscribe have their own icons (a circled check and a circled minus) instead of
sharing the generic plus and minus used for adding feeds, users and imports.
- Settings no longer disappears for a non-admin account. It was hiding the whole Settings modal
along with the log and the users screen, but a non-admin has settings of their own in there --
their subscriptions' Export and Import, and the schedule and quota are worth seeing even without
a say in them. Only the log and the users screen, which the server also refuses them, are gone.
## [0.5.2] - 2026-09-12
### Added
- Settings → Users and `ipx user list` show when each account was added and when it last signed
in, to the hour.
- `ipx user rename <name> <new name>` renames an account and keeps its feeds, read state and admin
rights. An account made before the proxy was set up can take the name the proxy signs it in as.
### Changed
- Directory and Popular list the feeds inside an OPML one by one, and no longer the OPML itself,
so you can subscribe to just the shows you want.
- The database no longer records when subscriptions and sign-in sessions were created. Nothing
ever read it, and an existing database drops the columns on its next start.
### Fixed
- Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of
HTML: the item's other copy of its notes is used instead, from the next time the feed changes.
Daily Meditation Podcast had 57.
- Docker no longer shows ipodderx as starting, or calls it unhealthy, while it scans or downloads:
`ipx status` answers at once instead of waiting for the job in progress to finish.
- Signing out after signing in through Cloudflare Access no longer lands on ipodderx's own password
page. With the new `sign_out_url` set, Sign out ends the Access session, and the password page
sends anyone the proxy signs in straight to their feeds.
- The sign-in guide, `docs/sso.md`, describes the setup ipodderx.sdf1.net really runs: Authentik as
Cloudflare Access's identity provider, and how to find the address ipx has to trust. It had never
been checked against a real setup, and pointed at the wrong address.
## [0.5.1] - 2026-09-12
### Fixed
- The triangle that opens an OPML or Patreon folder was cramped against the folder's art. It has
more room now, and a wider target to click.
## [0.5.0] - 2026-09-12
### Added
- A Patreon token pasted into Add feed, or a creator's RSS link without `&show=`, becomes a folder
of that creator's shows, kept in step on every scan like a subscribed OPML. A creator with only
one show stays a plain feed. One already added as a single long feed is split into its shows on
its next scan, keeping its files and what you had read.
- Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips
every explicit item.
### Changed
- Unread counts, unread dots and download progress are amber, the colour of the icon's EQ bars.
Blue is kept for the primary action and links, so a count no longer looks like a button.
- What is playing is marked by small EQ bars, in its row and in the player. They move only while it
plays.
- A folder in the sidebar shows its first four shows' art as a mosaic, and its shows sit under its
title. Only folders have a triangle, so every feed lines up with Directory and Popular above.
- Feeds without art get initials in a colour of their own, instead of all the same grey.
- The Flagged tab is called Kept, as the Keep button and Settings already said.
- A feed's header is one short line; when it checks next is in its tooltip.
- The item list takes more of the window, and the Files pane shows only when the item has files.
- Column headings and tags are in sentence case, and fewer things are bold.
- Unsubscribe is a round button beside the feed's other actions.
- Export and Import in Settings say what they do.
- The sign-in page shows the original icon large.
- Nothing animates when your system asks for reduced motion.
- The pages are about 90 KB smaller: the icon is served once instead of written into each.
- A web token generated for a new install is 64 characters instead of 32.
- The README is a short overview of what ipx does and how to run it, and points into `docs/` for
the rest. It still described the layout from before 0.4.0.
### Removed
- The systemd units in `contrib/`. Run ipx with Docker, or point a unit of your own at
`ipx daemon`.
- Upgrading from before 0.3.0 directly: what was read, kept or part-played before accounts is no
longer carried over to the admin, and OPML feeds that old versions wrote into `config.toml` are
no longer moved out of it. Upgrade through 0.4.0 first.
- `interval_mins` in `config.toml` is ignored; use `schedule`.
### Fixed
- The feed list works from the keyboard: Tab reaches every feed, Enter opens it, and Right and Left
open and close a folder. Every button shows where the focus is, including in the toolbar, which
used to clip the ring.
- "1 items" reads "1 item".
- A selected feed without art no longer loses its initials tile in Dark and Light.
- The player shows the feed's initials when there is no art, not the episode's.
- Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings
had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a
skipped one stayed skipped whatever you changed.
- Feeds inside an OPML or a Patreon creator follow your settings on the folder unless you set their
own, as the folder's settings dialog said they did. Before, the folder's settings reached nothing
inside it.
- A new feed no longer takes the name of one you removed earlier and shows that feed's old items.
Re-adding the same feed still gets its old name, and its history, back.
## [0.4.0] - 2026-09-11
### Added
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
through all three and remembers the choice.
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
it, and a status bar with the totals.
- Mark everything read from All Subscriptions, across every feed you subscribe to
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
published); click again to reverse. The server sorts, so it covers the whole list, not just the
fifty shown, and the choice is remembered.
### Changed
- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it,
marked Subscribed, and clicking one opens it.
- Adding a feed scans it straight away, and an OPML import that added feeds scans them, so their
items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
label, just the Download button. The retention summary says "deleted", not "reaped".
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
Download latest dialogs (save, download, cancel). The icons are Font Awesome Free, embedded as
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
- One meaning per icon. Minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
dialog's confirm button carries the icon of what it does. The feed header's unsubscribe was an x
and read as closing the page. The remaining word buttons are icons too:
- Log, Add feed, Users, Unsubscribe and OPML.
- Popular's Subscribe, Copy and Sign out.
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
- The player's back, play, forward and close, which were font characters, and the folder arrow.
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
- The item table's size has its own column, apart from the file's type, and shows KB for small
files instead of "0 MB". The Item heading is now Title.
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
downloaded and red when the download failed, with the details in its tooltip. One icon per
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
### Fixed
- Playing a file from the Files pane played it twice at once, in the pane and in the player bar.
The pane has a play button now, and the player bar is the only player.
- The password box in Manage users was white in the dark theme.
- An item with no date showed a stray dot in its details.
- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens
in its URL box, could not be closed with Escape.
### Security
- Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never
listed in Popular or the Directory. A Supercast feed, which keeps its key in the URL's path
rather than the query, was being listed.
## [0.3.0] - 2026-09-11
### Added
- Add feed lists what other people on this server subscribe to, most subscribers first, and
subscribes you by id (`GET /api/popular`, `POST /api/popular/{id}`). Feeds from an OPML, and
feeds with a login or a key in their URL, are never listed.
- Upload an OPML file to import, beside the paste box. The page checks it looks like OPML before
sending it and clears the picker afterwards.
- Settings → Manage users: add and remove accounts, and choose who is an admin
(`GET`/`POST /api/users`, `PATCH`/`DELETE /api/users/{id}`).
- Per-user subscriptions, and per-user read, starred and playback state. The existing library is
adopted by the admin on first start.
- Subscribing to a feed someone else already has costs no second fetch and no second copy. Scanning
merges every subscriber's wants.
- Delete on a shared feed reads **Delete for everyone**, and the server answers `409` while anyone
else has starred the item or not played it (`?force=true` overrides).
- A shared feed's header says how many other people read it.
- `docs/` for configuration, the CLI, users, SSO and architecture, and `CLAUDE.md` for anyone
working on the code.
- Browser tests for OPML import and export by every route, user admin, unread ordering, and
`ipx import`/`ipx export`.
### Changed
- Feeds inside an OPML subscription list the ones with unread items first.
- OPML import subscribes you to every feed in the file. `ipx import` subscribes the first admin.
- OPML export lists only your own subscriptions.
- Production runs as a Docker image pushed to `192.168.1.130:5000` and recreated with
`docker compose`.
- This changelog follows Keep a Changelog. The long-form entries moved to `docs/history.md`.
### Fixed
- Importing another account's OPML export subscribed nobody and reported "Imported 0 feed(s)".
Feeds it added had no subscriber, so they were never scanned.
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
refused before anything changes.
- Starring stopped protecting a file from the quota and age sweeps once read state became per-user.
### Security
- The log is admin-only (`GET /api/logs` answers `403`, and the Log button is hidden). It names
every account, every feed and every failed sign-in.
- OPML export no longer hands anyone signed in the whole catalogue, including other people's
private feed URLs.
## [0.2.0] - 2026-09-10
### Added
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
filters, search, sanitised show notes and live progress over SSE.
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
- Phone layout.
- Accounts and sign-in: Argon2id passwords, session cookies, `ipx user add|list|passwd|rm`, and a
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
from it is removed unless it has downloads.
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`).
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
artwork.
- In-app log view with Daemon I/O, Scans and HTTP tabs.
- Mark all read on an OPML subscription.
- Editable feed URL with a copy button.
- Docker image whose healthcheck goes through the control socket.
- The iPodderX name, icon, and a colour scheme taken from the icon.
- `tests/page-smoke.js` and a Playwright browser suite.
### Changed
- Global settings and scan schedules are admin-only. The per-feed schedule picker is gone.
- Feeds from an OPML live in the database, not `config.toml`.
- Opening an item marks it read. Playing it marks it read only at the end or past 90%.
- "Episodes" became "items", since half the library is text.
- A burst of scan events causes one refresh, not one per feed.
- All is the default filter. OPML import and export, Settings and Log moved out of the header.
- Torrents run detached, two at a time.
### Fixed
- Download fetched the next queued episodes instead of the one clicked.
- Pressing play made an item vanish from the Unread list.
- Clearing a folder, schedule or cap from the UI did nothing.
- The daemon ignored SIGTERM until the current download finished.
- A missing function stopped the page script and left the whole UI dead.
- One download painted progress on every pending row.
- A torrent could freeze scanning for up to an hour.
- Downloading from an OPML feed failed with "belongs to unsubscribed feed".
- Image enclosures were downloaded, counted as episodes and given a play button.
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
- An item with several enclosures kept only the last.
- OPML folders rendered open by default.
- Mark read in the text pane recursed until the stack overflowed.
- The Unread, Downloaded and Flagged filters answered `500` without a search term.
- An OPML subscription always showed 0 unread.
- Folder names kept doubled spaces where separators were stripped.
- Sidebar rows had four different left edges.
### Security
- The web UI needs a token or a sign-in. The token is compared in constant time, and an empty
token refuses to serve.
- Show notes are sanitised with `ammonia`.
- A proxy's user header is honoured only from an address in `trusted_proxies`.
- A feed URL must be http(s), so `file:///etc/passwd` is refused.
- Download folders are sanitised per path segment, so `../../etc/Show` cannot climb out.
## [0.1.0] - 2026-09-09
### Added
- `ipx`, a Rust rewrite of the iPodderX engine: TOML config, SQLite state, and `ipx list`, `add`,
`rm` and `fetch`.
- RSS and Atom parsing with conditional GET, `<ttl>` and basic auth.
- Streaming downloads with explicit, keyword and per-scan filters, deduplicated by enclosure URL.
- Quota and age retention that never touches a starred file, and `ipx reap [--dry-run]`.
- `ipx daemon` with a JSON-lines Unix socket. CLI commands proxy to a running daemon.
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0

324
CLAUDE.md
View File

@@ -1,4 +1,4 @@
# Working on ipodderx-rs
# Working on ipx
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
thing is built; this file is about working on it without repeating mistakes that have already been
@@ -6,72 +6,145 @@ made here.
## Where things are
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
Arcane project `ipx`: `/mnt/fast/arcane/projects/ipx/compose.yaml`. That file is what runs, and
the containers of the split (#207) join it there. `docker-compose.yml` in this repo is a sample for
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
service of the `content` project, beside audiobookshelf, immich and Plex (#208).
| | Host | In the container |
|---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
| Image | `192.168.1.130:5000/ipx:latest` | |
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
`main` and pushed, comment on the issue with what changed and the commit, then close it:
```sh
R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
t issues close $R 40
```
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
for later stays open, and that is how it gets picked up again.
Deploying a change is: build and push the image, then pull it and recreate the container.
```sh
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
docker logs --tail 20 iPodderX
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
docker logs --tail 20 iPX
```
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
The directory is the same, with its own image:
```sh
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
```
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
```sh
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
```
The home page (`site/`, #216) is not on Tower. `sh site/build.sh` puts it in `site/dist` with the
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` by
direct upload, since there is no wrangler login here: hash each file as wrangler does (BLAKE3 of
its base64 contents plus its extension, the first 32 hex digits, with the `blake3-wasm` package),
ask the project for an upload token (`GET .../pages/projects/ipxreader/upload-token`), send the
files to `/pages/assets/upload` with that token, then create a deployment
(`POST .../pages/projects/ipxreader/deployments`) with the manifest of paths and hashes and
`_headers`. Its screenshots (`site/shots`) come from a scratch iPX on public feeds, never
production's, which would show whose subscriptions are whose.
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
container on another network, as cloudflared's are (#209).
The three containers resolve names through Cloudflare (`dns: [1.1.1.1, 1.0.0.1]` in the compose
file), not the router: the LAN's DNS is NextDNS, which answers for a domain it filters with its own
block page, and ipx then logged a feed as failing with `invalid peer certificate: UnknownIssuer`
(tomrenner.com, 2026-10-17). Docker's own DNS still answers for the services' names first.
**Name the service.** A bare `up -d` recreates every container in the project, which once the
split lands means the frontend and directory too. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's
this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
`rust:1-slim-trixie` every time unless they are already stored locally. Pull them from Google's
mirror and tag them; the build then uses the local copies without asking Docker Hub:
```sh
docker pull mirror.gcr.io/library/debian:bookworm-slim
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim
docker pull mirror.gcr.io/library/rust:1-slim-bookworm
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
docker pull mirror.gcr.io/library/debian:trixie-slim
docker tag mirror.gcr.io/library/debian:trixie-slim debian:trixie-slim
docker pull mirror.gcr.io/library/rust:1-slim-trixie
docker tag mirror.gcr.io/library/rust:1-slim-trixie rust:1-slim-trixie
```
Run those again now and then, or the local copies go stale.
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
the daemon answers there and can read its database, not just that the web port is up. The socket
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
download does not fail the check; it also means a worker stuck on one job would still pass. The
container restarts on its own after a reboot.
The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
and `ipx status` against the control socket, which says the daemon answers and can read its
database. The socket answers `status` itself instead of queuing it behind the worker's current
job, so a worker stuck on one job still answers it. The containers restart on their own after a
reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
the shell running the command and kills the session (exit 144). This has happened more than once.
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
running the command and kills the session (exit 144), which has happened more than once.
## Before you touch the page
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
make up each page's script. `build.rs` runs
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
deploy changes them and their names. So **every page change needs a
rebuild** before it is visible, and building needs node and `npm ci` run once.
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
keeps them until a deploy changes them and their names. So **every page change needs ipx-web
rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once.
Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is
not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
for everyone, so what ipx used to write into it per person is the page's own job now:
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
theme).
The files in `web/src` are not modules. They are one script split up, concatenated in the order
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
@@ -83,13 +156,19 @@ After any edit to it:
```sh
npx tsc -p .
node tests/page-smoke.js
node --test tests/page-unit.js
```
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a
`ReferenceError` while every server-side test passed.
`ReferenceError` while every server-side test passed. The third loads the same built script and
calls its helpers directly (#234): a pure function added to the page gets its check there.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
@@ -97,13 +176,57 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
## Tests
```sh
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
cargo fmt --all --check # rustfmt, as rustfmt.toml says; the pre-commit hook refuses a commit without it
cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
cargo test # parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js
node --test tests/page-unit.js # the page's own functions, called directly, in a second
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 40 browser tests against a real daemon on fixture feeds
npx playwright test # the browser tests, against a real daemon on fixture feeds
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
```
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
directory followed in the same command by its tests' compile took Tower's load past 200 and the
session was stopped. So:
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
threads twice over, wait for it to come down before starting another.
* Test only what the change is in. ipx uses ipx-directory, and both use ipx-feed, so:
| changed | run |
|---|---|
| `web/` alone | the three page checks; `npx playwright test -g '<what changed>'`; no cargo |
| `crates/ipx/` | `cargo test -p ipx`; the browser tests too if the API changed |
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx` |
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, then `-p ipx` |
| `Cargo.toml`, `Cargo.lock` | all three, one at a time |
The pre-commit hook's clippy follows the same table. The whole browser suite once, at the end
of a change to the page or the API; never `cargo test` for the workspace in one command.
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
`/mnt/user` (FUSE) once took the load to 248 on its own.
* **Memory is the tighter limit.** Tower has 31 GB, no swap, and the containers leave about 4-7
GB. A build past that stalls everything on page faults: load 160 with the CPU idle and the
disks quiet, many processes blocked (`node_procs_blocked`, `node_vmstat_pgmajfault`). On
2026-10-18 linking a 431 MB test binary did it for 54 minutes. So `[profile.dev]` keeps only
line tables and dependencies no debug info; keep it that way, and look at `MemAvailable` beside
the load.
The Rust is formatted by rustfmt (`rustfmt.toml`) and kept free of clippy's warnings. Claude Code
formats each `.rs` file it edits (a hook in `.claude/settings.json`), and the pre-commit hook refuses
a commit touching Rust that is unformatted or has a clippy warning. A clone turns on the hook once: `git config core.hooksPath .githooks`; and `git config blame.ignoreRevsFile
.git-blame-ignore-revs` keeps blame past the commit that first formatted everything. A clippy
suggestion is not always the same code: on adopting it, `fold(false, |any, id| remove(id) || any)`
was offered as `any(...)`, which stops at the first and would have removed one feed of many. A lint
that is wrong for a line gets `#[allow(clippy::...)]` there with a comment saying why.
Things about the browser suite that have cost time:
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
@@ -118,9 +241,52 @@ Things about the browser suite that have cost time:
* `webServer` starts **before** `globalSetup`, which is why the fixture config is written at
config-load time instead.
The load tests (`tests/load`, issue #133) are for what one browser cannot show: twenty-five people
browsing at once, a hundred players saving positions while a scan writes, fifty listeners
seeking through files, a flood of wrong passwords. Things about them:
* They need **k6**, which `/src/install.sh` installs from k6's own signed apt repository, and they
build and run a **release** binary: Argon2 in a debug build takes about a second a sign-in,
which would measure the build.
* `run.js` serves the feeds itself, generated, and starts **its own daemon** under
`/tmp/ipx-load`, wiped each run, on ports 8793 and 8794, so it can run beside the browser suite.
It stops that daemon by its PID. People sign in by the `X-Load-User` header, trusted from
127.0.0.1, as production trusts Cloudflare Access's; only the sign-in test uses a password.
* Each threshold is a budget well above what the request takes now: it is there to catch a query
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
`ipx.env`; the harness refuses a database whose name does not end in `_test`. Postgres's
numbers are what production would see, and a run loads the server production's database is on,
so not while someone is using iPX in earnest. Each budget has a value for each database.
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.
## Production's database
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
```sh
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
BEGIN READ ONLY;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
ROLLBACK;
SQL
```
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
## Things that are true and easy to get wrong
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
@@ -147,15 +313,20 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
fighting over the database, with the stale one still holding the port.
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
Rename one and its panels go blank without an error; regenerate the dashboard to match.
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log.
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
* **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
command, the healthcheck's `ipx status` included, so it must never write when nothing is
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you. Look for
the column first and alter only when it is missing, as `feeds.description` does.
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
@@ -167,26 +338,67 @@ Comments explain **why**, not what. If a line looks odd, the comment says what w
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
addressed to the person using it.
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
Every change gets one line under `## [Unreleased]` in the changelog of the service it changes, in
its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md)
(the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page
and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A
change to more than one gets a line in each, each saying what that service does differently. The
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
body, where `git log` and `git blame` find it beside the change. (There was a long-form
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
compare links at the bottom of the changelog.
Each service has its own version and is released on its own, when its changelog has something to
release:
| service | version in | changelog | tag |
|---|---|---|---|
| ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split |
| ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` |
| ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` |
`crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
is a line in the changelog of each service it changes. The page shows ipx's version and its own in
the logo's tooltip; the directory logs its own at start; each binary answers `--version`.
Cutting a release of one: first read its `[Unreleased]` against its last release, not line by
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
record the way there: a feature added and then reworked in the same release is one line saying
what it is now, under Added; something added and taken out again before the release, or a fix for
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it,
set the service's version to `X.Y.Z` (dropping `-dev`), tag the commit with the service's tag, and
update the compare links at the bottom of its changelog. Push the commit and the tag, build and
deploy the service's image, then `sh scripts/release.sh ipx|directory|web` makes the Gitea
release: that version's changelog section as its notes, and a tarball of what the image runs (the
binary, or the page's files with the Caddyfile) attached, checked to be that version. Then, in the
next commit, set its version
to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
instead of claiming to be the last release. The release that follows can still be a minor or major
one; `-dev` only says the work comes after `X.Y.Z`. A service never released is at `0.1.0-dev`,
working towards its first release, `0.1.0`.
**Traces first, logs second.** Anything that does work (a service, a handler, a job, a call to
another service) gets spans, and passes `traceparent` on to whatever it calls, so one trace in
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
time went and what called what; a log line only that something happened. Log what a trace cannot
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
traces is not finished.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
## Known gaps
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
(documented in [docs/sso.md](docs/sso.md)).
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles.
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
issue left open.
## Styleguide
* Best effort to adhere to the rust style guide found at /src/styleguide/rustguide.md
<!-- rtk-instructions v2 -->
# Command output
@@ -197,4 +409,4 @@ normally, and batch related commands into one call to avoid extra turns.
Truncated results state their recovery path in their own output. Re-run a
command as `rtk proxy <cmd>` only when its result is unusable: empty when
output was clearly expected, contradicting its exit code, or garbled.
<!-- /rtk-instructions -->
<!-- /rtk-instructions -->

394
Cargo.lock generated
View File

@@ -17,6 +17,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -25,9 +40,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.4"
version = "4.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
dependencies = [
"cssparser",
"html5ever",
@@ -445,6 +460,27 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "brotli"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -620,9 +656,12 @@ version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
"zstd",
"zstd-safe",
]
[[package]]
@@ -750,9 +789,9 @@ dependencies = [
[[package]]
name = "cssparser"
version = "0.37.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
dependencies = [
"dtoa-short",
"itoa",
@@ -761,9 +800,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
]
@@ -1398,9 +1437,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
@@ -1516,12 +1555,13 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.39.0"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
dependencies = [
"log",
"markup5ever",
"memchr",
]
[[package]]
@@ -1820,34 +1860,90 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.8.2"
version = "1.5.0"
dependencies = [
"ammonia",
"anyhow",
"argon2",
"atom_syndication",
"axum",
"chrono",
"clap",
"flate2",
"futures-util",
"ipx-directory",
"ipx-feed",
"jsonwebtoken",
"librqbit",
"opentelemetry",
"opml",
"percent-encoding",
"quick-xml 0.42.0",
"reqwest",
"rss",
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"url",
]
[[package]]
name = "ipx-directory"
version = "1.4.0"
dependencies = [
"ammonia",
"anyhow",
"axum",
"clap",
"flate2",
"futures-util",
"ipx-feed",
"mimalloc",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"percent-encoding",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"url",
]
[[package]]
name = "ipx-feed"
version = "0.0.0"
dependencies = [
"anyhow",
"atom_syndication",
"chrono",
"http",
"hyper-util",
"opml",
"pulldown-cmark",
"quick-xml 0.42.0",
"reqwest",
"rss",
"serde_json",
"tokio",
"tower",
"tracing",
"url",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2007,6 +2103,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"serde",
"serde_json",
"signature",
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2036,6 +2148,15 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.23"
@@ -2407,9 +2528,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.39.0"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
dependencies = [
"log",
"tendril",
@@ -2475,6 +2596,15 @@ dependencies = [
"rapidhash",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2693,6 +2823,77 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.20",
]
[[package]]
name = "opentelemetry-http"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
"reqwest",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
dependencies = [
"flate2",
"http",
"httpdate",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest",
"thiserror 2.0.20",
]
[[package]]
name = "opentelemetry-proto"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.5",
"thiserror 2.0.20",
]
[[package]]
name = "opml"
version = "1.1.6"
@@ -2786,9 +2987,9 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_shared",
"serde",
@@ -2796,9 +2997,9 @@ dependencies = [
[[package]]
name = "phf_codegen"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
dependencies = [
"phf_generator",
"phf_shared",
@@ -2806,9 +3007,9 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
@@ -2816,9 +3017,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -2908,6 +3109,47 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.6"
@@ -3208,6 +3450,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -3321,9 +3564,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.44"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -3385,9 +3628,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -3786,6 +4029,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -4047,9 +4299,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
dependencies = [
"new_debug_unreachable",
"parking_lot",
@@ -4059,9 +4311,9 @@ dependencies = [
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
dependencies = [
"phf_generator",
"phf_shared",
@@ -4462,6 +4714,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"async-compression",
"bitflags 2.13.1",
"bytes",
"futures-core",
@@ -4537,6 +4790,30 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-opentelemetry"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
dependencies = [
"js-sys",
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -4547,12 +4824,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
@@ -4810,9 +5090,9 @@ dependencies = [
[[package]]
name = "web_atoms"
version = "0.2.6"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
dependencies = [
"phf",
"phf_codegen",
@@ -4831,9 +5111,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -5183,6 +5463,20 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
@@ -5228,3 +5522,31 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zstd"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.1+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -1,30 +1,56 @@
[package]
name = "ipx"
version = "0.8.2"
[workspace]
members = ["crates/ipx", "crates/feed", "crates/directory"]
resolver = "3"
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
# (the page), released and tagged on its own; see CLAUDE.md.
[workspace.package]
edition = "2024"
[dependencies]
[workspace.dependencies]
ipx-feed = { path = "crates/feed" }
ipx-directory = { path = "crates/directory" }
ammonia = "4.1.4"
anyhow = "1.0.104"
argon2 = "0.6.0"
atom_syndication = "0.12.10"
axum = "0.8.9"
flate2 = "1.1.10"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
opml = "1.1.6"
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
mimalloc = "0.1.52"
http = "1.5.0"
hyper-util = { version = "0.1.20", features = ["client-legacy"] }
percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1"
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
sha2 = "0.10.9"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
toml = "1.1.5"
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["fs"] }
tower-http = { version = "0.7.1", features = ["fs", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-opentelemetry = { version = "0.34", default-features = false }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
url = "2.5.8"
# Backtraces keep file and line; the full debug info made a 431 MB test binary whose link ran
# Tower, which has no swap, out of memory and stalled it for most of an hour (2026-10-18).
[profile.dev]
debug = "line-tables-only"
# Nobody steps through dependencies.
[profile.dev.package."*"]
debug = false

View File

@@ -1,31 +1,35 @@
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
# build.rs builds the web pages from TypeScript with swc, which needs node.
FROM rust:1-slim-bookworm AS build
# The pages are ipx-web's (Dockerfile.web), so no node here (#203).
FROM rust:1-slim-trixie AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential nodejs npm \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Dependencies first, so editing the source does not rebuild librqbit every time.
# Dependencies first, so editing the source does not rebuild librqbit every time: each crate of
# the workspace as an empty stub, its manifest real.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src
COPY crates/ipx/Cargo.toml crates/ipx/
COPY crates/feed/Cargo.toml crates/feed/
COPY crates/directory/Cargo.toml crates/directory/
RUN mkdir -p crates/ipx/src crates/feed/src crates/directory/src \
&& echo 'fn main(){}' > crates/ipx/src/main.rs && touch crates/feed/src/lib.rs \
&& echo 'fn main(){}' > crates/directory/src/main.rs && touch crates/directory/src/lib.rs \
&& cargo build --release --locked -p ipx \
&& rm -rf crates/ipx/src crates/feed/src crates/directory/src
COPY build.rs ./
COPY src ./src
COPY web ./web
COPY crates ./crates
# ipx builds the directory crate in, whose public pages serve these (#215).
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
RUN touch src/main.rs && cargo build --release --locked
RUN touch crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
FROM debian:bookworm-slim
FROM debian:trixie-slim
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
# about 480 MB, once: the layer stays cached until the base image changes.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates gosu \
ca-certificates gosu ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
COPY docker-entrypoint.sh /usr/local/bin/

25
Dockerfile.directory Normal file
View File

@@ -0,0 +1,25 @@
# ipx-directory (#204). Built from the repo's root: docker buildx build -f Dockerfile.directory .
# No node here: only ipx's own build runs web/build.mjs.
FROM rust:1-slim-trixie AS build
# SQLite is compiled from source for sqlx, which needs a C toolchain.
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Every member of the workspace, since cargo reads them all to build one.
# ponytail: no dependency layer, so each build compiles sea-orm again; add the stub trick from
# the main Dockerfile if builds get slow enough to matter.
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
# The logo and icon the public pages serve (#215), compiled into the binary.
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
RUN cargo build --release --locked -p ipx-directory
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/ipx-directory /usr/local/bin/ipx-directory
ENV DIRECTORY_CONFIG=/config/directory.toml
# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere.
USER 99:100
EXPOSE 8100
CMD ["ipx-directory"]

16
Dockerfile.web Normal file
View File

@@ -0,0 +1,16 @@
# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx.
# Built from the repo's root: docker buildx build -f Dockerfile.web .
FROM node:22-slim AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY web ./web
RUN node web/build.mjs /dist
FROM caddy:2
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
# curl flaw such as CVE-2026-92392 has nothing to reach in a container facing the internet (#237).
RUN apk del --no-cache curl
COPY web/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /dist /srv
EXPOSE 8080

View File

@@ -1,4 +1,4 @@
# ipodderx-rs
# ipx
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
@@ -9,7 +9,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
## What it does
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
- **The web UI.** It has a toolbar, and a feed list that opens with the Directory, Currently Listening and All
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
comes in Dark, Light and Classic themes, and works on a phone.
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
@@ -17,8 +17,8 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
manage accounts and settings.
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap
on new downloads per scan.
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a limit
on how many of a feed's newest episodes are downloaded.
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
an item someone has pinned.
@@ -30,12 +30,14 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
With Docker:
```sh
docker build -t ipodderx .
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
docker compose up -d
```
`docker-compose.yml` is set up for the author's own server. Point its `image` and its three volumes
(`/config`, `/data` and `/downloads`) at yours first. The UI is on port 8099. BitTorrent uses 6881
`docker-compose.yml` is a sample: ipx, with its database in SQLite and its three volumes
(`/config`, `/data` and `/downloads`) in folders beside the file, and ipx-web, the page, served by
Caddy in front of it. Change them to suit. The UI is on port 8099. BitTorrent uses 6881
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
From source:
@@ -45,7 +47,9 @@ cargo build --release
./target/release/ipx daemon
```
The first start creates **admin / ipodderx**. Sign in at `/login`, then change it:
The first start creates **admin** with a random password, written to `admin-password` beside
config.toml (readable by ipx's user alone; the log says where, never what it is). Set
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
```sh
echo -n 'a good password' | ipx user passwd admin
@@ -62,7 +66,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
| [docs/users.md](docs/users.md) | Accounts, and what several people share |
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
| [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md), [web/CHANGELOG.md](web/CHANGELOG.md), [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) | What changed, by release, for ipx, the page and the directory |
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
## Tests
@@ -70,6 +74,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
```sh
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js # the page script loads without throwing
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```

View File

@@ -1,14 +0,0 @@
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=web");
println!("cargo:rerun-if-changed=package-lock.json");
let out = std::env::var("OUT_DIR").unwrap();
let status = Command::new("node")
.args(["web/build.mjs", &out])
.status()
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
}

View File

@@ -0,0 +1,94 @@
# Changelog: ipx-directory
The directory of public podcasts that ipx servers share (docs/directory.md). ipx's changelog is
[crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md), the page's [web/CHANGELOG.md](../../web/CHANGELOG.md).
Its version is in `crates/directory/Cargo.toml`, and a release is tagged `directory-vX.Y.Z`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.4.0] - 2026-10-23
### Fixed
- A WordPress.com site that answers its feed with a browser check (HTTP 403) is read from WordPress.com's API instead of failing (#317).
### Security
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
- A server's count of a feed's subscribers is taken up to a million. Two servers sending the largest number there is made the sum overflow, and every listing and page failed until one was dropped (#342).
## [1.3.0] - 2026-10-19
### Added
- A feed's items written in Markdown show formatted on its page, from a Markdown description or RSS's `<source:markdown>` (#284).
### Fixed
- A listed feed whose address serves something larger than 50 MB is refused as too large, instead of read whole into memory at every check (#295).
### Security
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one (#281).
## [1.2.0] - 2026-10-16
### Added
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
### Changed
- Each feed is checked every twelve hours instead of six (#243).
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
## [1.1.1] - 2026-10-15
### Changed
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
### Security
- A failed feed check is logged without its address's query or password (#238).
## [1.1.0] - 2026-10-13
### Added
- With `metrics_bind` set, the directory serves Prometheus metrics on a port of its own: memory, open files, and the feeds it has fetched and their bytes. A feed check's trace says how large the feed was (#224).
### Changed
- The directory uses mimalloc instead of glibc's allocator, so it gives back memory it has freed instead of holding over a gigabyte while idle (#224).
- The example in docs/directory.md has no healthcheck; `ipx-directory health` is still there to run by hand (#218).
### Security
- A feed check's trace keeps the feed's address without its query or password (#225).
## [1.0.0] - 2026-10-12
### Added
- Anyone can browse the directory in a web browser, read only: the most subscribed, categories, every feed A to Z, a search by name, and each feed's page with its latest items and its address to copy into any podcast app (#215).
- `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206).
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.4.0...main
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.3.0...directory-v1.4.0
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...directory-v1.3.0
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0

View File

@@ -0,0 +1,38 @@
[package]
name = "ipx-directory"
version = "1.4.0"
edition.workspace = true
publish = false
[[bin]]
name = "ipx-directory"
path = "src/main.rs"
[dependencies]
ammonia.workspace = true
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
futures-util.workspace = true
ipx-feed.workspace = true
opentelemetry.workspace = true
opentelemetry-otlp.workspace = true
opentelemetry_sdk.workspace = true
mimalloc.workspace = true
percent-encoding.workspace = true
reqwest.workspace = true
sea-orm.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tokio.workspace = true
toml.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-opentelemetry.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
[dev-dependencies]
flate2.workspace = true
tower.workspace = true

View File

@@ -0,0 +1,281 @@
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
//!
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use tracing::Instrument;
use crate::{Shared, SnapshotEntry};
/// What the charts are filed under, as a server's snapshot is under its token's name.
pub const INSTANCE: &str = "apple-podcasts";
/// How often, and how long to wait after a sync fails before trying again.
const EVERY: i64 = 7 * 86_400;
const RETRY: Duration = Duration::from_secs(3600);
/// The top this many overall, and of each category: about a thousand shows once they overlap.
const TOP: u32 = 100;
const TOP_OF_EACH: u32 = 50;
/// Apple asks for no more than about twenty calls a minute.
const PAUSE: Duration = Duration::from_secs(3);
/// Syncs when a week has passed since the last, forever.
pub async fn run(dir: Shared, country: String) {
let client = match reqwest::Client::builder()
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.build()
{
Ok(c) => c,
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
};
loop {
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
let wait = last + EVERY - crate::store::now();
if wait > 0 {
// Woken hourly at most, so a database restored from before the last sync is noticed.
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
continue;
}
let span = tracing::info_span!("apple_sync", country = %country);
match sync(&dir, &client, &country).instrument(span).await {
Ok(()) => {
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
tokio::time::sleep(RETRY).await;
}
}
}
}
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
let theirs = categories(&genres);
let (added, gone) = differ(&theirs, ipx_feed::categories());
if !added.is_empty() || !gone.is_empty() {
tracing::warn!(
added = %added.join(", "), gone = %gone.join(", "),
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
);
}
// The top overall, then the top of each category, each show once.
let mut ids: Vec<String> = Vec::new();
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
for (genre, n) in charts {
let url = match &genre {
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
};
match get::<Chart>(client, &url).await {
Ok(chart) => ids.extend(chart_ids(&chart)),
// One category's chart missing is no reason to keep last week's whole list.
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
}
tokio::time::sleep(PAUSE).await;
}
let mut seen = std::collections::HashSet::new();
ids.retain(|id| seen.insert(id.clone()));
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
let mut entries = Vec::new();
for batch in ids.chunks(100) {
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
let found: Lookup = get(client, &url).await?;
entries.extend(lookup_entries(&found));
tokio::time::sleep(PAUSE).await;
}
let result = dir.store.snapshot(INSTANCE, &entries).await?;
tracing::info!(
shows = ids.len(),
accepted = result.accepted,
refused_private = result.refused_private,
refused_blocked = result.refused_blocked,
refused_invalid = result.refused_invalid,
"Apple Podcasts synced"
);
// Fetched now, not when the refresher next looks.
dir.wake.notify_one();
Ok(())
}
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
let span = tracing::info_span!("apple_get", url = %url);
async {
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
resp.json::<T>().await.context("reading Apple's answer")
}
.instrument(span)
.await
}
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
#[derive(Deserialize)]
struct Genres(BTreeMap<String, Genre>);
#[derive(Deserialize)]
struct Genre {
name: String,
#[serde(default)]
subgenres: BTreeMap<String, Genre>,
}
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
g.0.get("26")
.map(|podcasts| {
podcasts
.subgenres
.values()
.map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect()))
.collect()
})
.unwrap_or_default()
}
fn genre_ids(g: &Genres) -> Vec<String> {
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
}
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
/// "Category" or "Category / Subcategory".
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
};
let theirs: std::collections::BTreeSet<String> =
theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
let ours: std::collections::BTreeSet<String> =
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
}
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
#[derive(Deserialize)]
struct Chart {
feed: ChartFeed,
}
#[derive(Deserialize)]
struct ChartFeed {
// A chart of one show is that show, not a list of one.
#[serde(default, deserialize_with = "one_or_many")]
entry: Vec<ChartEntry>,
}
#[derive(Deserialize)]
struct ChartEntry {
id: ChartId,
}
#[derive(Deserialize)]
struct ChartId {
attributes: ChartIdAttributes,
}
#[derive(Deserialize)]
struct ChartIdAttributes {
#[serde(rename = "im:id")]
id: String,
}
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(ChartEntry),
Many(Vec<ChartEntry>),
}
Ok(match OneOrMany::deserialize(d)? {
OneOrMany::One(e) => vec![e],
OneOrMany::Many(v) => v,
})
}
fn chart_ids(c: &Chart) -> Vec<String> {
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
}
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
#[derive(Deserialize)]
struct Lookup {
#[serde(default)]
results: Vec<LookupResult>,
}
#[derive(Deserialize)]
struct LookupResult {
#[serde(rename = "feedUrl")]
feed_url: Option<String>,
#[serde(rename = "primaryGenreName")]
genre: Option<String>,
}
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
l.results
.iter()
.filter_map(|r| {
let url = r.feed_url.as_deref()?.trim();
(!url.is_empty()).then(|| SnapshotEntry {
url: url.to_owned(),
subscribers: 0,
// "Podcasts" is the root Apple files a show under when it gives no category.
category: r.genre.clone().filter(|g| g != "Podcasts"),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apples_categories_are_read_and_compared_with_ipxs() {
let genres: Genres = serde_json::from_str(
r#"{"26": {"name": "Podcasts", "subgenres": {
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
"1310": {"name": "Music"}}}}"#,
)
.unwrap();
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
let (added, gone) = differ(&categories(&genres), ours);
assert_eq!(added, ["Arts / Murals"]);
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
// ipx's own copy is Apple's whole list, each category once.
let mine = ipx_feed::categories();
assert_eq!(mine.len(), 19);
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
}
#[test]
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
let chart: Chart = serde_json::from_str(
r#"{"feed": {"entry": [
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
)
.unwrap();
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
let one: Chart =
serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
assert!(chart_ids(&empty).is_empty());
let found: Lookup = serde_json::from_str(
r#"{"resultCount": 3, "results": [
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
{"trackName": "No feed"},
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
)
.unwrap();
let entries = lookup_entries(&found);
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
assert_eq!(entries[1].category, None, "Podcasts is no category");
}
}

View File

@@ -0,0 +1,198 @@
//! The directory's config file: where it listens, where its database is, and the tokens it
//! accepts. Tokens are only ever changed here, never over the API, so whoever can edit this
//! file decides who gets in (#204).
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_bind")]
pub bind: String,
/// The environment variable holding the database URL, so the file holds no secret.
#[serde(default = "default_database_url_env")]
pub database_url_env: String,
/// Where to serve /metrics for Prometheus (#224), apart from `bind`, which the tunnel
/// publishes: `0.0.0.0:9188`. Empty serves none.
#[serde(default)]
pub metrics_bind: String,
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
/// left out, does neither.
#[serde(default)]
pub apple_country: String,
/// Internal addresses and networks the refresher may fetch feeds from all the same, as ipx's
/// `fetch_internal` (#329). Empty, the default, fetches nothing off the public internet.
#[serde(default)]
pub fetch_internal: Vec<String>,
#[serde(default)]
pub tokens: Vec<TokenCfg>,
}
fn default_bind() -> String {
"0.0.0.0:8100".into()
}
fn default_database_url_env() -> String {
"DIRECTORY_DATABASE_URL".into()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TokenCfg {
/// Who holds it: what its snapshot is filed under and what the log says, never the token.
pub name: String,
/// No default: a token whose access is missing or misspelled is refused when the file is
/// read, rather than given some access nobody chose.
pub access: Access,
/// The token's SHA-256, in hex: what `ipx-directory token new` prints. The file holds no
/// token, so adding or revoking one is an edit here and nothing else.
pub token_sha256: String,
}
/// What a token may do, each level including the one before.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Access {
/// Browse.
Ro,
/// Browse and send snapshots.
Rw,
/// All that, and remove, block and refresh feeds and drop an instance's snapshot.
Rwa,
}
/// A token as the server checks it.
#[derive(Debug, Clone)]
pub struct Token {
pub name: String,
pub access: Access,
/// SHA-256 of the token, lower-case hex.
pub sha256: String,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Self::parse(&text).with_context(|| format!("in {}", path.display()))
}
pub fn parse(text: &str) -> Result<Self> {
let cfg: Config = toml::from_str(text)?;
let mut seen = std::collections::HashSet::new();
for t in &cfg.tokens {
if t.name.trim().is_empty() {
bail!("a token has an empty name");
}
// Snapshots are filed by name: two instances under one name would overwrite each other.
if !seen.insert(t.name.as_str()) {
bail!("two tokens are named {:?}", t.name);
}
// Apple's charts are filed under it; a server of that name would replace them.
if t.name == crate::apple::INSTANCE {
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
}
}
let c = cfg.apple_country.trim();
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
}
Ok(cfg)
}
/// The tokens as the server checks them. A hash that is not 64 hex digits, or the same
/// hash under two names, is an error: which name a request is would be ambiguous.
pub fn tokens(&self) -> Result<Vec<Token>> {
let mut seen = std::collections::HashSet::new();
self.tokens
.iter()
.map(|t| {
let sha256 = t.token_sha256.trim().to_ascii_lowercase();
if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
bail!("token {:?}: token_sha256 is not a SHA-256 in hex", t.name);
}
if !seen.insert(sha256.clone()) {
bail!("token {:?} is the same token as another", t.name);
}
Ok(Token { name: t.name.clone(), access: t.access, sha256 })
})
.collect()
}
}
/// A token's SHA-256 in hex, as the config file holds it.
pub fn hash(token: &str) -> String {
use sha2::Digest;
sha2::Sha256::digest(token.trim().as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
/// Equal without saying, by how long it took, how much of the guess was right.
pub fn same_secret(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// A new token: `ipxd_` and 256 random bits, the prefix so one found in a log says what it is.
pub fn new_token() -> String {
use std::io::Read;
let mut bytes = [0u8; 32];
// Falling back to the clock would make a guessable token. Better to fail.
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut bytes))
.expect("no source of randomness for a token");
format!("ipxd_{}", bytes.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD: &str = r#"
[[tokens]]
name = "tower"
access = "rwa"
token_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
[[tokens]]
name = "friend"
access = "ro"
token_sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
"#;
#[test]
fn tokens_carry_their_access_and_take_their_value_from_the_environment() {
let cfg = Config::parse(GOOD).unwrap();
assert_eq!(cfg.bind, "0.0.0.0:8100");
let t = cfg.tokens().unwrap();
assert_eq!(
(t[0].name.as_str(), t[0].access, t[0].sha256.as_str()),
("tower", Access::Rwa, "a".repeat(64).as_str())
);
assert_eq!(t[1].access, Access::Ro);
assert!(Access::Ro < Access::Rw && Access::Rw < Access::Rwa);
}
#[test]
fn a_missing_or_misspelled_access_a_reused_name_or_a_bad_hash_is_refused() {
let h = "a".repeat(64);
assert!(Config::parse(&format!("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"")).is_err());
assert!(
Config::parse(&format!("[[tokens]]\nname = \"a\"\naccess = \"admin\"\ntoken_sha256 = \"{h}\"")).is_err()
);
assert!(Config::parse(&GOOD.replace("\"friend\"", "\"tower\"")).is_err());
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), "xyz")).unwrap().tokens().is_err());
assert!(Config::parse(&GOOD.replace(&"b".repeat(64), &h)).unwrap().tokens().is_err());
}
#[test]
fn secrets_compare_whole() {
assert!(same_secret("abc", "abc"));
assert!(!same_secret("abc", "abd"));
assert!(!same_secret("abc", "abcd"));
let t = new_token();
assert!(t.starts_with("ipxd_") && t.len() == 69 && t != new_token());
assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
}
}

706
crates/directory/src/lib.rs Normal file
View File

@@ -0,0 +1,706 @@
//! ipx's directory service (#204): a catalogue of public podcasts and blogs that ipx servers
//! share. It holds metadata only, never a file: each server still fetches its own feeds. ipx
//! servers send which public feeds their people subscribe to, the directory fetches each
//! feed's title, artwork, description and latest items on a slow schedule, and any server
//! holding a token can browse it.
//!
//! The types here are the wire format, shared with ipx, which asks the directory on its
//! people's behalf.
pub mod apple;
pub mod config;
pub mod metrics;
pub mod otel;
mod site;
pub mod store;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use axum::extract::{Path, Query, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Json, Router};
use serde::{Deserialize, Serialize};
use config::{Access, Token};
use store::{Refreshed, Store};
/// A feed as the directory lists it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Listing {
/// The first 64 bits of the SHA-256 of its normalised address (`store::feed_id`).
pub id: String,
pub url: String,
pub title: Option<String>,
pub image: Option<String>,
/// Apple's category and the subcategory within it, from the feed's own iTunes category.
pub category: Option<String>,
pub subcategory: Option<String>,
/// Any audio or video in its latest items.
pub podcast: bool,
/// Summed over every instance's latest snapshot.
pub subscribers: i64,
}
/// One feed's page: what it says it is, and its latest items.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedPage {
#[serde(flatten)]
pub listing: Listing,
/// The feed's own text, HTML as often as not, as it was sent: whoever shows it sanitizes it.
pub description: Option<String>,
/// Why its last check failed, in words, when it did.
pub failing: Option<String>,
pub items: Vec<Item>,
}
/// One of a feed's latest items: what it is, never its files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub guid: String,
pub title: Option<String>,
pub link: Option<String>,
pub published: Option<i64>,
pub duration: Option<i64>,
/// As the feed sent it, unsanitized.
pub description: Option<String>,
}
/// One feed in a snapshot: its address, and how many people on the sending server subscribe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotEntry {
pub url: String,
pub subscribers: i64,
/// The category the sending server's admin gave a feed that names none itself: used only
/// while the feed names none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SnapshotResult {
pub accepted: usize,
/// Addresses that look like they carry a subscriber's own access.
pub refused_private: usize,
/// Feeds an admin removed from the directory.
pub refused_blocked: usize,
/// Not an http(s) address.
pub refused_invalid: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Me {
pub name: String,
pub access: Access,
}
/// A token whose snapshot the directory holds: how many feeds it gave, and how many people.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instance {
pub name: String,
pub feeds: i64,
pub subscribers: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Blocked {
pub id: String,
pub url: Option<String>,
pub title: Option<String>,
pub at: i64,
}
/// The most a snapshot may list: far more than one server's subscriptions, few enough that one
/// request cannot hold the database for long.
pub const MAX_SNAPSHOT: usize = 20_000;
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
pub const REFRESH_EVERY: i64 = 12 * 3600;
pub struct Directory {
pub store: Store,
tokens: RwLock<Vec<Token>>,
client: reqwest::Client,
/// Wakes the refresher, so a feed a snapshot brings in is fetched now, not in a minute.
wake: tokio::sync::Notify,
/// The public pages' copy of the list, and when it was read.
pages_list: tokio::sync::Mutex<Option<(Instant, Arc<Vec<Listing>>)>>,
}
/// How long the public pages show the same copy of the list.
const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
pub type Shared = Arc<Directory>;
impl Directory {
pub fn new(
store: Store,
tokens: Vec<Token>,
fetch_internal: ipx_feed::guard::Allowed,
) -> Result<Shared, reqwest::Error> {
// No redirects: ipx_feed::fetch follows them itself, so it sees each hop.
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(ipx_feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
// The feeds are named by whoever holds a write token, and redirect where their
// publishers like: nothing on this network is fetched unless listed (#329).
.connector_layer(ipx_feed::guard::Guard::new(fetch_internal))
.build()?;
Ok(Arc::new(Self {
store,
tokens: RwLock::new(tokens),
client,
wake: tokio::sync::Notify::new(),
pages_list: Default::default(),
}))
}
/// The whole list for the public pages, read from the database at most once a minute. Anyone
/// may ask for a page, and each asked the database for the whole catalogue on the pool the
/// refresher and every server's /v1 calls share; a query string of its own got past any
/// cache in front (#341). The lock is held through the read, so a burst waits on one query.
pub async fn pages_list(&self) -> anyhow::Result<Arc<Vec<Listing>>> {
let mut held = self.pages_list.lock().await;
if let Some((at, list)) = held.as_ref()
&& at.elapsed() < PAGES_LIST_FOR
{
return Ok(list.clone());
}
let list = Arc::new(self.store.list().await?);
*held = Some((Instant::now(), list.clone()));
Ok(list)
}
/// Takes the tokens of a config file read again, and drops the snapshots of any that went.
pub async fn set_tokens(&self, tokens: Vec<Token>) -> anyhow::Result<u64> {
let names: Vec<String> = tokens.iter().map(|t| t.name.clone()).collect();
*self.tokens.write().unwrap() = tokens;
self.store.keep_instances(&names).await
}
fn caller(&self, presented: &str) -> Option<Me> {
let h = config::hash(presented);
// Every token compared, whether or not one matched, so timing says nothing either way.
self.tokens.read().unwrap().iter().fold(None, |found, t| {
if config::same_secret(&t.sha256, &h) { Some(Me { name: t.name.clone(), access: t.access }) } else { found }
})
}
/// Fetches one feed and stores what it found.
pub async fn refresh(&self, due: &store::Due) -> anyhow::Result<()> {
let got = match ipx_feed::fetch(&self.client, &due.url, None, due.etag.as_deref(), due.last_modified.as_deref())
.await
{
Ok((ipx_feed::Fetched::NotModified, _)) => Refreshed::NotModified,
Ok((ipx_feed::Fetched::Body { bytes, etag, last_modified }, _)) => {
if ipx_feed::is_opml(&bytes) {
// ipx sends the feeds inside an OPML, never the OPML.
Refreshed::Failed("an OPML list, not a feed".into())
} else {
match ipx_feed::parse(&bytes) {
Ok(parsed) => Refreshed::Feed { parsed, etag, last_modified },
Err(e) => Refreshed::Failed(format!("{e:#}")),
}
}
}
Err(e) => Refreshed::Failed(format!("{e:#}")),
};
if let Refreshed::Failed(e) = &got {
tracing::info!(feed = %due.id, error = %ipx_feed::redact_text(e), "feed check failed");
}
// ponytail: a feed that moved for good is still asked for at its old address, which
// redirects; follow the move when a feed's id can change without losing its counts.
self.store.save(&due.id, got).await
}
}
/// Fetches whatever is due, a few at a time, forever.
pub async fn refresher(dir: Shared) {
loop {
match dir.store.due(REFRESH_EVERY, 25).await {
Ok(due) => {
if !due.is_empty() {
// A round is a trace, each feed's check a span in it (#213).
let round = tracing::info_span!("refresh", feeds = due.len());
tracing::Instrument::instrument(
async {
// A few at a time: in turn, two publishers timing out held a round
// for a minute and more (#227).
futures_util::StreamExt::for_each_concurrent(futures_util::stream::iter(&due), 5, |d| {
let check = tracing::info_span!("feed", feed = %d.id);
async {
if let Err(e) = tracing::Instrument::instrument(dir.refresh(d), check).await {
tracing::warn!(feed = %d.id, error = %format!("{e:#}"), "could not store a feed check");
}
}
})
.await;
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
tracing::info!(checked = due.len(), "feeds checked");
},
round,
)
.await;
continue;
}
}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not read which feeds are due"),
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
_ = dir.wake.notified() => {}
}
}
}
/// Reads the config file again whenever it changes, checked each minute. A file that fails to
/// load leaves the tokens as they were, so a typo locks nobody out.
pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
let mut last = mtime(&path);
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let now = mtime(&path);
if now == last {
continue;
}
last = now;
match config::Config::load(&path).and_then(|c| c.tokens()) {
Ok(tokens) => {
let n = tokens.len();
match dir.set_tokens(tokens).await {
Ok(dropped) => tracing::info!(tokens = n, dropped, "config read again"),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "config read again, but old snapshots could not be dropped")
}
}
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "config changed but does not load; keeping the tokens as they were")
}
}
}
}
pub fn router(dir: Shared) -> Router {
let v1 = Router::new()
.route("/v1/me", get(me))
.route("/v1/feeds", get(list))
.route("/v1/popular", get(popular))
.route("/v1/feeds/{id}", get(feed).delete(remove))
.route("/v1/feeds/{id}/refresh", post(refresh))
.route("/v1/snapshot", post(snapshot))
.route("/v1/blocked", get(blocked))
.route("/v1/blocked/{id}", delete(unblock))
.route("/v1/instances", get(instances))
.route("/v1/instances/{name}", delete(drop_instance))
.layer(axum::middleware::from_fn_with_state(dir.clone(), authenticate));
Router::new()
.route("/healthz", get(healthz))
.merge(v1)
// Anyone may browse, no token, read only (#215).
.merge(site::router())
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
// was sent.
.layer(tower_http::compression::CompressionLayer::new().br(true).zstd(true).gzip(true))
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
.layer(tower_http::decompression::RequestDecompressionLayer::new().gzip(true))
.layer(axum::middleware::from_fn(access_log))
.with_state(dir)
}
struct ApiError(StatusCode, String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
tracing::error!(error = %format!("{e:#}"), "request failed");
ApiError(StatusCode::INTERNAL_SERVER_ERROR, "something went wrong; the directory's log says what".into())
}
}
/// Who made the request, for the access log, set by `authenticate`.
#[derive(Clone)]
struct Who(String);
/// One line a request, with the token's name and never the token: what the Grafana panels read.
async fn access_log(req: Request, next: Next) -> Response {
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
let start = Instant::now();
// A span for each request but the healthcheck's, a child of the caller's when it sent a
// traceparent, as ipx does on its calls here (#213).
let span = if path == "/healthz" {
tracing::Span::none()
} else {
tracing::info_span!(
"http",
otel.name = %format!("{method} {path}"),
http.request.method = %method,
url.path = %path,
http.response.status_code = tracing::field::Empty,
)
};
if let Some(parent) = otel::remote_parent(req.headers(), None) {
use tracing_opentelemetry::OpenTelemetrySpanExt;
let _ = span.set_parent(parent);
}
let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await;
span.record("http.response.status_code", resp.status().as_u16());
let _in = span.enter();
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
let status = resp.status().as_u16();
if path != "/healthz" {
tracing::info!(
target: "ipx_directory::http",
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
"{method} {path} -> {status}"
);
}
resp
}
async fn authenticate(State(dir): State<Shared>, mut req: Request, next: Next) -> Response {
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.filter(|t| !t.is_empty());
let Some(me) = presented.and_then(|t| dir.caller(t)) else {
return ApiError(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>".into())
.into_response();
};
let who = Who(me.name.clone());
req.extensions_mut().insert(me);
let mut resp = next.run(req).await;
resp.extensions_mut().insert(who);
resp
}
fn need(me: &Me, access: Access) -> Result<(), ApiError> {
if me.access >= access {
Ok(())
} else {
let what = match access {
Access::Ro => "browse",
Access::Rw => "send snapshots",
Access::Rwa => "administer the directory",
};
Err(ApiError(StatusCode::FORBIDDEN, format!("this token may not {what}")))
}
}
async fn healthz(State(dir): State<Shared>) -> Result<&'static str, ApiError> {
dir.store.ping().await?;
Ok("ok")
}
async fn me(Extension(me): Extension<Me>) -> Json<Me> {
Json(me)
}
async fn list(State(dir): State<Shared>) -> Result<Json<Vec<Listing>>, ApiError> {
Ok(Json(dir.store.list().await?))
}
#[derive(Deserialize)]
struct Limit {
limit: Option<u64>,
}
async fn popular(State(dir): State<Shared>, Query(q): Query<Limit>) -> Result<Json<Vec<Listing>>, ApiError> {
Ok(Json(dir.store.popular(q.limit.unwrap_or(10).clamp(1, 100)).await?))
}
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Result<Json<FeedPage>, ApiError> {
dir.store
.feed(&id)
.await?
.map(Json)
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))
}
async fn snapshot(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Json(entries): Json<Vec<SnapshotEntry>>,
) -> Result<Json<SnapshotResult>, ApiError> {
need(&me, Access::Rw)?;
if entries.len() > MAX_SNAPSHOT {
return Err(ApiError(StatusCode::PAYLOAD_TOO_LARGE, format!("a snapshot lists at most {MAX_SNAPSHOT} feeds")));
}
let r = dir.store.snapshot(&me.name, &entries).await?;
dir.wake.notify_one();
tracing::info!(
instance = %me.name, accepted = r.accepted, refused_private = r.refused_private,
refused_blocked = r.refused_blocked, refused_invalid = r.refused_invalid, "snapshot"
);
Ok(Json(r))
}
async fn remove(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?;
dir.store.block(&id).await?;
tracing::info!(feed = %id, by = %me.name, "feed removed and blocked");
Ok(StatusCode::NO_CONTENT)
}
async fn blocked(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Result<Json<Vec<Blocked>>, ApiError> {
need(&me, Access::Rwa)?;
Ok(Json(dir.store.blocked().await?))
}
async fn unblock(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?;
if !dir.store.unblock(&id).await? {
return Err(ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not blocked")));
}
tracing::info!(feed = %id, by = %me.name, "feed unblocked");
Ok(StatusCode::NO_CONTENT)
}
async fn instances(State(dir): State<Shared>, Extension(me): Extension<Me>) -> Result<Json<Vec<Instance>>, ApiError> {
need(&me, Access::Rwa)?;
Ok(Json(dir.store.instances().await?))
}
async fn drop_instance(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(name): Path<String>,
) -> Result<StatusCode, ApiError> {
need(&me, Access::Rwa)?;
let n = dir.store.drop_instance(&name).await?;
tracing::info!(instance = %name, feeds = n, by = %me.name, "snapshot dropped");
Ok(StatusCode::NO_CONTENT)
}
async fn refresh(
State(dir): State<Shared>,
Extension(me): Extension<Me>,
Path(id): Path<String>,
) -> Result<Json<FeedPage>, ApiError> {
need(&me, Access::Rwa)?;
let due = dir
.store
.due_one(&id)
.await?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{id:?} is not in the directory")))?;
dir.refresh(&due).await?;
feed(State(dir), Path(id)).await
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use tower::ServiceExt;
async fn app() -> (Router, Shared) {
let tokens = ["ro", "rw", "rwa"]
.iter()
.map(|n| Token {
name: format!("{n}-token"),
access: serde_json::from_value(serde_json::json!(n)).unwrap(),
sha256: config::hash(&format!("secret-{n}")),
})
.collect();
let dir = Directory::new(Store::scratch().await, tokens, Default::default()).unwrap();
(router(dir.clone()), dir)
}
async fn call(
app: &Router,
method: &str,
path: &str,
token: Option<&str>,
body: Option<serde_json::Value>,
) -> (u16, serde_json::Value) {
let mut req = axum::http::Request::builder().method(method).uri(path);
if let Some(t) = token {
req = req.header("authorization", format!("Bearer secret-{t}"));
}
let req = match body {
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status().as_u16();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
}
#[tokio::test]
async fn a_snapshot_may_come_gzipped() {
use std::io::Write as _;
let (app, _) = app().await;
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(json.as_bytes()).unwrap();
// As ipx sends it (#253).
let req = axum::http::Request::builder()
.method("POST")
.uri("/v1/snapshot")
.header("authorization", "Bearer secret-rw")
.header("content-type", "application/json")
.header("content-encoding", "gzip")
.body(Body::from(gz.finish().unwrap()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
let got: serde_json::Value =
serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
assert_eq!(got["accepted"], 1);
}
#[tokio::test]
async fn the_refresher_fetches_nothing_on_this_network() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
let (_, dir) = app().await;
let err = dir.client.get(format!("http://{addr}/feed.xml")).send().await.unwrap_err();
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
}
#[tokio::test]
async fn the_public_pages_read_the_list_once_a_minute() {
let (_, dir) = app().await;
let first = dir.pages_list().await.unwrap();
assert!(Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()), "a second page asks the database nothing");
// Once the copy is a minute old, the next page reads the list again.
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
}
#[tokio::test]
async fn answers_come_compressed_as_the_caller_asks() {
let (app, _) = app().await;
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
// A page, not an empty feed list: under 32 bytes is sent as it is.
let req =
axum::http::Request::builder().uri("/").header("accept-encoding", asked).body(Body::empty()).unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
}
}
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status().as_u16();
let csp = resp.headers().get("content-security-policy").map(|v| v.to_str().unwrap().to_owned());
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 22).await.unwrap();
(status, String::from_utf8(bytes.to_vec()).unwrap(), csp)
}
#[tokio::test]
async fn anyone_browses_the_pages_and_the_api_still_needs_a_token() {
let (app, dir) = app().await;
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 3 }]);
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
store::tests::fetch_all(&dir.store).await;
let id = dir.store.list().await.unwrap()[0].id.clone();
let (s, home, csp) = get_page(&app, "/").await;
assert_eq!(s, 200);
assert!(csp.unwrap().contains("script-src 'self'"));
assert!(home.contains("Most subscribed") && home.contains(&format!("/feed/{id}")), "{home}");
assert!(get_page(&app, "/all").await.1.contains("3 people subscribe"));
let (s, page, _) = get_page(&app, &format!("/feed/{id}")).await;
assert_eq!(s, 200);
// Its address, to subscribe to anywhere.
assert!(page.contains("value=\"https://x.test/a.xml\""), "{page}");
assert!(!page.contains("<script>"), "the publisher's HTML is text here");
assert_eq!(get_page(&app, "/feed/nope").await.0, 404);
assert_eq!(get_page(&app, "/category/Nothing").await.0, 404);
assert_eq!(get_page(&app, "/search?q=").await.0, 303);
assert_eq!(get_page(&app, "/site.js").await.0, 200);
// Reading the pages takes no token; the API still does.
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), None, None).await.0, 401);
assert_eq!(call(&app, "DELETE", &format!("/feed/{id}"), None, None).await.0, 405);
}
#[tokio::test]
async fn each_access_level_does_what_it_may_and_no_more() {
let (app, dir) = app().await;
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 2, "category": "Comedy" }]);
assert_eq!(call(&app, "GET", "/v1/feeds", None, None).await.0, 401);
assert_eq!(call(&app, "GET", "/v1/feeds", Some("wrong"), None).await.0, 401);
assert_eq!(call(&app, "GET", "/healthz", None, None).await.0, 200);
let (s, me) = call(&app, "GET", "/v1/me", Some("ro"), None).await;
assert_eq!((s, me["name"].as_str(), me["access"].as_str()), (200, Some("ro-token"), Some("ro")));
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("ro"), Some(snap.clone())).await.0, 403);
let (s, r) = call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await;
assert_eq!((s, r["accepted"].as_u64()), (200, Some(1)));
store::tests::fetch_all(&dir.store).await;
let (_, list) = call(&app, "GET", "/v1/feeds", Some("ro"), None).await;
let id = list[0]["id"].as_str().unwrap().to_owned();
assert_eq!(list[0]["subscribers"], 2);
assert_ne!(list[0]["category"], "Comedy", "the feed's own category wins over the sender's");
assert_eq!(call(&app, "GET", &format!("/v1/feeds/{id}"), Some("ro"), None).await.0, 200);
assert_eq!(call(&app, "GET", "/v1/feeds/nope", Some("ro"), None).await.0, 404);
assert_eq!(call(&app, "GET", "/v1/popular?limit=5", Some("ro"), None).await.1[0]["id"], id.as_str());
for (m, p) in [
("DELETE", format!("/v1/feeds/{id}")),
("GET", "/v1/blocked".into()),
("DELETE", "/v1/instances/rw-token".into()),
] {
assert_eq!(call(&app, m, &p, Some("rw"), None).await.0, 403, "{m} {p}");
}
assert_eq!(call(&app, "DELETE", &format!("/v1/feeds/{id}"), Some("rwa"), None).await.0, 204);
assert_eq!(call(&app, "GET", "/v1/blocked", Some("rwa"), None).await.1[0]["id"], id.as_str());
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap.clone())).await.1["refused_blocked"], 1);
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 204);
assert_eq!(call(&app, "DELETE", &format!("/v1/blocked/{id}"), Some("rwa"), None).await.0, 404);
call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await;
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rwa"), None).await.0, 204);
assert_eq!(call(&app, "GET", "/v1/feeds", Some("ro"), None).await.1, serde_json::json!([]));
}
#[tokio::test]
async fn a_revoked_token_loses_its_access_and_its_counts() {
let dir = Directory::new(Store::scratch().await, vec![], Default::default()).unwrap();
let t = |n: &str| Token { name: n.into(), access: Access::Rw, sha256: config::hash(&format!("secret-{n}")) };
dir.set_tokens(vec![t("a"), t("b")]).await.unwrap();
let app = router(dir.clone());
let snap = serde_json::json!([{ "url": "https://x.test/a.xml", "subscribers": 1 }]);
call(&app, "POST", "/v1/snapshot", Some("a"), Some(snap.clone())).await;
call(&app, "POST", "/v1/snapshot", Some("b"), Some(snap)).await;
store::tests::fetch_all(&dir.store).await;
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 2);
dir.set_tokens(vec![t("a")]).await.unwrap();
assert_eq!(call(&app, "GET", "/v1/feeds", Some("b"), None).await.0, 401);
assert_eq!(call(&app, "GET", "/v1/feeds", Some("a"), None).await.1[0]["subscribers"], 1);
}
}

View File

@@ -0,0 +1,140 @@
//! `ipx-directory`: serve the directory, make a token, or check that a running one answers.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ipx_directory::{Directory, config, store::Store};
use tracing_subscriber::prelude::*;
#[derive(Parser)]
#[command(version, about = "ipx's directory of public podcasts")]
struct Cli {
/// The config file: where to listen, the database, and the tokens it accepts. Otherwise
/// DIRECTORY_CONFIG, or else /config/directory.toml.
#[arg(long)]
config: Option<std::path::PathBuf>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Serve the directory (the default).
Serve,
/// Make a token, and print it with the lines that let it in.
Token {
#[command(subcommand)]
cmd: TokenCmd,
},
/// Check that the directory on this machine answers and can read its database.
Health,
}
#[derive(Subcommand)]
enum TokenCmd {
New {
/// Who it is for, as the log and their snapshot will name them.
name: String,
#[arg(long, value_parser = ["ro", "rw", "rwa"])]
access: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let config_path = cli
.config
.or_else(|| std::env::var_os("DIRECTORY_CONFIG").map(Into::into))
.unwrap_or_else(|| "/config/directory.toml".into());
match cli.cmd.unwrap_or(Cmd::Serve) {
Cmd::Serve => serve(&config_path).await,
Cmd::Token { cmd: TokenCmd::New { name, access } } => {
let token = config::new_token();
println!("A token for {name}, to give them; it is not kept anywhere:\n\n {token}\n");
println!("To let it in, add this to {}:\n", config_path.display());
println!("[[tokens]]\nname = {name:?}\naccess = {access:?}\ntoken_sha256 = {:?}", config::hash(&token));
println!("\nThe directory reads the file again within a minute. Delete the entry to revoke it.");
Ok(())
}
Cmd::Health => {
let cfg = config::Config::load(&config_path)?;
let port = cfg.bind.rsplit(':').next().unwrap_or("8100");
let resp = reqwest::Client::new()
.get(format!("http://127.0.0.1:{port}/healthz"))
.timeout(std::time::Duration::from_secs(4))
.send()
.await
.context("the directory does not answer")?;
anyhow::ensure!(resp.status().is_success(), "the directory answers {}", resp.status());
Ok(())
}
}
}
async fn serve(path: &std::path::Path) -> Result<()> {
// One JSON object a line for Loki when DIRECTORY_LOG_FORMAT=json, as ipx does; text otherwise.
let json = std::env::var("DIRECTORY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let filter =
|| tracing_subscriber::EnvFilter::try_from_env("DIRECTORY_LOG").unwrap_or_else(|_| "ipx_directory=info".into());
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's (#213).
let otel = ipx_directory::otel::provider("ipx-directory")?;
tracing_subscriber::registry()
.with((!json).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter())))
.with(json.then(|| {
tracing_subscriber::fmt::layer()
.json()
.flatten_event(true)
.with_writer(std::io::stderr)
.with_filter(filter())
}))
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx-directory"))
.with_filter(tracing_subscriber::EnvFilter::new("ipx_directory=info,ipx_feed=info"))
}))
.init();
let cfg = config::Config::load(path)?;
let tokens = cfg.tokens()?;
let url = std::env::var(&cfg.database_url_env)
.ok()
.filter(|u| !u.trim().is_empty())
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
let store = Store::open(&url, true).await.context("opening the database")?;
let dir = Directory::new(store, vec![], ipx_feed::guard::Allowed::parse(&cfg.fetch_internal)?)?;
let dropped = dir.set_tokens(tokens).await?;
let listener =
tokio::net::TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = cfg.tokens.len(), dropped, "directory started");
if !cfg.metrics_bind.is_empty() {
let metrics = tokio::net::TcpListener::bind(&cfg.metrics_bind)
.await
.with_context(|| format!("listening for metrics on {}", cfg.metrics_bind))?;
tracing::info!(bind = %cfg.metrics_bind, "serving metrics for Prometheus at /metrics");
tokio::spawn(ipx_directory::metrics::serve(metrics));
}
tokio::spawn(ipx_directory::refresher(dir.clone()));
let country = cfg.apple_country.trim().to_ascii_lowercase();
if !country.is_empty() {
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
}
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
axum::serve(listener, ipx_directory::router(dir))
.with_graceful_shutdown(async {
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
})
.await?;
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
}
Ok(())
}

View File

@@ -0,0 +1,98 @@
//! Memory, for ipx and the directory both (#224), and the directory's own /metrics.
//!
//! mimalloc is the allocator of every binary that builds this crate in, ipx included: with
//! glibc's, ipx kept the 500-850 MB a night of scans had needed until it restarted, and the
//! directory sat at 1.25 GiB doing nothing, each thread's arena holding what it had freed.
//! mimalloc gives freed pages back, so resident memory follows what is in use: a spike that
//! does not come down again is a leak. Not jemalloc, whose upstream was archived in 2025.
use std::sync::atomic::Ordering::Relaxed;
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
pub fn process() -> (Option<f64>, Option<f64>) {
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
let kb =
s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
Some(kb * 1024.0)
});
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
(rss, fds)
}
/// The directory's /metrics, in Prometheus's text format: memory, open files, and the feeds it
/// has fetched. Nothing from the database, so a scrape costs nothing and answers while it is down.
fn render(started: std::time::Instant) -> String {
let mut out = String::new();
let mut gauge = |name: &str, kind: &str, help: &str, labels: &str, v: f64| {
out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} {kind}\n{name}{labels} {v}\n"));
};
let (rss, fds) = process();
if let Some(v) = rss {
gauge("directory_process_resident_bytes", "gauge", "Memory the process holds.", "", v);
}
if let Some(v) = fds {
gauge("directory_process_open_fds", "gauge", "Files and sockets the process has open.", "", v);
}
gauge(
"directory_feed_fetches_total",
"counter",
"Feeds fetched, answered 304 included.",
"",
ipx_feed::FETCHES.load(Relaxed) as f64,
);
gauge(
"directory_feed_bytes_total",
"counter",
"Bytes of feed bodies fetched.",
"",
ipx_feed::FETCHED_BYTES.load(Relaxed) as f64,
);
gauge(
"directory_uptime_seconds",
"gauge",
"Seconds since the directory started.",
"",
started.elapsed().as_secs() as f64,
);
gauge(
"directory_build_info",
"gauge",
"The version running, as a label.",
&format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")),
1.0,
);
out
}
/// Serves /metrics on a listener of its own: the public port is behind the tunnel with no
/// Access in front, and these are for the LAN.
pub async fn serve(listener: tokio::net::TcpListener) {
let started = std::time::Instant::now();
let app = axum::Router::new()
.route(
"/metrics",
axum::routing::get(move || async move {
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
}),
)
// Prometheus asks for gzip (#253).
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "metrics stopped");
}
}
#[cfg(test)]
mod tests {
#[test]
fn metrics_say_memory_and_feeds() {
let text = super::render(std::time::Instant::now());
assert!(
text.contains("directory_process_resident_bytes ") && text.contains("directory_feed_bytes_total "),
"{text}"
);
}
}

View File

@@ -0,0 +1,91 @@
//! Traces, for ipx and the directory both (#213): the exporter, and W3C `traceparent` in from a
//! caller and out to a callee, so one trace follows a request from the browser through ipx-web
//! and ipx into the directory.
/// Traces over OTLP, to Tempo for one, when OTEL_EXPORTER_OTLP_ENDPOINT names a collector
/// (`http://host:4318`: the exporter speaks OTLP over HTTP and adds `/v1/traces`). The exporter
/// reads that and the other `OTEL_` variables itself.
pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sdk::trace::SdkTracerProvider>> {
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default().is_empty() {
return Ok(None);
}
let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?;
let resource = opentelemetry_sdk::Resource::builder().with_service_name(service).build();
Ok(Some(
opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build(),
))
}
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
/// itself (#192).
pub fn remote_parent(headers: &axum::http::HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
let v = match headers.get("traceparent") {
Some(h) => h.to_str().ok()?,
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
};
let mut parts = v.split('-');
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
if version != "00" || trace.len() != 32 || span.len() != 16 {
return None;
}
let sc = SpanContext::new(
TraceId::from_hex(trace).ok()?,
SpanId::from_hex(span).ok()?,
TraceFlags::SAMPLED,
true,
TraceState::default(),
);
sc.is_valid().then(|| opentelemetry::Context::new().with_remote_span_context(sc))
}
/// The current span as a `traceparent`, for a request to another service, so its spans join
/// this trace. None outside a traced span.
pub fn traceparent() -> Option<String> {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let cx = tracing::Span::current().context();
let sc = cx.span().span_context().clone();
sc.is_valid().then(|| format!("00-{}-{}-{:02x}", sc.trace_id(), sc.span_id(), sc.trace_flags().to_u8()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_requests_trace_is_the_pages_from_its_traceparent() {
use opentelemetry::trace::TraceContextExt;
let with = |v: &str| {
let mut h = axum::http::HeaderMap::new();
h.insert("traceparent", v.parse().unwrap());
remote_parent(&h, None)
};
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(cx.span().span_context().is_remote());
// Anything else from the browser starts a trace of its own.
for bad in [
"",
"00-xyz-00f067aa0ba902b7-01",
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"00-00000000000000000000000000000000-00f067aa0ba902b7-01",
"00-4bf92f3577b34da6a3ce929d0e0e4736",
] {
assert!(with(bad).is_none(), "{bad}");
}
assert!(remote_parent(&axum::http::HeaderMap::new(), None).is_none());
// From the query, where a beacon or Safari's player cannot send a header (#192).
let q = remote_parent(
&axum::http::HeaderMap::new(),
Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
);
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
}
}

View File

@@ -0,0 +1,67 @@
/* The directory's pages (#215), in iPX's palette: the logo's navy, sky blue and needle orange,
dark or light as the system is. */
:root{--bg:#eef5fb;--panel:#fff;--panel2:#e4eff9;--line:#c6d9ea;--fg:#10233a;--dim:#46596e;--faint:#56697e;--accent:#2f6aa0;--accent2:#c43e00;--ink:#fff}
@media (prefers-color-scheme:dark){:root{--bg:#0a1726;--panel:#0f1f31;--panel2:#15283d;--line:#264060;--fg:#f3f7fb;--dim:#a3b6ca;--faint:#8a9fb5;--accent:#8fc2ea;--accent2:#ff6a1a;--ink:#0a1726}}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1}
.brand{display:flex;align-items:center;gap:10px;color:var(--fg);font-weight:700;font-size:17px;white-space:nowrap}
.brand img{border-radius:8px;display:block}
header form{margin-left:auto;flex:0 1 320px}
input{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:8px 12px;width:100%}
main{max-width:1100px;margin:0 auto;padding:20px 20px 40px}
footer{max-width:1100px;margin:0 auto;padding:16px 20px 32px;color:var(--faint);font-size:13px;border-top:1px solid var(--line)}
h2{font-size:22px;margin:20px 0 12px}
h3{font-size:17px;margin:28px 0 8px}
.hint,.count{color:var(--dim)}
.back{margin:0;color:var(--faint);font-size:14px}
.more{font-weight:600}
.art{position:relative;display:block;aspect-ratio:1;border-radius:10px;overflow:hidden;background:var(--panel2);flex:none}
.art i{position:absolute;inset:0;display:grid;place-items:center;font-style:normal;font-weight:700;color:var(--accent);font-size:28px}
.art img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.art.sm{width:48px;border-radius:8px}.art.sm i{font-size:16px}
.art.lg{width:180px;border-radius:14px}.art.lg i{font-size:48px}
.chart{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr));gap:4px 24px}
/* A grid item is as wide as its content unless told otherwise, and a long title then runs off the page. */
.chart li,.tile{min-width:0}
.chart a{display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;color:var(--fg)}
.chart a:hover{background:var(--panel);text-decoration:none}
.no{width:22px;text-align:right;color:var(--faint);font-variant-numeric:tabular-nums;flex:none}
.txt{display:flex;flex-direction:column;min-width:0}
.txt b,.tile b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
small{color:var(--dim);font-size:13px}
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
.cat{display:flex;flex-direction:column;gap:2px;padding:14px 16px;border-radius:12px;background:var(--panel);border:1px solid var(--line);color:var(--fg)}
.cat:hover{border-color:var(--accent);text-decoration:none}
.tabs,.pills{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 12px}
.tabs a,.pills a{padding:6px 14px;border-radius:999px;background:var(--panel);border:1px solid var(--line);color:var(--fg);font-size:14px}
.tabs a[aria-current],.pills a[aria-current]{background:var(--accent);border-color:var(--accent);color:var(--ink)}
.pills a[aria-current] small{color:inherit}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px 14px}
.tile{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--fg)}
.tile:hover{text-decoration:none}.tile:hover b{text-decoration:underline}
.show{display:flex;gap:24px;align-items:flex-end;margin-top:8px}
.meta{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1}
.meta h2{margin:0;font-size:28px;line-height:1.15;overflow-wrap:anywhere}
.sub{margin:0;color:var(--dim)}
.addr{display:flex;flex-direction:column;gap:4px;margin-top:8px;max-width:560px}
.addr label{font-size:13px;color:var(--faint)}
.addr .row{display:flex;gap:8px}
.addr input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
button{font:inherit;font-weight:600;padding:8px 16px;border-radius:10px;border:0;background:var(--accent);color:var(--ink);cursor:pointer}
.about{color:var(--dim);max-width:72ch;margin:20px 0 0}
.failing{color:var(--accent2)}
article{padding:12px 0;border-bottom:1px solid var(--line);max-width:80ch}
article p{margin:4px 0;color:var(--dim)}
@media (max-width:600px){
header{padding:10px 16px;gap:10px}
.brand span,.brand{font-size:15px}
main{padding:16px}
.show{flex-direction:column;align-items:flex-start;gap:14px}
.art.lg{width:140px}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
}

View File

@@ -0,0 +1,10 @@
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
// Without it, the address is in a field of its own to select by hand.
document.addEventListener('click', ev => {
const b = ev.target.closest('[data-copy]');
if (!b || !navigator.clipboard) return;
navigator.clipboard.writeText(b.dataset.copy).then(() => {
b.textContent = 'Copied';
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
});
});

View File

@@ -0,0 +1,428 @@
//! The directory in a web browser (#215): anyone may browse it, no token, read only. The most
//! subscribed and the categories, every feed A to Z, a search by name, and a page per feed with
//! its address to subscribe to in any podcast app. Rendered here from the store, as plain HTML:
//! no build step, and nothing the API does not already hold.
//!
//! A feed's description and items are the publisher's HTML, unsanitized in the store; they are
//! shown as text, tags stripped, and an item's link only when it is http(s).
use std::collections::BTreeMap;
use std::fmt::Write;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use crate::{Listing, Shared};
pub fn router() -> Router<Shared> {
Router::new()
.route("/", get(home))
.route("/all", get(all))
.route("/search", get(search))
.route("/category/{name}", get(category))
.route("/feed/{id}", get(feed))
.route("/site.css", get(|| asset("text/css; charset=utf-8", include_bytes!("site.css"))))
.route("/site.js", get(|| asset("text/javascript; charset=utf-8", include_bytes!("site.js"))))
.route("/logo.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo.svg"))))
.route("/logo-dark.svg", get(|| asset("image/svg+xml", include_bytes!("../../../web/logo-dark.svg"))))
.route("/favicon.png", get(|| asset("image/png", include_bytes!("../../../web/favicon.png"))))
}
/// Its own script and stylesheet only; covers come from their publishers.
const CSP: &str = "default-src 'none'; img-src https: http: data:; style-src 'self'; script-src 'self'; \
base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
async fn asset(kind: &'static str, body: &'static [u8]) -> Response {
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
}
#[derive(serde::Deserialize, Default)]
struct Filter {
/// `podcasts` or `blogs`; anything else is both.
kind: Option<String>,
sub: Option<String>,
q: Option<String>,
}
impl Filter {
fn keep(&self, l: &Listing) -> bool {
match self.kind.as_deref() {
Some("podcasts") => l.podcast,
Some("blogs") => !l.podcast,
_ => true,
}
}
}
async fn home(State(dir): State<Shared>) -> Response {
let all = match dir.pages_list().await {
Ok(a) => a,
Err(e) => return failed(e),
};
let mut top = (*all).clone();
top.sort_by(|a, b| {
b.subscribers.cmp(&a.subscribers).then_with(|| name(a).to_lowercase().cmp(&name(b).to_lowercase()))
});
top.truncate(10);
let mut cats: BTreeMap<&str, Vec<&Listing>> = BTreeMap::new();
for l in all.iter() {
cats.entry(l.category.as_deref().unwrap_or("Other")).or_default().push(l);
}
let mut b = String::new();
if all.is_empty() {
b.push_str("<p class=hint>Nothing is listed yet. A feed appears once a server has shared it and the directory has read it.</p>");
} else {
b.push_str("<h2>Most subscribed</h2><ol class=chart>");
for (i, l) in top.iter().enumerate() {
let _ = write!(
b,
"<li><a href=\"/feed/{}\"><span class=no>{}</span>{}<span class=txt><b>{}</b><small>{}</small></span></a></li>",
esc(&l.id),
i + 1,
art(l, "art sm"),
esc(name(l)),
subscribers(l.subscribers)
);
}
b.push_str("</ol><h2>Categories</h2><div class=cats>");
for (c, ls) in &cats {
let _ = write!(
b,
"<a class=cat href=\"/category/{}\"><b>{}</b><small>{}</small></a>",
seg(c),
esc(c),
ls.len()
);
}
let _ = write!(b, "</div><p><a class=more href=\"/all\">All {} feeds, A to Z</a></p>", all.len());
}
page("iPX Directory", None, &b)
}
async fn all(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
listed(&dir, "All feeds", "/all", &f, |_| true).await
}
async fn search(State(dir): State<Shared>, Query(f): Query<Filter>) -> Response {
let q = f.q.clone().unwrap_or_default().trim().to_lowercase();
if q.is_empty() {
return redirect("/all");
}
let title = format!("Results for \u{201c}{}\u{201d}", f.q.as_deref().unwrap_or("").trim());
listed(&dir, &title, "/search", &f, move |l| name(l).to_lowercase().contains(&q)).await
}
async fn category(State(dir): State<Shared>, Path(c): Path<String>, Query(f): Query<Filter>) -> Response {
let here = format!("/category/{}", seg(&c));
let cat = c.clone();
listed(&dir, &c, &here, &f, move |l| l.category.as_deref().unwrap_or("Other") == cat).await
}
/// A page of tiles: those `pick` takes, of the kind and subcategory asked for, A to Z.
async fn listed(dir: &Shared, title: &str, here: &str, f: &Filter, pick: impl Fn(&Listing) -> bool) -> Response {
let mut ls: Vec<Listing> = match dir.pages_list().await {
Ok(a) => a.iter().filter(|l| pick(l)).cloned().collect(),
Err(e) => return failed(e),
};
if ls.is_empty() && here.starts_with("/category/") {
return not_found("That category has nothing in it.");
}
let subs: BTreeMap<String, usize> =
ls.iter().filter_map(|l| l.subcategory.clone()).fold(BTreeMap::new(), |mut m, s| {
*m.entry(s).or_default() += 1;
m
});
ls.retain(|l| f.keep(l) && f.sub.as_ref().is_none_or(|s| l.subcategory.as_ref() == Some(s)));
ls.sort_by_key(|l| name(l).to_lowercase());
// The query a link keeps, with one of its keys changed.
let link = |kind: Option<&str>, sub: Option<&str>| {
let mut q = url::form_urlencoded::Serializer::new(String::new());
if let Some(v) = f.q.as_deref() {
q.append_pair("q", v);
}
if let Some(v) = kind {
q.append_pair("kind", v);
}
if let Some(v) = sub {
q.append_pair("sub", v);
}
let q = q.finish();
if q.is_empty() { here.to_owned() } else { format!("{here}?{q}") }
};
let mut b = String::new();
let _ = write!(
b,
"<p class=back><a href=\"/\">Directory</a></p><h2>{}</h2><nav class=tabs aria-label=Kind>",
esc(title)
);
for (k, label) in [(None, "All"), (Some("podcasts"), "Podcasts"), (Some("blogs"), "Blogs")] {
let on = f.kind.as_deref() == k;
let _ = write!(
b,
"<a href=\"{}\"{}>{label}</a>",
esc(&link(k, f.sub.as_deref())),
if on { " aria-current=page" } else { "" }
);
}
b.push_str("</nav>");
if !subs.is_empty() {
b.push_str("<nav class=pills aria-label=Subcategory>");
let _ = write!(
b,
"<a href=\"{}\"{}>All</a>",
esc(&link(f.kind.as_deref(), None)),
if f.sub.is_none() { " aria-current=page" } else { "" }
);
for (s, n) in &subs {
let on = f.sub.as_deref() == Some(s.as_str());
let _ = write!(
b,
"<a href=\"{}\"{}>{} <small>{n}</small></a>",
esc(&link(f.kind.as_deref(), Some(s))),
if on { " aria-current=page" } else { "" },
esc(s)
);
}
b.push_str("</nav>");
}
if ls.is_empty() {
b.push_str("<p class=hint>Nothing here.</p>");
} else {
let _ = write!(b, "<p class=count>{}</p><div class=tiles>", plural(ls.len(), "feed"));
for l in &ls {
let _ = write!(
b,
"<a class=tile href=\"/feed/{}\">{}<b>{}</b><small>{}</small></a>",
esc(&l.id),
art(l, "art"),
esc(name(l)),
subscribers(l.subscribers)
);
}
b.push_str("</div>");
}
page(title, None, &b)
}
async fn feed(State(dir): State<Shared>, Path(id): Path<String>) -> Response {
let p = match dir.store.feed(&id).await {
Ok(Some(p)) => p,
Ok(None) => return not_found("That feed is not in the directory."),
Err(e) => return failed(e),
};
let l = &p.listing;
let mut b = String::new();
b.push_str("<p class=back><a href=\"/\">Directory</a>");
if let Some(c) = &l.category {
let _ = write!(b, " <span aria-hidden=true>/</span> <a href=\"/category/{}\">{}</a>", seg(c), esc(c));
}
let _ = write!(
b,
"</p><div class=show>{}<div class=meta><h2>{}</h2><p class=sub>{}{}</p>",
art(l, "art lg"),
esc(name(l)),
subscribers(l.subscribers),
l.subcategory.as_deref().map(|s| format!(" &middot; {}", esc(s))).unwrap_or_default()
);
let _ = write!(
b,
"<div class=addr><label for=addr>Feed address</label><span class=row><input id=addr readonly value=\"{}\" spellcheck=false>\
<button type=button data-copy=\"{}\">Copy</button></span><small>Paste it into any podcast app or feed reader to subscribe.</small></div></div></div>",
esc(&l.url),
esc(&l.url)
);
if let Some(d) = p.description.as_deref().map(text).filter(|d| !d.is_empty() && d != name(l)) {
let _ = write!(b, "<p class=about>{}</p>", esc(&d));
}
if let Some(why) = &p.failing {
let _ = write!(b, "<p class=failing>{}</p>", esc(why));
}
let _ = write!(b, "<h3>{}</h3>", if l.podcast { "Latest episodes" } else { "Latest posts" });
if p.items.is_empty() {
b.push_str("<p class=hint>Nothing read from it yet. The directory checks each feed every six hours.</p>");
}
for e in &p.items {
let title = e.title.as_deref().map(text).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled".into());
let head = match e.link.as_deref().filter(|u| u.starts_with("https://") || u.starts_with("http://")) {
Some(u) => format!("<a href=\"{}\" rel=\"noopener nofollow\">{}</a>", esc(u), esc(&title)),
None => esc(&title),
};
let blurb: String = e.description.as_deref().map(text).unwrap_or_default().chars().take(400).collect();
let when = [e.published.map(date), e.duration.filter(|d| *d > 0).map(clock)]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" &middot; ");
let _ = write!(
b,
"<article><b>{head}</b>{}{}</article>",
if blurb.is_empty() { String::new() } else { format!("<p>{}</p>", esc(&blurb)) },
if when.is_empty() { String::new() } else { format!("<small>{when}</small>") }
);
}
page(name(l), Some(&text(p.description.as_deref().unwrap_or(""))), &b)
}
fn page(title: &str, description: Option<&str>, body: &str) -> Response {
let full = if title == "iPX Directory" { title.to_owned() } else { format!("{title} | iPX Directory") };
let desc = description
.filter(|d| !d.is_empty())
.map(|d| d.chars().take(200).collect::<String>())
.unwrap_or_else(|| "Public podcasts and blogs that iPX servers share.".into());
let html = format!(
"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">\
<title>{}</title><meta name=description content=\"{}\"><meta name=color-scheme content=\"light dark\">\
<link rel=icon href=/favicon.png><link rel=stylesheet href=/site.css><script src=/site.js defer></script></head>\
<body><header><a class=brand href=\"/\"><picture><source srcset=/logo-dark.svg media=\"(prefers-color-scheme: dark)\">\
<img src=/logo.svg alt=\"\" width=32 height=32></picture>iPX Directory</a>\
<form action=/search role=search><input type=search name=q placeholder=\"Find a feed\" aria-label=\"Find a feed by name\"></form></header>\
<main>{body}</main><footer>Public podcasts and blogs that iPX servers share, and how many people on them subscribe. \
Read only: to change what is here, an iPX server shares its public feeds.</footer></body></html>",
esc(&full),
esc(&desc),
);
let mut r = html.into_response();
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
r
}
fn not_found(why: &str) -> Response {
let mut r = page(
"Not found",
None,
&format!("<p class=back><a href=\"/\">Directory</a></p><h2>Not found</h2><p class=hint>{}</p>", esc(why)),
);
*r.status_mut() = StatusCode::NOT_FOUND;
r
}
fn failed(e: anyhow::Error) -> Response {
tracing::error!(error = %format!("{e:#}"), "a directory page failed");
let mut r = page(
"Unavailable",
None,
"<h2>Unavailable</h2><p class=hint>The directory could not read its list just now. Try again in a minute.</p>",
);
*r.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
r
}
fn redirect(to: &'static str) -> Response {
(StatusCode::SEE_OTHER, [(header::LOCATION, to)]).into_response()
}
/// A cover from its publisher, over the feed's initials, which show where it has none or it fails.
fn art(l: &Listing, class: &str) -> String {
// The first letter of each of the first two words that have one: `<antirez>` is A, not nothing.
let initials: String =
name(l).split_whitespace().filter_map(|w| w.chars().find(|c| c.is_alphanumeric())).take(2).collect();
let img = l
.image
.as_deref()
.filter(|u| u.starts_with("https://") || u.starts_with("http://"))
.map(|u| format!("<img src=\"{}\" alt=\"\" loading=lazy referrerpolicy=no-referrer>", esc(u)))
.unwrap_or_default();
format!("<span class=\"{class}\" aria-hidden=true><i>{}</i>{img}</span>", esc(&initials.to_uppercase()))
}
fn name(l: &Listing) -> &str {
l.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&l.id)
}
fn subscribers(n: i64) -> String {
match n {
0 => "Listed, no subscribers yet".into(),
1 => "1 person subscribes".into(),
n => format!("{n} people subscribe"),
}
}
fn plural(n: usize, word: &str) -> String {
format!("{n} {word}{}", if n == 1 { "" } else { "s" })
}
/// `12 Oct 2026`, from a Unix time, with no date library: the civil-from-days algorithm.
fn date(t: i64) -> String {
let z = t.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
format!("{d} {} {y}", MONTHS[(m - 1) as usize])
}
fn clock(secs: i64) -> String {
let (h, m) = (secs / 3600, secs % 3600 / 60);
if h > 0 { format!("{h} h {m} min") } else { format!("{} min", m.max(1)) }
}
/// The publisher's HTML as plain text: tags gone, entities read, whitespace collapsed. Escaped
/// again where it is shown.
fn text(html: &str) -> String {
// A space where each tag was, or two paragraphs run together: "drives.For over".
let stripped = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
let decoded = stripped
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&");
decoded.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
c => out.push(c),
}
}
out
}
/// A category as a path segment.
fn seg(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_publishers_html_becomes_text_and_is_escaped_where_shown() {
assert_eq!(text("<p>Tom &amp; Jerry <script>alert(1)</script><b>live</b></p>"), "Tom & Jerry live");
assert_eq!(text("<p>One.</p><p>Two.</p>"), "One. Two.");
assert_eq!(esc(&text("a &lt;b&gt; c")), "a &lt;b&gt; c");
assert_eq!(esc("\"><img onerror=x>"), "&quot;&gt;&lt;img onerror=x&gt;");
}
#[test]
fn dates_and_lengths_read_as_people_write_them() {
assert_eq!(date(0), "1 Jan 1970");
assert_eq!(date(1_791_820_800), "12 Oct 2026");
assert_eq!(date(951_782_400), "29 Feb 2000");
assert_eq!(clock(59), "1 min");
assert_eq!(clock(3_725), "1 h 2 min");
}
}

View File

@@ -0,0 +1,675 @@
//! The directory's database: the feeds instances have sent, what each instance sent, and what
//! an admin has blocked. SQL written by hand, as ipx's is, so it runs on SQLite (tests) and
//! Postgres (production) both: `$1` parameters, `ON CONFLICT`, booleans as themselves.
use anyhow::Result;
use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait, Value};
use crate::{Blocked, FeedPage, Instance, Item, Listing, SnapshotEntry, SnapshotResult};
/// A feed's items as its page shows them, and as many as ipx's own Directory page showed.
pub const ITEMS: usize = 20;
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
const FLAG_AFTER_SECS: i64 = 24 * 3600;
/// The most subscribers one instance may say a feed has.
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
pub struct Store {
db: DatabaseConnection,
}
/// What the refresher needs to ask for a feed again.
pub struct Due {
pub id: String,
pub url: String,
pub etag: Option<String>,
pub last_modified: Option<String>,
}
/// What a fetch found, to be stored.
pub enum Refreshed {
NotModified,
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
Failed(String),
}
pub fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
}
/// The address as every instance would write it: scheme and host lower-cased, a default port
/// and a fragment dropped. None for anything that is not http(s).
pub fn normalise(url: &str) -> Option<String> {
let mut u = url::Url::parse(url.trim()).ok()?;
if !matches!(u.scheme(), "http" | "https") || u.host_str().is_none() {
return None;
}
u.set_fragment(None);
Some(u.to_string())
}
/// A feed's id: the first 64 bits of its normalised address's SHA-256, so every instance, and
/// anyone else, arrives at the same id for the same feed without asking.
pub fn feed_id(normalised: &str) -> String {
use sha2::Digest;
sha2::Sha256::digest(normalised.as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect()
}
const SCHEMA: &[&str] = &[
"CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT,
image TEXT,
category TEXT,
subcategory TEXT,
description TEXT,
podcast BOOLEAN NOT NULL DEFAULT false,
items TEXT,
etag TEXT,
last_modified TEXT,
checked_at BIGINT,
last_error TEXT,
error_since BIGINT
)",
"CREATE TABLE IF NOT EXISTS snapshots (
instance TEXT NOT NULL,
feed_id TEXT NOT NULL,
subscribers BIGINT NOT NULL,
category TEXT,
subcategory TEXT,
PRIMARY KEY (instance, feed_id)
)",
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
// When each periodic job last finished: Apple's weekly sync (#252).
"CREATE TABLE IF NOT EXISTS syncs (
name TEXT PRIMARY KEY,
at BIGINT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS blocked (
id TEXT PRIMARY KEY,
url TEXT,
title TEXT,
at BIGINT NOT NULL
)",
];
impl Store {
/// A database of its own for a test: a fresh schema on IPX_TEST_DATABASE_URL's Postgres when
/// that is set, as ipx's tests do, or else SQLite in memory.
#[cfg(test)]
pub async fn scratch() -> Self {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
return Self::open("sqlite::memory:", false).await.unwrap();
};
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
// A bare connection: Store::open would make the tables in the public schema, and the
// tests running at once raced each other doing it.
let admin = Self { db: sea_orm::Database::connect(url.as_str()).await.unwrap() };
// Schemas an earlier run left behind: anything of ours not made by this process.
let old = admin
.rows(&format!(r"SELECT nspname FROM pg_namespace WHERE nspname LIKE 'ipxd\_%' AND nspname NOT LIKE 'ipxd\_{pid}\_%'"), vec![])
.await
.unwrap();
for r in old {
let name: String = r.try_get_by_index(0).unwrap();
admin.db.execute_unprepared(&format!("DROP SCHEMA IF EXISTS {name} CASCADE")).await.unwrap();
}
let schema = format!("ipxd_{pid}_{n}");
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
let sep = if url.contains('?') { '&' } else { '?' };
Self::open(
&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"),
false,
)
.await
.unwrap()
}
/// `keep_open` for the server, which keeps its whole pool connected.
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
let mut url = url.to_owned();
if url.starts_with("postgres") && !url.contains("options=") {
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
// asking for none keeps them out of the log, as ipx's connections do.
let sep = if url.contains('?') { '&' } else { '?' };
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
}
let mut opts = sea_orm::ConnectOptions::new(url.clone());
opts.sqlx_logging(false);
// An in-memory SQLite database is one per connection: a pool of them would be several.
if url.contains(":memory:") {
opts.max_connections(1);
}
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
if keep_open && url.starts_with("postgres") {
opts.max_connections(10).min_connections(10);
}
let db = sea_orm::Database::connect(opts).await?;
for sql in SCHEMA {
db.execute_unprepared(sql).await?;
}
// Added after the first deploy: a database made before has the table without them.
if db.execute_unprepared("SELECT category FROM snapshots WHERE false").await.is_err() {
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN category TEXT").await?;
db.execute_unprepared("ALTER TABLE snapshots ADD COLUMN subcategory TEXT").await?;
}
Ok(Self { db })
}
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
}
async fn exec(&self, sql: &str, values: Vec<Value>) -> Result<u64> {
Ok(self.db.execute_raw(self.stmt(sql, values)).await?.rows_affected())
}
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<sea_orm::QueryResult>> {
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
}
/// When the job `name` last finished, Unix seconds.
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
Some(r) => Some(r.try_get("", "at")?),
None => None,
})
}
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
self.exec(
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
vec![name.into(), at.into()],
)
.await?;
Ok(())
}
pub async fn ping(&self) -> Result<()> {
self.rows("SELECT 1", vec![]).await?;
Ok(())
}
/// Replaces everything `instance` sent before with `entries`. A private-looking address or a
/// blocked feed is left out and counted; the rest are added to the feeds, new ones to be
/// fetched by the refresher. Feeds no instance sends any more go.
pub async fn snapshot(&self, instance: &str, entries: &[SnapshotEntry]) -> Result<SnapshotResult> {
let blocked: std::collections::HashSet<String> = self
.rows("SELECT id FROM blocked", vec![])
.await?
.iter()
.map(|r| r.try_get("", "id"))
.collect::<Result<_, _>>()?;
let mut result = SnapshotResult::default();
let mut keep = std::collections::BTreeMap::new();
for e in entries {
let Some(url) = normalise(&e.url) else {
result.refused_invalid += 1;
continue;
};
// Checked here as well as by the sender: one instance's miss must not publish
// someone's paid feed to every other.
if ipx_feed::url_looks_private(&url) {
result.refused_private += 1;
continue;
}
let id = feed_id(&url);
if blocked.contains(&id) {
result.refused_blocked += 1;
continue;
}
// The same feed twice in one snapshot counts once, at its larger number.
// Bounded above as well: two instances' i64::MAX summed past BIGINT, and every listing
// and page failed until the instance was dropped (#342). No server has a million.
let n = e.subscribers.clamp(0, MAX_SUBSCRIBERS);
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
let slot = keep.entry(id).or_insert((url, n, path.clone()));
slot.1 = slot.1.max(n);
if slot.2.is_none() {
slot.2 = path;
}
}
result.accepted = keep.len();
let tx = self.db.begin().await?;
let backend = tx.get_database_backend();
let run = |sql: &str, values: Vec<Value>| Statement::from_sql_and_values(backend, sql, values);
tx.execute_raw(run("DELETE FROM snapshots WHERE instance = $1", vec![instance.into()])).await?;
for (id, (url, n, path)) in &keep {
tx.execute_raw(run(
"INSERT INTO feeds (id, url) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
vec![id.clone().into(), url.clone().into()],
))
.await?;
tx.execute_raw(run(
"INSERT INTO snapshots (instance, feed_id, subscribers, category, subcategory) VALUES ($1, $2, $3, $4, $5)",
vec![
instance.into(),
id.clone().into(),
(*n).into(),
path.as_ref().map(|p| p.0.clone()).into(),
path.as_ref().and_then(|p| p.1.clone()).into(),
],
))
.await?;
}
tx.execute_raw(run("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![])).await?;
tx.commit().await?;
Ok(result)
}
/// Drops the snapshots of every instance not in `names`: a token taken out of the config
/// file takes its counts with it.
pub async fn keep_instances(&self, names: &[String]) -> Result<u64> {
let mut dropped = 0;
let present: Vec<String> = self
.rows("SELECT DISTINCT instance FROM snapshots", vec![])
.await?
.iter()
.map(|r| r.try_get("", "instance"))
.collect::<Result<_, _>>()?;
// Apple's charts are no token's, and stay until the next sync replaces them.
for name in present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE) {
dropped += self.drop_instance(name).await?;
}
Ok(dropped)
}
/// Drops what one instance sent; how many feeds it had.
pub async fn drop_instance(&self, name: &str) -> Result<u64> {
let n = self.exec("DELETE FROM snapshots WHERE instance = $1", vec![name.into()]).await?;
self.exec("DELETE FROM feeds WHERE id NOT IN (SELECT feed_id FROM snapshots)", vec![]).await?;
Ok(n)
}
// The feed's own category, or else one a sending server's admin gave it.
// Only feeds read as a feed at least once (`items` set): never an OPML a server sent before it
// knew it was one, nor an address that has never worked.
const LISTING: &str = "SELECT f.id, f.url, f.title, f.image,
CASE WHEN f.category IS NULL THEN max(s.category) ELSE f.category END AS category,
CASE WHEN f.category IS NULL THEN max(s.subcategory) ELSE f.subcategory END AS subcategory,
f.podcast, CAST(sum(s.subscribers) AS BIGINT) AS subscribers
FROM feeds f JOIN snapshots s ON s.feed_id = f.id WHERE f.items IS NOT NULL";
fn listing(r: &sea_orm::QueryResult) -> Result<Listing> {
Ok(Listing {
id: r.try_get("", "id")?,
url: r.try_get("", "url")?,
title: r.try_get("", "title")?,
image: r.try_get("", "image")?,
category: r.try_get("", "category")?,
subcategory: r.try_get("", "subcategory")?,
podcast: r.try_get("", "podcast")?,
subscribers: r.try_get("", "subscribers")?,
})
}
/// Every feed, A to Z by title (one not fetched yet has none and sorts by its address).
///
/// ponytail: the whole list in one answer, as ipx's own Directory was; page it when it
/// grows past a few thousand feeds.
pub async fn list(&self) -> Result<Vec<Listing>> {
let sql = format!("{} GROUP BY f.id ORDER BY lower(coalesce(f.title, f.url)), f.id", Self::LISTING);
self.rows(&sql, vec![]).await?.iter().map(Self::listing).collect()
}
/// The most subscribed, most first.
pub async fn popular(&self, limit: u64) -> Result<Vec<Listing>> {
let sql = format!(
"{} GROUP BY f.id ORDER BY sum(s.subscribers) DESC, lower(coalesce(f.title, f.url)), f.id LIMIT $1",
Self::LISTING
);
self.rows(&sql, vec![(limit as i64).into()]).await?.iter().map(Self::listing).collect()
}
/// One feed with its description and latest items.
pub async fn feed(&self, id: &str) -> Result<Option<FeedPage>> {
let sql = format!("{} AND f.id = $1 GROUP BY f.id", Self::LISTING);
let Some(r) = self.rows(&sql, vec![id.into()]).await?.into_iter().next() else { return Ok(None) };
let listing = Self::listing(&r)?;
let r = self
.rows("SELECT description, items, last_error, error_since FROM feeds WHERE id = $1", vec![id.into()])
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("feed {id} went while it was read"))?;
let items: Option<String> = r.try_get("", "items")?;
let last_error: Option<String> = r.try_get("", "last_error")?;
let error_since: Option<i64> = r.try_get("", "error_since")?;
Ok(Some(FeedPage {
listing,
description: r.try_get("", "description")?,
failing: last_error.map(|e| failure_words(&e, now() - error_since.unwrap_or_else(now))),
items: items.and_then(|i| serde_json::from_str(&i).ok()).unwrap_or_default(),
}))
}
/// Removes a feed and blocks it, so no snapshot brings it back. False when it was neither
/// listed nor blocked already.
pub async fn block(&self, id: &str) -> Result<bool> {
let r = self.rows("SELECT url, title FROM feeds WHERE id = $1", vec![id.into()]).await?.into_iter().next();
let (url, title): (Option<String>, Option<String>) = match &r {
Some(r) => (r.try_get("", "url")?, r.try_get("", "title")?),
None => (None, None),
};
let added = self
.exec(
"INSERT INTO blocked (id, url, title, at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
vec![id.into(), url.into(), title.into(), now().into()],
)
.await?;
self.exec("DELETE FROM snapshots WHERE feed_id = $1", vec![id.into()]).await?;
let removed = self.exec("DELETE FROM feeds WHERE id = $1", vec![id.into()]).await?;
Ok(added + removed > 0)
}
/// Unblocks a feed; it comes back with the next snapshot that has it.
pub async fn unblock(&self, id: &str) -> Result<bool> {
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
}
pub async fn instances(&self) -> Result<Vec<Instance>> {
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
self.rows(
"SELECT instance, count(*) AS feeds, CAST(sum(subscribers) AS BIGINT) AS subscribers
FROM snapshots GROUP BY instance ORDER BY instance",
vec![],
)
.await?
.iter()
.map(|r| {
Ok(Instance {
name: r.try_get("", "instance")?,
feeds: r.try_get("", "feeds")?,
subscribers: r.try_get("", "subscribers")?,
})
})
.collect()
}
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
.await?
.iter()
.map(|r| {
Ok(Blocked {
id: r.try_get("", "id")?,
url: r.try_get("", "url")?,
title: r.try_get("", "title")?,
at: r.try_get("", "at")?,
})
})
.collect()
}
/// Feeds never fetched, then those fetched longest ago, older than `every` seconds.
pub async fn due(&self, every: i64, limit: u64) -> Result<Vec<Due>> {
self.rows(
"SELECT id, url, etag, last_modified FROM feeds
WHERE checked_at IS NULL OR checked_at < $1
ORDER BY coalesce(checked_at, 0), id LIMIT $2",
vec![(now() - every).into(), (limit as i64).into()],
)
.await?
.iter()
.map(|r| {
Ok(Due {
id: r.try_get("", "id")?,
url: r.try_get("", "url")?,
etag: r.try_get("", "etag")?,
last_modified: r.try_get("", "last_modified")?,
})
})
.collect()
}
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
.await?
.first()
.map(|r| -> Result<Due> {
Ok(Due {
id: r.try_get("", "id")?,
url: r.try_get("", "url")?,
etag: r.try_get("", "etag")?,
last_modified: r.try_get("", "last_modified")?,
})
})
.transpose()
}
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
let t = now();
match got {
Refreshed::NotModified => {
self.exec(
"UPDATE feeds SET checked_at = $1, last_error = NULL, error_since = NULL WHERE id = $2",
vec![t.into(), id.into()],
)
.await?;
}
Refreshed::Failed(err) => {
self.exec(
"UPDATE feeds SET checked_at = $1, last_error = $2, error_since = coalesce(error_since, $1) WHERE id = $3",
vec![t.into(), err.into(), id.into()],
)
.await?;
}
Refreshed::Feed { parsed, etag, last_modified } => {
let path = parsed.category.as_deref().map(ipx_feed::category_path);
let podcast = parsed
.entries
.iter()
.flat_map(|e| &e.enclosures)
.any(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/") || m.starts_with("video/")));
let mut entries = parsed.entries;
entries.sort_by_key(|e| std::cmp::Reverse(e.published));
// What an item is, never its files: their addresses are where a key would be.
let items: Vec<Item> = entries
.into_iter()
.take(ITEMS)
.map(|e| Item {
guid: e.guid,
title: e.title,
link: e.link,
published: e.published,
duration: e.duration,
description: e.description,
})
.collect();
self.exec(
"UPDATE feeds SET title = $1, image = $2, category = $3, subcategory = $4, description = $5,
podcast = $6, items = $7, etag = $8, last_modified = $9, checked_at = $10,
last_error = NULL, error_since = NULL
WHERE id = $11",
vec![
parsed.title.into(),
parsed.image.into(),
path.as_ref().map(|p| p.0.clone()).into(),
path.and_then(|p| p.1).into(),
parsed.description.into(),
podcast.into(),
serde_json::to_string(&items)?.into(),
etag.into(),
last_modified.into(),
t.into(),
id.into(),
],
)
.await?;
}
}
Ok(())
}
}
/// A feed's last error as its page says it: explained once it has been failing a day, or else
/// only that the check failed. Never the error itself, which is for the log.
fn failure_words(err: &str, failing_for: i64) -> String {
(failing_for >= FLAG_AFTER_SECS)
.then_some(err)
.and_then(ipx_feed::explain_failure)
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Reads the fixture feed as every feed waiting to be fetched, as the refresher would.
pub(crate) async fn fetch_all(s: &Store) {
for d in s.due(0, 100).await.unwrap() {
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
}
}
fn e(url: &str, n: i64) -> SnapshotEntry {
SnapshotEntry { url: url.into(), subscribers: n, category: None }
}
#[test]
fn a_failure_is_explained_after_a_day_and_never_names_its_address() {
let nothing = "the feed's address answered with nothing at all";
assert_eq!(failure_words(nothing, FLAG_AFTER_SECS), "This address answers with nothing; the site may be gone.");
// Not before it has been failing a day, as on the feed's own page in ipx (#143).
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
assert_eq!(failure_words(raw, FLAG_AFTER_SECS), "Its last check failed.");
}
#[test]
fn every_instance_writes_one_feed_the_same_way() {
let a = normalise("HTTPS://Example.COM:443/feed.xml#top").unwrap();
assert_eq!(a, "https://example.com/feed.xml");
assert_eq!(feed_id(&a), feed_id(&normalise("https://example.com/feed.xml").unwrap()));
assert_eq!(feed_id(&a).len(), 16);
assert!(normalise("ftp://example.com/x").is_none());
assert!(normalise("not a url").is_none());
}
#[tokio::test]
async fn no_count_an_instance_sends_breaks_the_listing() {
let s = Store::scratch().await;
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
fetch_all(&s).await;
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
assert!(s.instances().await.is_ok());
}
#[tokio::test]
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
let s = Store::scratch().await;
let r = s
.snapshot("a", &[e("https://x.test/a.xml", 3), e("https://x.test/b.xml", 1), e("https://x.test/a.xml", 5)])
.await
.unwrap();
assert_eq!(r.accepted, 2);
s.snapshot("b", &[e("https://x.test/a.xml", 2)]).await.unwrap();
// Nothing is listed until it has been read as a feed.
assert!(s.list().await.unwrap().is_empty());
fetch_all(&s).await;
let pop = s.popular(10).await.unwrap();
assert_eq!((pop[0].url.as_str(), pop[0].subscribers), ("https://x.test/a.xml", 7));
assert_eq!(pop[1].subscribers, 1);
// a no longer has b.xml: nobody does, so it goes.
s.snapshot("a", &[e("https://x.test/a.xml", 1)]).await.unwrap();
let all = s.list().await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].subscribers, 3);
let i = s.instances().await.unwrap();
assert_eq!(
i.iter().map(|i| (i.name.as_str(), i.feeds, i.subscribers)).collect::<Vec<_>>(),
[("a", 1, 1), ("b", 1, 2)]
);
// A token taken out of the config file takes its counts.
s.keep_instances(&["b".into()]).await.unwrap();
assert_eq!(s.list().await.unwrap()[0].subscribers, 2);
s.drop_instance("b").await.unwrap();
assert!(s.list().await.unwrap().is_empty());
}
#[tokio::test]
async fn private_looking_addresses_and_blocked_feeds_are_refused() {
let s = Store::scratch().await;
let r = s
.snapshot(
"a",
&[
e("https://feeds.supercast.com/feeds/abc123", 1),
e("https://x.test/feed?token=secret", 1),
e("https://user:pass@x.test/feed", 1),
e("https://www.patreon.com/rss/show?auth=k", 1),
e("mailto:x@y", 1),
e("https://x.test/public.xml", 1),
],
)
.await
.unwrap();
assert_eq!((r.accepted, r.refused_private, r.refused_invalid), (1, 4, 1));
fetch_all(&s).await;
let id = s.list().await.unwrap()[0].id.clone();
assert!(s.block(&id).await.unwrap());
assert!(s.list().await.unwrap().is_empty());
let r = s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap();
assert_eq!((r.accepted, r.refused_blocked), (0, 1));
assert_eq!(s.blocked().await.unwrap()[0].url.as_deref(), Some("https://x.test/public.xml"));
assert!(s.unblock(&id).await.unwrap());
assert_eq!(s.snapshot("a", &[e("https://x.test/public.xml", 1)]).await.unwrap().accepted, 1);
}
#[tokio::test]
async fn a_feed_naming_no_category_takes_the_one_a_server_gave_it() {
let s = Store::scratch().await;
let entry = |c: Option<&str>| SnapshotEntry {
url: "https://x.test/blog.xml".into(),
subscribers: 1,
category: c.map(Into::into),
};
s.snapshot("a", &[entry(Some("Visual Arts"))]).await.unwrap();
let d = s.due(0, 10).await.unwrap().remove(0);
let mut parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
parsed.category = None;
s.save(&d.id, Refreshed::Feed { parsed, etag: None, last_modified: None }).await.unwrap();
let l = s.list().await.unwrap().remove(0);
assert_eq!((l.category.as_deref(), l.subcategory.as_deref()), (Some("Arts"), Some("Visual Arts")));
s.snapshot("a", &[entry(None)]).await.unwrap();
assert_eq!(s.list().await.unwrap()[0].category, None);
}
#[tokio::test]
async fn a_fetched_feed_shows_its_metadata_and_newest_items_but_no_files() {
let s = Store::scratch().await;
s.snapshot("a", &[e("https://x.test/feed.xml", 1)]).await.unwrap();
let due = s.due(3600, 10).await.unwrap();
assert_eq!(due.len(), 1);
let parsed = ipx_feed::parse(include_bytes!("../../../tests/data/rss2.xml")).unwrap();
let title = parsed.title.clone();
s.save(&due[0].id, Refreshed::Feed { parsed, etag: Some("e".into()), last_modified: None }).await.unwrap();
assert!(s.due(3600, 10).await.unwrap().is_empty());
let page = s.feed(&due[0].id).await.unwrap().unwrap();
assert_eq!(page.listing.title, title);
assert!(page.listing.podcast);
assert!(!page.items.is_empty() && page.items.len() <= ITEMS);
assert!(page.items.windows(2).all(|w| w[0].published >= w[1].published));
assert!(page.failing.is_none());
let json = serde_json::to_string(&page).unwrap();
assert!(!json.contains(".mp3"), "an item's file must not be in its page: {json}");
s.save(&due[0].id, Refreshed::Failed("HTTP 404 Not Found".into())).await.unwrap();
let page = s.feed(&due[0].id).await.unwrap().unwrap();
assert_eq!(page.failing.as_deref(), Some("Its last check failed."));
assert_eq!(page.listing.title, title, "a failure keeps what was found before");
}
}

24
crates/feed/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "ipx-feed"
version = "0.0.0"
publish = false
edition.workspace = true
[dependencies]
anyhow.workspace = true
atom_syndication.workspace = true
chrono.workspace = true
http.workspace = true
hyper-util.workspace = true
opml.workspace = true
pulldown-cmark.workspace = true
quick-xml.workspace = true
reqwest.workspace = true
rss.workspace = true
serde_json.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true
[dev-dependencies]
tokio.workspace = true

269
crates/feed/src/guard.rs Normal file
View File

@@ -0,0 +1,269 @@
//! Keeps fetches off internal addresses (#320). A feed, an episode, its artwork and a .torrent are
//! all at addresses a publisher or any signed-in person names, and redirects go anywhere, so
//! without this the server fetched from its own loopback, its Docker network and the LAN, and
//! handed back what answered.
//!
//! The check is on the connection, after the name is resolved and before a byte of the request
//! is sent, so it covers every request, every redirect, an address written as an IP, and a name
//! that resolves to an internal address only on the second lookup.
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use anyhow::{Context as _, Result};
use hyper_util::client::legacy::connect::{Connection, HttpInfo};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Internal addresses and networks a fetch may reach all the same: config.toml's `fetch_internal`,
/// as `192.168.1.20` or `192.168.1.0/24`. Names are not taken: the check sees only the address.
#[derive(Clone, Debug, Default)]
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
impl Allowed {
pub fn parse(list: &[String]) -> Result<Self> {
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
}
pub fn permits(&self, ip: IpAddr) -> bool {
let ip = canonical(ip);
!is_internal(ip) || self.0.iter().any(|&(net, bits)| contains(net, bits, ip))
}
}
fn parse_net(s: &str) -> Result<(IpAddr, u8)> {
let bad = || format!("fetch_internal: {s:?} is not an address or a network such as 192.168.1.0/24");
let (addr, bits) = s.trim().split_once('/').map_or((s.trim(), None), |(a, b)| (a, Some(b)));
let ip = canonical(addr.parse::<IpAddr>().with_context(bad)?);
let max = if ip.is_ipv4() { 32 } else { 128 };
let bits = match bits {
Some(b) => b.parse::<u8>().ok().filter(|n| *n <= max).with_context(bad)?,
None => max,
};
Ok((ip, bits))
}
/// An IPv4 address written as IPv6 (`::ffff:127.0.0.1`) is the IPv4 one.
fn canonical(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
v4 => v4,
}
}
fn contains(net: IpAddr, bits: u8, ip: IpAddr) -> bool {
match (net, ip) {
(IpAddr::V4(n), IpAddr::V4(i)) => {
let mask = u32::MAX.checked_shl(32 - u32::from(bits)).unwrap_or(0);
u32::from(n) & mask == u32::from(i) & mask
}
(IpAddr::V6(n), IpAddr::V6(i)) => {
let mask = u128::MAX.checked_shl(128 - u32::from(bits)).unwrap_or(0);
u128::from(n) & mask == u128::from(i) & mask
}
_ => false,
}
}
/// Not on the public internet: loopback, private, link-local, shared (CGNAT), multicast and
/// reserved ranges, and their IPv6 counterparts. `IpAddr::is_global` would say this, but is
/// unstable.
// shortcut: 6to4 (2002::/16) and Teredo addresses that wrap an internal IPv4 one pass; check the
// embedded address if an IPv6 route to them ever exists here.
pub fn is_internal(ip: IpAddr) -> bool {
match canonical(ip) {
IpAddr::V4(a) => {
let [o0, o1, o2, _] = a.octets();
a.is_private()
|| a.is_loopback()
|| a.is_link_local()
|| a.is_unspecified()
|| a.is_broadcast()
|| a.is_multicast()
|| o0 == 0
|| o0 >= 240
|| (o0 == 100 && o1 & 0xc0 == 64)
|| (o0 == 192 && o1 == 0 && o2 == 0)
}
IpAddr::V6(a) => {
let s = a.segments();
a.is_loopback()
|| a.is_unspecified()
|| a.is_multicast()
|| s[0] & 0xfe00 == 0xfc00
|| s[0] & 0xffc0 == 0xfe80
// NAT64 carries an IPv4 address in its last 32 bits.
|| (s[0] == 0x64 && s[1] == 0xff9b && is_internal(IpAddr::V4(Ipv4Addr::from(u128::from(a) as u32))))
}
}
}
/// A connector layer for `reqwest::ClientBuilder::connector_layer`: a connection to an address
/// `Allowed` does not permit fails before the request is sent.
#[derive(Clone)]
pub struct Guard(Allowed);
impl Guard {
pub fn new(allowed: Allowed) -> Self {
Self(allowed)
}
}
impl<S> tower::Layer<S> for Guard {
type Service = Guarded<S>;
fn layer(&self, inner: S) -> Guarded<S> {
Guarded { inner, allowed: self.0.clone() }
}
}
#[derive(Clone)]
pub struct Guarded<S> {
inner: S,
allowed: Allowed,
}
impl<S, R> tower::Service<R> for Guarded<S>
where
S: tower::Service<R>,
S::Response: Connection,
S::Error: Into<BoxError>,
S::Future: Send + 'static,
{
type Response = S::Response;
type Error = BoxError;
type Future = Pin<Box<dyn Future<Output = Result<S::Response, BoxError>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
self.inner.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, req: R) -> Self::Future {
let connecting = self.inner.call(req);
let allowed = self.allowed.clone();
Box::pin(async move {
let conn = connecting.await.map_err(Into::into)?;
let mut extras = http::Extensions::new();
conn.connected().get_extras(&mut extras);
// Refused when the peer is unknown too: a connection this cannot check is one it
// cannot vouch for.
match extras.get::<HttpInfo>().map(|i| i.remote_addr().ip()) {
Some(ip) if allowed.permits(ip) => Ok(conn),
Some(ip) => {
Err(format!("{ip} is an internal address, not fetched (fetch_internal in config.toml)").into())
}
None => Err("the connection's address is unknown, so it is not fetched".into()),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn internal_addresses_are_known() {
for a in [
"127.0.0.1",
"10.1.2.3",
"172.16.0.1",
"192.168.1.130",
"169.254.169.254",
"100.64.0.1",
"0.0.0.0",
"224.0.0.1",
"255.255.255.255",
"::1",
"::",
"fd00::1",
"fe80::1",
"::ffff:127.0.0.1",
"64:ff9b::a00:1",
] {
assert!(is_internal(ip(a)), "{a}");
}
for a in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111", "64:ff9b::808:808", "100.128.0.1"] {
assert!(!is_internal(ip(a)), "{a}");
}
}
#[test]
fn the_allowlist_lets_through_what_it_names_and_no_more() {
let a = Allowed::parse(&["192.168.1.0/24".into(), "127.0.0.1".into(), "fd00::/8".into()]).unwrap();
assert!(a.permits(ip("192.168.1.20")));
assert!(a.permits(ip("127.0.0.1")));
assert!(a.permits(ip("::ffff:127.0.0.1")), "the same address written as IPv6");
assert!(a.permits(ip("fd12::1")));
assert!(!a.permits(ip("192.168.2.1")));
assert!(!a.permits(ip("127.0.0.2")));
assert!(a.permits(ip("1.1.1.1")), "public addresses need no listing");
assert!(Allowed::default().permits(ip("1.1.1.1")));
assert!(!Allowed::default().permits(ip("10.0.0.1")));
for bad in ["nas.lan", "10.0.0.0/33", "10.0.0.0/x", ""] {
assert!(Allowed::parse(&[bad.into()]).is_err(), "{bad}");
}
}
async fn answers() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let _ = sock.read(&mut [0u8; 1024]).await;
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nsecret").await;
}
});
format!("http://{addr}/")
}
fn client(allowed: &[&str]) -> reqwest::Client {
let allowed = Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
reqwest::Client::builder().connector_layer(Guard::new(allowed)).build().unwrap()
}
#[tokio::test]
async fn a_fetch_from_loopback_is_refused_unless_listed() {
let url = answers().await;
let err = client(&[]).get(&url).send().await.unwrap_err();
assert!(format!("{err:?}").contains("internal address"), "{err:?}");
let body = client(&["127.0.0.1"]).get(&url).send().await.unwrap().text().await.unwrap();
assert_eq!(body, "secret");
}
#[tokio::test]
async fn a_redirect_to_loopback_is_refused() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let inner = answers().await;
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let _ = sock.read(&mut [0u8; 1024]).await;
let r = format!("HTTP/1.1 302 Found\r\nLocation: {inner}\r\nContent-Length: 0\r\n\r\n");
let _ = sock.write_all(r.as_bytes()).await;
}
});
// 127.0.0.2 stands for a public address: listed, it is let through, and the redirect it
// sends to 127.0.0.1 is not.
let err = client(&["127.0.0.2"]).get(format!("http://{addr}/")).send().await.unwrap_err();
assert!(format!("{err:?}").contains("127.0.0.1 is an internal address"), "{err:?}");
}
/// Run by hand: `cargo test -p ipx-feed guard -- --ignored`. HTTPS gives the peer's address
/// too; were it lost under TLS, every https fetch would be refused.
#[tokio::test]
#[ignore = "reaches the internet"]
async fn https_is_checked_and_let_through() {
let r = client(&[]).get("https://example.com/").send().await.unwrap();
assert!(r.status().is_success());
}
}

2062
crates/feed/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

958
crates/ipx/CHANGELOG.md Normal file
View File

@@ -0,0 +1,958 @@
# Changelog: ipx
The daemon: the API, scans, downloads, DeadAir and the database. The page has its own changelog,
[web/CHANGELOG.md](../../web/CHANGELOG.md), and the directory service its own,
[crates/directory/CHANGELOG.md](../directory/CHANGELOG.md). Until 0.12.0 this was the changelog of
all of ipx, the page included; they were split with the services (#207).
Its version is in `crates/ipx/Cargo.toml`, and a release is tagged `vX.Y.Z`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.5.0] - 2026-10-23
### Changed
- Traces show more of where a request's time goes: a page of items' query and its files apart, artwork's cache, check and fetch, the status counts, and each part of changing or leaving a feed (#350).
- A page of items finds its files by feed as well as item: it read every file's row in the database, about a quarter of a second a page (#344).
- A page of items asks for its count beside the items, and the feed list asks its five questions at once, where each waited for the one before (#346).
- A Signal's counts of items and of unread ones are worked out in one pass, not two (#348).
- Artwork kept on disk is read and written off the threads that answer requests, and two requests for the same missing artwork fetch it once (#345, #349).
- Unread lists and counts start from each person's oldest unread item in each feed, which the daily cleanup works out, instead of reading the whole back catalogue and everything you have read (#347, #352).
### Fixed
- A WordPress.com site that answers its feed with a browser check (HTTP 403, "Checking your browser...") is read from WordPress.com's API instead, so Pelgrane Press's feed no longer fails for hours at a time. The items are the ones the feed gave: same ids (#317).
### Security
- An episode that comes as a torrent downloads into a folder of its own, `torrent-<id>` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331).
- A downloaded file opens in the browser only when it is audio, video, an image other than SVG, a PDF or plain text, and is saved otherwise. An SVG or web page a podcast sent ran its script as iPX, signed in as whoever opened it (#319).
- An OPML nested more than 100 levels deep is refused, uploaded or fetched. A deep enough one stopped iPX, and a subscribed one stopped it again each time it restarted (#322).
- A `.torrent` file larger than 10 MB, or slower than the torrent stall limit, is refused. iPX read one into memory however large it was (#330).
- Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320).
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
- A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321).
- A show kept from a private OPML or Patreon list stays private, with the list's username and password, once the list is removed or left. It became public and was shared to the directory (#332).
- A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328).
- A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339).
- An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337).
- A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336).
- `ipx user passwd` signs the account out of every browser, and says how many of its API tokens still work. A session signed in with the old password went on working (#334).
- Live updates about a feed go only to the people who read it, and admins. Everyone signed in had every feed's, errors included, which carried a paid feed's address with its key (#325).
- An open page stops getting live updates within half a minute of its account being removed. They went on until the page was closed (#335).
- Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333).
- An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338).
- Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324).
- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340).
- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343).
## [1.4.0] - 2026-10-19
### Added
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, by person and by whose key paid so the server's bill can be split, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275, #278).
- An admin setting for how many days back a new Signal reads (#276).
- A list's trace says which filter, sort and page it was asked for, and whether a search, so a slow list can be told from the others (#287).
- A feed, an OPML subscription or a Patreon creator can be left out of All Subscriptions: `hide_from_all` on `PATCH /api/feeds/{id}` and in the feed list. Set on an OPML or creator it covers every show in it; Signals and Currently Listening still read them (#288).
- A feed, an OPML subscription or a Patreon creator can be kept out of Signals: `hide_from_signals` on `PATCH /api/feeds/{id}` and in the feed list. Jev is not asked about its items, so they cost nothing, and they are left out of each Signal's list and counts (#289).
- An admin setting, Signals for everyone on the server's TypeSafe key, so the server pays for Jev instead of each person (#271).
### Changed
- `GET /api/feeds/{id}/entries` for an OPML subscription or a Patreon creator lists the items of the shows in it that you subscribe to, together, where it listed nothing (#283).
- A TypeSafe key is checked with TypeSafe before it is saved, and one it refuses is not saved (#271).
- A new Signal reads the last week instead of the last 30 days, unless an admin sets otherwise (#276).
- Editing a Signal's name, words or description asks again only about what it had picked out, and about new items as they come, instead of the whole window again (#276).
- Signals fill faster and cost less: up to ten calls to TypeSafe at once instead of one, paced under TypeSafe's limits of 80 calls and 100,000 input tokens a second, with every call held back while TypeSafe answers 429 (#277), everyone's Signals on an item in one call on the server's key, and an item's text cut at 1,000 characters instead of 2,000 (#275).
### Fixed
- Artwork comes quickly: an image already kept is served without asking the database again, and the database finds which feed names an image by an index instead of reading every item, which took over half a second an image (#272).
- The Directory shows the artwork of podcasts nobody on this server subscribes to, where it drew their initials (#273).
- Artwork a publisher sends without an image type, or as `application/octet-stream`, is shown when it is a JPEG, PNG, GIF or WebP, instead of initials (#279).
- Looking for items to read for Signals read every item on the server on each scan; it now reads only recent ones (#274).
- With the downloads folder out of reach during a clean-up, as when the array stops, every downloaded episode was taken as deleted by hand and forgotten; the clean-up now leaves them alone until the folder is back (#293).
- A feed address that serves something larger than 50 MB, such as an episode's file given as a feed, is refused as too large, instead of read whole into memory at every check (#295).
- Searching items for `%` or `_` finds those characters, instead of treating them as wildcards that matched nearly everything (#296).
### Security
- ammonia, which cleans publishers' HTML, updated to 4.2.4 for three advisories, among them XSS through SVG animation tags; rustls to 0.23.46 for five and h2 to 0.4.20 for one, in every connection ipx makes (#281).
- The address of a file being downloaded, which for Patreon carries its signature, is sent to open pages and written to the log without its query, as other addresses already were (#292).
## [1.3.0] - 2026-10-16
### Added
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
### Changed
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
## [1.2.0] - 2026-10-15
### Added
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
### Changed
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
- A scan's trace names the feed each fetch is for (#229).
### Security
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
## [1.1.0] - 2026-10-13
### Added
- `/metrics` counts the feeds fetched and the bytes of their bodies (`ipx_feed_fetches_total`, `ipx_feed_bytes_total`), and a feed's trace says how large its body was (#224).
### Changed
- ipx uses mimalloc instead of glibc's allocator, which gives freed memory back to the system: with glibc's, the memory a night of scans needed stayed held until a restart (#224).
- `docker-compose.yml` has no healthchecks: Docker runs them with exec, which can fail on the host while ipx is well. The library's counts in `/metrics` are kept every 30 s by the daemon itself (#218).
### Security
- Traces no longer keep a feed's or file's address with its query or password, where private feeds carry their credentials, such as Patreon's `auth` (#225).
## [1.0.0] - 2026-10-12
### Added
- With a directory token that may administer it, ipx's admins remove a feed from the directory (blocking it) or check it now, list and unblock what is blocked, and list and drop the servers sharing (#206).
- ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213).
### Changed
- The Directory comes from a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a setting, never a private one, and subscriber counts are every sharing server's. ipx sends the directory its feeds when they change, and every half hour (#205, #211).
- A feed can be marked private, which keeps it out of the directory. A feed with a login, or whose address carries a key, is private already (#205).
- ipx serves no page: the page is ipx-web's, which passes the API on to ipx, and ipx is built without node (#203).
- `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208).
- The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202).
## [0.12.0] - 2026-10-11
### Added
- With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. (#194, #195, #198, #199).
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
### Changed
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space; the copies already made are removed (#179, #181, #196).
- DeadAir is in the same box as a show's download settings, below them, and stays there when automatic downloads are off (#185).
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
- On Postgres, ipx keeps up to 20 connections to the database instead of 10, for the bursts of a page load asking for many covers at once (#200).
- Traces of the disk sweep, episode lists and DeadAir's work on each file show each step, and the sweep's trace says what it removed (#187).
### Fixed
- All Subscriptions, and every item list, comes up faster, most of all the first time after a quiet spell: it took half a second to two (#188).
- Checking for new episodes no longer waits up to 12 seconds each time on a clear-out of dead feeds from the Directory, which now runs once a day (#193).
- A page left open no longer misses updates after a check for new episodes: the note that each feed not due was skipped, one per feed, filled the queue they share (#197).
### Security
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
## [0.11.0] - 2026-10-09
### Added
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is ("Cutting dead air…" until it is done), and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172, #173, #174, #176).
### Fixed
- A downloaded file's play, save, share and delete buttons stay on one row in the Files pane, instead of delete wrapping underneath (#175).
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.
## [0.10.3] - 2026-10-09
### Changed
- Swiping past the last item shows the theme's accent colour, without the words "No more items".
- An empty list and the reader with nothing picked say nothing, instead of "Nothing here yet — try Scan now." and "Pick an item to read it."; a search that finds nothing still says so.
- In Settings, the API tokens sit in a box of their own.
- A feed's settings are redesigned: downloading on its own is a switch with what depends on it beneath, how many is Default, Newest or All shown on a strip of the show's own episodes, words to pick or hide are chips, the feed's address is at the top with a copy button, and Save is always in reach. Its folder and Directory category are no longer in it.
- The feed list's traces show each of its database queries, so it can be told where its time goes.
- Saving a feed's settings asks the database for your subscription once, not twice.
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
- A pinned item or feed's pin button is filled with the theme's accent, in the reader, on the feed's page and in the toolbar, so pinned no longer looks like not pinned.
- A feed's page in the Directory says how many people here subscribe only when someone does, without "Nobody here subscribes yet".
### Fixed
- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload.
- A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing.
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server, and asks for a group's title once, not once for every feed in it.
- Downloading or deleting a file the server no longer has answers "not found" instead of a server error.
- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it.
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it. In Glass the bar is solid on a phone, in the colour its glass showed.
- A feed's buttons stay on one row on a narrow phone, and the reader's buttons stay together instead of breaking across lines under a long feed name.
- A moment's DNS failure no longer marks a feed as failing for a day or calls it gone: iPX tries it again in ten minutes. One failed lookup one morning left 205 podcasts unread, uncategorised and labelled gone until the next day. A name that does not resolve is said to be maybe gone, and only once it has been failing a day, on its Directory page as on its own.
## [0.10.2] - 2026-10-05
### Fixed
- A publisher's correction to an item already in iPX, a retitled episode, mended text, new artwork or a fixed length, arrives on the next read. Since 0.9.1 an item kept what it first said: Ain't It Cool News's items each showed the text of the one before, long after the feed was fixed.
- A feed's page in the Directory says when the feed is failing, and why, and no longer says nobody here subscribes to a feed someone does.
## [0.10.1] - 2026-10-05
### Fixed
- A feed whose address answers with nothing, as a lapsed domain does behind a DNS filter's block page, shows as failing, with what to do about it, instead of as a show that has not posted yet.
- The search box finds episodes in Currently Listening. It did nothing there.
- `ipx add`, `ipx rm` and `ipx import`, run while the daemon runs, reach it at once. It kept its old list of feeds and wrote it back at its next change, undoing them.
- On SQLite, several requests are answered at once instead of one at a time. With 25 people browsing, a page of a feed's items comes back in 10ms rather than 130.
- On Postgres, a write no longer waits for the database server's disk. A scan read about one feed a second and now reads 1,500 in 20 seconds.
- Checking every feed no longer makes every icon in the feed list flash while it runs: the list keeps the icons it has already drawn.
### Security
- A flood of sign-in attempts no longer stalls the site for everyone else. Passwords are checked a few at a time, away from the threads that answer every other request; forty wrong passwords at a time made everything else take four seconds.
- A wrong password is refused in the same time whether or not the name is an account here, so how long it takes no longer tells anyone which names are.
## [0.10.0] - 2026-10-05
### Added
- API tokens: in Settings, make a named token that lets a script or an agent use iPX as you, sent as `Authorization: Bearer`, and revoke it there. Each shows when it was last used.
- In the Directory, a feed you do not subscribe to opens a page of its own: its cover, its category, how many people here subscribe, what the feed says it is, and its latest episodes or posts, with a button to subscribe. Before, only its subscribe button did anything.
- Feeds that don't set a category, as most blogs don't, now get one automatically, so they show up under a category in the Directory. iPX picks the closest of Apple's podcast categories from the feed's title and latest headlines. This happens once per feed: the first time a new feed is read, or for a feed you already had, the next time you force a refresh. It needs `TYPESAFE_KEY` set. An admin can change the category in the feed's settings.
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
it lists it or sets its category.
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
Directory lists feeds worth taking.
### Changed
- The Directory is laid out as Apple's is. It opens on the ten most subscribed feeds, ranked, and the categories as tiles showing some of their shows' covers. A category opens a page of its own with its most subscribed feeds, its subcategories (Tech News under News, Video Games under Leisure) and its feeds; See all lists every one, A to Z, Z to A or by most subscribers. This replaces the grid of every feed under rows of category buttons, and Popular, which is gone from the feed list with its key, g p.
- A pinned item in a list wears the same disc as a pinned feed, in the theme's accent, instead of a plain solid pin.
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
- The Directory lists feeds nobody subscribes to yet; its most subscribed still lists what people subscribe to.
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
listed feed downloads nothing until someone subscribes.
- The Directory loads faster: it asked the database three questions per feed.
### Fixed
- A database on SQLite, the default, no longer waits for the disk after nearly every write. On a slow disk a scan of 1,500 feeds took most of an hour and now takes half a minute. Postgres is unaffected.
- The search box finds a feed in the Directory by name. It did nothing there.
- Feeds that file themselves under Apple's Football or Soccer show under Sports, and those under the old Software How-To under Technology, instead of as categories of their own.
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
### Security
- A feed's items and files reach only the people who subscribe to it. Anyone signed in could read any feed's items, a paid feed's included, with the addresses of its files, which can carry the subscriber's key, and could play, download or delete its files.
## [0.9.1] - 2026-09-29
### Added
- `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method,
route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type`
(and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`.
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its
artwork and in the database.
- A feed with no artwork of its own, or whose artwork is missing, shows its website's icon, or the
site's `/favicon.ico`, and follows the site when it changes. It is asked for again only when the
artwork changes or you refresh the feed.
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
while any of yours is.
- Adding a website's address, with or without `https://`, subscribes to the feed that site links.
An address is checked before it is added, and anything that is neither a feed nor a page linking
one is refused with the reason, instead of being added and failing on every check.
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
the version, for a dashboard such as Homepage.
### Changed
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list every minute.
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
chosen one yet starts from the theme your account had.
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
orange of its needle.
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
feed list, and left out on a phone, for the search box. It follows the page, dark in a dark theme
and light in a light one, as the browser tab's icon does, and hovering it shows iPX's version,
which says so between releases: 0.9.1-dev, not 0.9.0.
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
### Removed
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
links correctly; that clean-up has run.
### Fixed
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
- The feed list loads several times faster: it was asking the database six questions per feed.
- Artwork a feed offers only over plain http shows on the https site too.
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
its counts, and its artwork greyed out once it has failed for a day.
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
- The read button shows whether an item is read, as the pin beside it shows whether it is
pinned: a tick when read, an envelope when not. It used to show the opposite.
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
a feed that has not changed is still read again.
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
a second pull meanwhile does nothing, instead of no sign at all that the check started.
- Settings no longer lists the server's download folder, which only an admin can change, on
the admin page.
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
back to the list. The items read on the way leave the Unread tab once you close the reader.
## [0.9.0] - 2026-09-28
### Added
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
version. The 2004 icon is still at /icon.png.
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
list of its own; an item with one of those words or phrases in its title or text is hidden
from you and is not downloaded on your account. Other people's lists never hide anything from
you.
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
elsewhere they copy the link. A link that looks like it carries your own access to a paid
feed asks before it goes anywhere.
### Changed
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
keep their outlines.
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
one along the far edges, and a soft sheen from the top-left corner.
### Fixed
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
them.
- Swiping between items on a phone takes a quarter of the screen, so a diagonal scroll no longer
jumps ahead by accident, and no longer shows the item list through the reader: the next or
previous item sits beside the one you are reading and comes across with it, following your
finger, the end of the list says so, and swiping right from the first item draws the reader off
the list, dimmed until it is uncovered. A swipe from the screen's left edge stays with the page,
rather than Safari taking it as Back.
## [0.8.4] - 2026-09-19
### Added
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
goes solid when the system asks for less transparency or more contrast.
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
plays through the host's own player instead of the page's, so it keeps going when the screen
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
work as they always did. Video still plays in the page. In a browser nothing changes.
### Fixed
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
home screen. In landscape the bars keep clear of the notch as well.
## [0.8.3] - 2026-09-19
### Security
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
says where the token is kept instead: `[web] token` in config.toml.
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
the email header. Without it, anything on the same Docker host as ipx could send the header. See
docs/sso.md.
### Fixed
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
now on have it.
## [0.8.2] - 2026-09-19
### Added
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
contrast, black and white with every colour at 7:1 or more.
### Changed
- The themes in Settings are listed in alphabetical order.
### Fixed
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
read comfortably. A failed-action message is easier to read in every theme.
## [0.8.1] - 2026-09-19
### Changed
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
"Downloaded" toast is only for a file on your screen.
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
- The Classic theme is listed in Settings as just "Classic".
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
### Fixed
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
drawing at desktop width.
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
- In Nordic's dark half, button and toolbar borders show.
- A zero unread count on the selected feed no longer disappears into the selection.
## [0.8.0] - 2026-09-18
### Added
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
across, everything in one go.
- The catalogue of feeds and the server settings the admin page edits are kept in the database
rather than config.toml, which keeps where things are, the torrent settings and who may sign
in. The first start takes them from config.toml and trims it, keeping the original as
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
### Changed
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
be opened by a 0.7 release first, which brings its tables up to date.
## [0.7.0] - 2026-09-18
### Added
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
A theme chosen before this carries over.
- Your theme is kept on your account rather than in the browser, so it follows you to another
browser or computer, and the page arrives in it with no flash of the default. The theme a
browser already had is saved to your account the first time you load the page.
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
- Touch gestures: pull the item list down from its top to check the feed for new items, and
swipe the item you are reading left for the next one and right for the one before, or back
to the list from the first.
- A favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on their own,
and on white for an iPhone's home screen.
### Changed
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
Settings is now yours alone: your theme and your subscriptions.
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
them, where long notes left them looking missing.
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
- The page is served minified, about a quarter smaller, with its script in a file of its own,
`/app.js`, which your browser keeps between visits and fetches again only when an update changes
it. The script is TypeScript in `web/src`, type-checked and built with swc; building ipx needs
node.
### Fixed
- Switching tabs straight after marking everything read no longer shows the previous tab's
items: of two lists asked for at once, only the later one is shown.
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
higher than a downloaded one's.
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
now show: ipx asks for them without saying it is the page showing them.
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
pause, as the player bar's does, and pause it when pressed.
- An item you open stays read. A list refresh that crossed with marking it read could put its
unread dot back until the next refresh.
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
one, rather than a few read items lingering until a refresh cleared them.
- An image or link in a post given relative to the post, such as The Observation Deck's, now
points at the post's site rather than at ipx, and shows.
## [0.6.1] - 2026-09-15
### Fixed
- Time left, and when an episode counts as finished, go by the length your player measured
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
- A player left open in another tab or on another device no longer saves its older place over
where you have got to since, which could drop an episode out of Currently Listening.
## [0.6.0] - 2026-09-15
### Added
- Each episode in Currently Listening has a cross that takes it off the list. It forgets where you
got to, so playing it again starts from the beginning.
- An admin can give a feed a Directory category in its settings (`category` in config.toml), for
the blogs and other feeds that name none of their own. A feed's own iTunes category still wins.
- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds,
g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list.
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
both filter in place.
### Changed
- Currently Listening marks the episode in the player with the EQ bars, as the item list does,
and its progress and time left move as it plays. Each row says how much is left.
- Directory shows each feed as its cover art in a grid, title and subscriber count underneath,
instead of a list. Popular and the Add a feed dialog keep their rows.
- The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande.
- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin
in place of Keep, Kept and Stop keeping. A pinned item is still never deleted.
- Currently Listening is its own place in the feed list, below Popular, instead of a section at
the bottom of the Popular page.
- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each
picks up its category without waiting for the publisher to change something.
### Fixed
- An episode that fails to load, or is paused before it has, no longer forgets where you left off
in it, and so no longer drops out of Currently Listening.
- Currently Listening lists the episodes you have started. It left out anything marked read, and
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
once 90% of it has played.
- A WordPress post that embeds the file it encloses no longer lists, and downloads, that file
twice. Items that already had it twice are folded into one at startup, and the spare copy
deleted.
- The pinned column's heading lines up with the pins under it, and every heading sits a pixel
further right, over its column.
- The feeds left behind by an OPML subscription removed before ipx retired them are cleared at
startup: forgotten if nothing was downloaded, kept as orphaned if something was. Feeds from it
that have since been given their own settings stay as they are, with their items. Removing an
OPML or Patreon subscription no longer deletes the items of a feed inside it that has its own
settings.
- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text:
"Meta&#8217;s" reads "Meta’s". Titles already stored are corrected the next time their feed
changes.
## [0.5.5] - 2026-09-14
### Changed
- A feed whose site sends a message instead of the feed, such as "Unable to establish a DB
connection", now shows that message and is flagged as the publisher's problem, instead of two
parser errors about reaching the end of input.
### Fixed
- Add a feed opened over Directory or Popular now shows its Popular list instead of staying on
"Loading…", and no longer cuts the Directory behind it down to ten.
## [0.5.4] - 2026-09-14
### Added
- Currently Listening, below Popular: episodes you started and have not finished, across every
feed you subscribe to. Tap one to pick up where you left off.
- Theme has an Auto option, alongside Dark, Light and Classic, that follows your system's
light/dark setting. All four are now also in Settings, as a dropdown next to the header
button's one-click-at-a-time toggle -- the same setting either way.
### Changed
- The feed (or Directory/Popular/All Subscriptions) and the tab you had open are remembered
across a reload or a new visit. A feed you no longer subscribe to, or a first visit with
nothing remembered yet, lands on All Subscriptions instead of the first feed alphabetically.
### Fixed
- On iOS, the topbar (the hamburger menu included) could stop responding to taps until a hard
refresh. The page sized itself with `100vh`, which iOS Safari measures against the address
bar's collapsed state rather than what is actually visible; `100dvh` tracks the real viewport
as the bar shows and hides.
## [0.5.3] - 2026-09-14
### Added
- A feed that has been failing for a day shows a plain-English reason in the sidebar and on its
own page, sorted from a 404, a 401/403, a 402, a name that no longer resolves, or a web page in
place of the feed -- with Unsubscribe or, when the page links its new feed, Use the new address.
A feed that fails once and reads fine again within a day is never flagged.
### Changed
- Unsubscribing from the last person's OPML or Patreon subscription now retires the feeds it
listed, the same as a feed the list itself drops: removed if nothing was downloaded, kept and
marked orphaned otherwise. Until now they stayed in the database and kept being scanned hourly
with auto-download on, which is how 922 defunct `davewiner` feeds outlived the OPML that listed
them.
### Fixed
- A feed whose XML uses a bare `&` instead of `&amp;` (kcpw, both feedland feeds) is now read
instead of refused.
- A feed URL that now serves a web page says so, and names the feed the page links to when it has
one, instead of a raw XML parser error.
- A publisher answering with an empty body (British Antarctic Survey's 202) is read as nothing new
to report, not a parse failure.
- A link in an item's show notes opens in a new tab instead of navigating away from ipx.
- A video file plays as video, in a small floating pane above the player bar, instead of silently
as sound only.
- On the Unread tab, opening an item no longer makes it disappear from the list -- it stays until
you open a different one, even if a scan finishes and refreshes the list while it is open.
- Subscribe and Unsubscribe have their own icons (a circled check and a circled minus) instead of
sharing the generic plus and minus used for adding feeds, users and imports.
- Settings no longer disappears for a non-admin account. It was hiding the whole Settings modal
along with the log and the users screen, but a non-admin has settings of their own in there --
their subscriptions' Export and Import, and the schedule and quota are worth seeing even without
a say in them. Only the log and the users screen, which the server also refuses them, are gone.
## [0.5.2] - 2026-09-12
### Added
- Settings → Users and `ipx user list` show when each account was added and when it last signed
in, to the hour.
- `ipx user rename <name> <new name>` renames an account and keeps its feeds, read state and admin
rights. An account made before the proxy was set up can take the name the proxy signs it in as.
### Changed
- Directory and Popular list the feeds inside an OPML one by one, and no longer the OPML itself,
so you can subscribe to just the shows you want.
- The database no longer records when subscriptions and sign-in sessions were created. Nothing
ever read it, and an existing database drops the columns on its next start.
### Fixed
- Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of
HTML: the item's other copy of its notes is used instead, from the next time the feed changes.
Daily Meditation Podcast had 57.
- Docker no longer shows ipodderx as starting, or calls it unhealthy, while it scans or downloads:
`ipx status` answers at once instead of waiting for the job in progress to finish.
- Signing out after signing in through Cloudflare Access no longer lands on ipodderx's own password
page. With the new `sign_out_url` set, Sign out ends the Access session, and the password page
sends anyone the proxy signs in straight to their feeds.
- The sign-in guide, `docs/sso.md`, describes the setup ipodderx.sdf1.net really runs: Authentik as
Cloudflare Access's identity provider, and how to find the address ipx has to trust. It had never
been checked against a real setup, and pointed at the wrong address.
## [0.5.1] - 2026-09-12
### Fixed
- The triangle that opens an OPML or Patreon folder was cramped against the folder's art. It has
more room now, and a wider target to click.
## [0.5.0] - 2026-09-12
### Added
- A Patreon token pasted into Add feed, or a creator's RSS link without `&show=`, becomes a folder
of that creator's shows, kept in step on every scan like a subscribed OPML. A creator with only
one show stays a plain feed. One already added as a single long feed is split into its shows on
its next scan, keeping its files and what you had read.
- Add feed has an "Allow items marked explicit" box, so a new feed's first scan no longer skips
every explicit item.
### Changed
- Unread counts, unread dots and download progress are amber, the colour of the icon's EQ bars.
Blue is kept for the primary action and links, so a count no longer looks like a button.
- What is playing is marked by small EQ bars, in its row and in the player. They move only while it
plays.
- A folder in the sidebar shows its first four shows' art as a mosaic, and its shows sit under its
title. Only folders have a triangle, so every feed lines up with Directory and Popular above.
- Feeds without art get initials in a colour of their own, instead of all the same grey.
- The Flagged tab is called Kept, as the Keep button and Settings already said.
- A feed's header is one short line; when it checks next is in its tooltip.
- The item list takes more of the window, and the Files pane shows only when the item has files.
- Column headings and tags are in sentence case, and fewer things are bold.
- Unsubscribe is a round button beside the feed's other actions.
- Export and Import in Settings say what they do.
- The sign-in page shows the original icon large.
- Nothing animates when your system asks for reduced motion.
- The pages are about 90 KB smaller: the icon is served once instead of written into each.
- A web token generated for a new install is 64 characters instead of 32.
- The README is a short overview of what ipx does and how to run it, and points into `docs/` for
the rest. It still described the layout from before 0.4.0.
### Removed
- The systemd units in `contrib/`. Run ipx with Docker, or point a unit of your own at
`ipx daemon`.
- Upgrading from before 0.3.0 directly: what was read, kept or part-played before accounts is no
longer carried over to the admin, and OPML feeds that old versions wrote into `config.toml` are
no longer moved out of it. Upgrade through 0.4.0 first.
- `interval_mins` in `config.toml` is ignored; use `schedule`.
### Fixed
- The feed list works from the keyboard: Tab reaches every feed, Enter opens it, and Right and Left
open and close a folder. Every button shows where the focus is, including in the toolbar, which
used to clip the ring.
- "1 items" reads "1 item".
- A selected feed without art no longer loses its initials tile in Dark and Light.
- The player shows the feed's initials when there is no art, not the episode's.
- Turning on Allow explicit, or changing keywords or auto-download, brings back what those settings
had skipped on the feed's next scan. Before, an item was judged once, when first seen, and a
skipped one stayed skipped whatever you changed.
- Feeds inside an OPML or a Patreon creator follow your settings on the folder unless you set their
own, as the folder's settings dialog said they did. Before, the folder's settings reached nothing
inside it.
- A new feed no longer takes the name of one you removed earlier and shows that feed's old items.
Re-adding the same feed still gets its old name, and its history, back.
## [0.4.0] - 2026-09-11
### Added
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
through all three and remembers the choice.
- A toolbar across the top, after the original iPodderX: add, unsubscribe and scan; play, mark
read and keep for the selected item, showing its state; a search box for what is showing; and
Settings and Log.
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
- Items show as a table (unread, kept, title, feed, file, size, published) with a Files pane beside
it, and a status bar with the totals. A file's type is an icon (audio, video, image, PDF, torrent,
other), green once it is downloaded and red when the download failed, with the details in its
tooltip; its size is in KB for small files instead of "0 MB".
- Mark everything read from All Subscriptions, across every feed you subscribe to
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
published); click again to reverse. The server sorts, so it covers the whole list, not just the
fifty shown, and the choice is remembered.
### Changed
- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it,
marked Subscribed, and clicking one opens it.
- Adding a feed scans it straight away, and an OPML import that added feeds scans them, so their
items show without pressing Scan.
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
label, just the Download button. The retention summary says "deleted", not "reaped".
- Buttons are icons, with the words in their tooltips, one meaning per icon: minus unsubscribes,
x closes or cancels, plus adds or subscribes, a dialog's confirm button carries the icon of what
it does, Keep is a flag, as it was in the original, mark unread is an envelope, and Subscribed is
a green check. The icons are Font Awesome Free, embedded as SVG: only the ones used, no font to
download, and nothing fetched from anyone else. They replace words and font characters such as
⟳ ⤓ ↗, which came out thin and tiny and differed from font to font, and the feed header's x for
unsubscribe, which read as closing the page.
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
### Fixed
- Playing a file from the Files pane played it twice at once, in the pane and in the player bar.
The pane has a play button now, and the player bar is the only player.
- The password box in Manage users was white in the dark theme.
- An item with no date showed a stray dot in its details.
- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens
in its URL box, could not be closed with Escape.
### Security
- Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never
listed in Popular or the Directory. A Supercast feed, which keeps its key in the URL's path
rather than the query, was being listed.
## [0.3.0] - 2026-09-11
### Added
- Add feed lists what other people on this server subscribe to, most subscribers first, and
subscribes you by id (`GET /api/popular`, `POST /api/popular/{id}`). Feeds from an OPML, and
feeds with a login or a key in their URL, are never listed.
- Upload an OPML file to import, beside the paste box. The page checks it looks like OPML before
sending it and clears the picker afterwards.
- Settings → Manage users: add and remove accounts, and choose who is an admin
(`GET`/`POST /api/users`, `PATCH`/`DELETE /api/users/{id}`).
- Per-user subscriptions, and per-user read, starred and playback state. The existing library is
adopted by the admin on first start.
- Subscribing to a feed someone else already has costs no second fetch and no second copy. Scanning
merges every subscriber's wants.
- Delete on a shared feed reads **Delete for everyone**, and the server answers `409` while anyone
else has starred the item or not played it (`?force=true` overrides).
- A shared feed's header says how many other people read it.
- `docs/` for configuration, the CLI, users, SSO and architecture, and `CLAUDE.md` for anyone
working on the code.
- Browser tests for OPML import and export by every route, user admin, unread ordering, and
`ipx import`/`ipx export`.
### Changed
- Feeds inside an OPML subscription list the ones with unread items first.
- OPML import subscribes you to every feed in the file. `ipx import` subscribes the first admin.
- OPML export lists only your own subscriptions.
- Production runs as a Docker image pushed to `192.168.1.130:5000` and recreated with
`docker compose`.
- This changelog follows Keep a Changelog. The long-form entries moved to `docs/history.md`.
### Fixed
- Importing something that is not OPML answered `500`. It is now `400` "that is not an OPML file",
refused before anything changes.
### Security
- The log is admin-only (`GET /api/logs` answers `403`, and the Log button is hidden). It names
every account, every feed and every failed sign-in.
- OPML export no longer hands anyone signed in the whole catalogue, including other people's
private feed URLs.
## [0.2.0] - 2026-09-10
### Added
- Web UI served by the daemon: plain HTML and JS compiled into the binary, with feeds, items,
filters, search, sanitised show notes and live progress over SSE. Opening an item marks it
read; playing it marks it read only at the end or past 90%.
- Player bar with resume, speed, keyboard shortcuts and lock-screen controls.
- Three-pane layout: feeds beside, items above, and the selected item's text and files below.
- Phone layout.
- Accounts and sign-in: Argon2id passwords, session cookies, `ipx user add|list|passwd|rm`, and a
trusted proxy header for Cloudflare Zero Trust or Authentik (`docs/sso.md`).
- Subscribing to an OPML: it is re-read every scan and its feeds show as a folder. A feed dropped
from it is removed unless it has downloads.
- Scheduling: a global interval with per-feed overrides (`every 30m`, `4h`, `1d`, `2w`), set by an
admin, as are the global settings.
- `[general] media_types`, default audio and video, and `max_new_per_check`, default 3.
- Every enclosure of an item, a View link for files that are not audio or video, and per-item
artwork.
- In-app log view with Daemon I/O, Scans and HTTP tabs.
- Mark all read on an OPML subscription.
- Editable feed URL with a copy button.
- Docker image whose healthcheck goes through the control socket.
- The iPodderX name, icon, and a colour scheme taken from the icon.
- `tests/page-smoke.js` and a Playwright browser suite.
### Changed
- Feeds from an OPML live in the database, not `config.toml`.
- Torrents run detached, two at a time.
### Fixed
- The daemon ignored SIGTERM until the current download finished.
- A torrent could freeze scanning for up to an hour.
- Image enclosures were downloaded as episodes.
- A feed whose entries had been deleted stayed empty, because the server kept answering `304`.
- An item with several enclosures kept only the last.
### Security
- The web UI needs a token or a sign-in. The token is compared in constant time, and an empty
token refuses to serve.
- Show notes are sanitised with `ammonia`.
- A proxy's user header is honoured only from an address in `trusted_proxies`.
- A feed URL must be http(s), so `file:///etc/passwd` is refused.
- Download folders are sanitised per path segment, so `../../etc/Show` cannot climb out.
## [0.1.0] - 2026-09-09
### Added
- `ipx`, a Rust rewrite of the iPodderX engine: TOML config, SQLite state, and `ipx list`, `add`,
`rm` and `fetch`.
- RSS and Atom parsing with conditional GET, `<ttl>` and basic auth.
- Streaming downloads with explicit, keyword and per-scan filters, deduplicated by enclosure URL.
- Quota and age retention that never touches a starred file, and `ipx reap [--dry-run]`.
- `ipx daemon` with a JSON-lines Unix socket. CLI commands proxy to a running daemon.
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.5.0...main
[1.5.0]: https://git.sdf1.net/rays/ipx/compare/v1.4.0...v1.5.0
[1.4.0]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...v1.4.0
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
[0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3
[0.10.2]: https://git.sdf1.net/rays/ipx/compare/v0.10.1...v0.10.2
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
[0.10.0]: https://git.sdf1.net/rays/ipx/compare/v0.9.1...v0.10.0
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0

35
crates/ipx/Cargo.toml Normal file
View File

@@ -0,0 +1,35 @@
[package]
name = "ipx"
version = "1.5.0"
edition.workspace = true
publish = false
[dependencies]
ipx-feed.workspace = true
ipx-directory.workspace = true
ammonia.workspace = true
anyhow.workspace = true
argon2.workspace = true
flate2.workspace = true
axum.workspace = true
chrono.workspace = true
clap.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
librqbit.workspace = true
opentelemetry.workspace = true
opml.workspace = true
percent-encoding.workspace = true
reqwest.workspace = true
sea-orm.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tokio.workspace = true
toml.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-opentelemetry.workspace = true
tracing-subscriber.workspace = true
url.workspace = true

220
crates/ipx/src/access.rs Normal file
View File

@@ -0,0 +1,220 @@
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
//! address is the Docker gateway: any container there could send the header and be anyone.
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
//! takes the network out of the question.
use std::collections::HashMap;
use std::future::Future;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
/// made-up key ids cannot turn every request into a request to Cloudflare.
const REFETCH_EVERY: Duration = Duration::from_secs(60);
#[derive(Default)]
pub struct Keys {
cache: Mutex<Cache>,
}
#[derive(Default)]
struct Cache {
keys: HashMap<String, DecodingKey>,
fetched: Option<Instant>,
}
#[derive(serde::Deserialize)]
struct Claims {
email: Option<String>,
}
impl Keys {
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
/// expired token, a key that cannot be had, or no email in it (a service token has none).
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
self.verify_with(team, aud, token, || fetch(client, team)).await
}
/// Fill the cache before the first request needs it. A failure is only logged: the next
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
match fetch(client, team).await {
Ok(set) => self.store(set),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
}
}
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<JwkSet>>,
{
let kid = decode_header(token).ok()?.kid?;
let key = match self.key(&kid) {
Some(k) => k,
None => {
if !self.may_refetch() {
return None;
}
match fetch().await {
Ok(set) => self.store(set),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
return None;
}
}
self.key(&kid)?
}
};
// RS256 only: a token that names HS256 or none is refused here, before its signature
// is looked at, rather than checked with the public key as if it were a secret.
let mut v = Validation::new(Algorithm::RS256);
v.set_audience(&[aud]);
v.set_issuer(&[format!("https://{team}")]);
v.validate_nbf = true;
match decode::<Claims>(token, &key, &v) {
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
Err(e) => {
tracing::warn!(error = %e, "refused a Cloudflare Access token");
None
}
}
}
fn key(&self, kid: &str) -> Option<DecodingKey> {
self.cache.lock().unwrap().keys.get(kid).cloned()
}
/// Takes the slot as it answers, so two requests at once do not both fetch.
fn may_refetch(&self) -> bool {
let mut c = self.cache.lock().unwrap();
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
return false;
}
c.fetched = Some(Instant::now());
true
}
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
fn store(&self, set: JwkSet) {
let keys =
set.keys.iter().filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))).collect();
let mut c = self.cache.lock().unwrap();
c.keys = keys;
c.fetched = Some(Instant::now());
}
}
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
let url = format!("https://{team}/cdn-cgi/access/certs");
client
.get(&url)
.timeout(Duration::from_secs(10))
.send()
.await
.with_context(|| format!("fetching {url}"))?
.error_for_status()?
.json()
.await
.context("reading the signing keys")
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
const TEAM: &str = "team.cloudflareaccess.com";
const AUD: &str = "aud-tag";
fn jwks() -> JwkSet {
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
}
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
let mut h = Header::new(alg);
h.kid = Some("k1".into());
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
encode(&h, &claims, &k).unwrap()
}
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
let now = get_current_timestamp() as i64;
serde_json::json!({
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
})
}
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
async fn check(keys: &Keys, t: &str) -> Option<String> {
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
}
#[tokio::test]
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
let keys = Keys::default();
keys.store(jwks());
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
assert_eq!(check(&keys, &expired).await, None, "expired");
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
assert_eq!(check(&keys, &hs).await, None, "HS256");
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", ok.split('.').nth(1).unwrap());
assert_eq!(check(&keys, &none).await, None, "alg none");
}
#[tokio::test]
async fn an_unknown_key_refetches_once_a_minute_at_most() {
let keys = Keys::default();
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
let fetches = std::sync::atomic::AtomicUsize::new(0);
let count = || {
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async { Ok(jwks()) }
};
assert_eq!(
keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(),
Some("rays@sdf1.net"),
"a key not cached yet is fetched"
);
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
// A made-up key id straight after: not fetched again.
let mut h = Header::new(Algorithm::RS256);
h.kid = Some("nobody".into());
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
let count = || {
fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async { Ok(jwks()) }
};
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
}
#[tokio::test]
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
let keys = Keys::default();
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
assert_eq!(got, None);
}
}

138
crates/ipx/src/art.rs Normal file
View File

@@ -0,0 +1,138 @@
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
//! of each publisher's server for every visit, and still there when a publisher is not.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
pub fn dir() -> PathBuf {
crate::config::data_dir().join("art")
}
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
// only makes the cache miss once and refill; a collision among a few thousand images is about
// one in 10^12. Move to a SHA if either ever matters.
pub fn path(url: &str) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut h);
dir().join(format!("{:016x}", h.finish()))
}
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
let body = std::fs::read(file).ok()?;
if let Ok(f) = std::fs::File::options().write(true).open(file) {
let _ = f.set_modified(std::time::SystemTime::now());
}
Some((kind, body))
}
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
/// never reads half of one.
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
let tmp = file.with_extension("part");
std::fs::write(&tmp, body)?;
std::fs::write(file.with_extension("type"), kind)?;
std::fs::rename(&tmp, file)
}
/// How long an address that could not be fetched is answered from memory before it is tried again.
const RETRY: Duration = Duration::from_secs(60 * 60);
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
/// Why `url` could not be fetched, if it failed less than an hour ago.
pub fn failed(url: &str) -> Option<String> {
failed_as_of(url, Instant::now())
}
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
match map.get(url) {
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
Some(_) => {
map.remove(url);
None
}
None => None,
}
}
pub fn fail(url: &str, why: &str) {
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
}
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
/// Returns how many went.
pub fn trim(dir: &Path, limit: u64) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_none())
.filter_map(|p| {
let m = p.metadata().ok()?;
Some((m.modified().ok()?, m.len(), p))
})
.collect();
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
kept.sort();
let mut gone = 0;
for (_, n, p) in kept {
if total <= limit {
break;
}
let _ = std::fs::remove_file(p.with_extension("type"));
if std::fs::remove_file(&p).is_ok() {
total -= n;
gone += 1;
}
}
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
gone
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_least_recently_used_go_first_until_it_fits() {
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
let f = d.join(name);
write(&f, "image/png", &[0; 1000]).unwrap();
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
}
// Shown just now: no longer the oldest.
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
assert_eq!(trim(&d, 2000), 1);
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
assert!(d.join("old").exists() && d.join("new").exists());
assert_eq!(trim(&d, 0), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failure_is_remembered_for_an_hour() {
let u = "https://example.com/a-failure-is-remembered.png";
assert_eq!(failed(u), None);
fail(u, "larger than 5 MB");
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
// Asked for after the hour, it is forgotten, not just ignored.
assert_eq!(failed(u), None);
}
}

165
crates/ipx/src/auth.rs Normal file
View File

@@ -0,0 +1,165 @@
//! Who is asking. Sign-in is either a local password or a header set by whatever fronts
//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated
//! address in `Cf-Access-Authenticated-User-Email`.
use anyhow::{Result, bail};
use argon2::Argon2;
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The
/// salt is generated per password by the hasher itself.
pub fn hash_password(password: &str) -> Result<String> {
if password.len() < 8 {
bail!("password must be at least 8 characters");
}
Argon2::default()
.hash_password(password.as_bytes())
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("could not hash the password: {e}"))
}
/// False for a wrong password *and* for a stored hash this build cannot parse; either way
/// the answer is no.
pub fn verify_password(password: &str, stored: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored) else {
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
return false;
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
/// How many password checks run at once: each is tens of milliseconds of CPU, and forty wrong
/// passwords at a time, run on the async workers, made every other request wait 4s (#137).
/// Half the cores, so a flood of sign-ins waits on itself and the rest of the server has the rest.
static CHECKS: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> = std::sync::LazyLock::new(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).max(1)),
))
});
/// What a name that is not an account is checked against, so that refusing it takes as long as
/// refusing a wrong password does. Refused without a check, it came back 31ms sooner, and the
/// time told anyone which names are accounts here (#138).
static DECOY: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| hash_password("no account here has this password").expect("hashing a fixed password"));
/// A sign-in's password against the account's hash, or against `DECOY` when there is no account
/// or it has no password: false either way, in the same time. Off the async workers, and a few at
/// a time; see `CHECKS`.
pub async fn check_password(password: String, stored: Option<String>) -> bool {
let Ok(turn) = CHECKS.clone().acquire_owned().await else { return false };
// The turn goes with the check, not with this future: a client that hung up dropped the
// future, freeing the turn while the check ran on, so disconnecting sign-ins ran any number
// at once (#324).
tokio::task::spawn_blocking(move || {
let _turn = turn;
match stored {
Some(h) => verify_password(&password, &h),
None => {
verify_password(&password, &DECOY);
false
}
}
})
.await
.unwrap_or(false)
}
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
pub fn new_session_token() -> String {
let mut bytes = [0u8; 32];
if getrandom(&mut bytes).is_err() {
// Falling back to the clock would be a predictable session id. Better to fail.
panic!("no source of randomness for a session token");
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// A new API token: `ipx_` and a session's 256 random bits, the prefix so one found in a log
/// or a file says what it opens (#123).
pub fn new_api_token() -> String {
format!("ipx_{}", new_session_token())
}
/// What is kept of an API token. A fast hash is enough: the token is 256 random bits, not a
/// password, so there is nothing to guess from a stolen hash.
pub fn api_token_hash(token: &str) -> String {
use sha2::Digest;
sha2::Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read;
std::fs::File::open("/dev/urandom")?.read_exact(buf)
}
/// A username taken from a proxy header. Cloudflare sends an email address; the local part
/// is what a person recognises, and the whole thing stays unique enough for one household.
pub fn name_from_header(raw: &str) -> Option<String> {
let name = raw.trim();
if name.is_empty() || name.len() > 190 {
return None;
}
// Anything that could confuse a lookup or a log line is not a name.
if name.chars().any(|c| c.is_control() || c == ',' || c == ';') {
return None;
}
Some(name.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn a_sign_in_without_an_account_is_checked_all_the_same() {
let h = hash_password("correct horse battery").unwrap();
assert!(check_password("correct horse battery".into(), Some(h.clone())).await);
assert!(!check_password("wrong".into(), Some(h)).await);
assert!(!check_password("correct horse battery".into(), None).await);
// A decoy that does not parse is refused before any hashing, and the time says so (#138).
assert!(PasswordHash::new(&DECOY).is_ok());
}
#[test]
fn a_password_verifies_only_against_itself() {
let h = hash_password("correct horse battery").unwrap();
assert!(verify_password("correct horse battery", &h));
assert!(!verify_password("Correct horse battery", &h));
assert!(!verify_password("", &h));
// A hash from a different scheme, or a truncated one, must not authenticate.
assert!(!verify_password("correct horse battery", "not-a-hash"));
assert!(hash_password("short").is_err());
}
#[test]
fn api_tokens_are_prefixed_and_hash_to_hex() {
let t = new_api_token();
assert!(t.starts_with("ipx_") && t.len() == 68);
let h = api_token_hash(&t);
assert_eq!(h.len(), 64);
assert_eq!(h, api_token_hash(&t), "the same token, the same hash");
assert_ne!(h, api_token_hash(&new_api_token()));
assert_eq!(
api_token_hash("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"SHA-256"
);
}
#[test]
fn session_tokens_are_long_and_distinct() {
let a = new_session_token();
let b = new_session_token();
assert_eq!(a.len(), 64);
assert_ne!(a, b);
}
#[test]
fn a_header_name_is_cleaned_or_refused() {
assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into()));
assert_eq!(name_from_header(""), None);
assert_eq!(name_from_header("ray\nadmin"), None);
assert_eq!(name_from_header("ray;admin"), None);
}
}

View File

@@ -39,6 +39,27 @@ pub struct General {
/// image in an <enclosure>, so taking everything filled the disk with artwork and
/// counted it as episodes. Empty means take anything.
pub media_types: Vec<String>,
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
/// every publisher; the least recently shown goes first. 0 keeps none.
pub art_cache_mb: u64,
/// The directory service the Directory shows (#205), as `https://directory.ipxreader.app`. Empty
/// for none: then the Directory says so, and feeds are added by their address.
pub directory_url: String,
/// The token the directory gave this server. Never sent to the page: it says only whether
/// one is set.
pub directory_token: String,
/// Send the directory the public feeds people here subscribe to, and how many of them do.
pub directory_share: bool,
/// Signals are judged on the server's TypeSafe key (`TYPESAFE_KEY`), for everyone, rather than
/// on each person's own (#271). Without that key it changes nothing.
pub signals_on_server: bool,
/// How far back a new Signal looks, in days (#276): each item is a call to TypeSafe, and a
/// month of production's feeds was about 8,000.
pub signal_days: u64,
/// Internal addresses and networks feeds, episodes and artwork may still be fetched from, as
/// `["192.168.1.20", "10.0.0.0/8"]`: everything off the public internet is refused otherwise,
/// since a feed or anyone signed in names what is fetched (#320). Addresses, not names.
pub fetch_internal: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -78,6 +99,14 @@ pub struct Web {
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>,
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
/// host any container can send one from the gateway's address.
pub access_team: String,
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
/// leaves the signature unchecked.
pub access_aud: String,
/// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool,
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
@@ -86,6 +115,9 @@ pub struct Web {
pub sign_out_url: String,
/// Sign a session out after this long without a request.
pub session_days: i64,
/// Where to serve /metrics for Prometheus (#194), on a thread of its own, without sign-in:
/// `0.0.0.0:9187`. Empty serves none.
pub metrics_bind: String,
}
impl Default for Web {
@@ -96,9 +128,12 @@ impl Default for Web {
token: String::new(),
trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
access_team: String::new(),
access_aud: String::new(),
auto_create_users: true,
sign_out_url: String::new(),
session_days: 30,
metrics_bind: String::new(),
}
}
}
@@ -107,6 +142,12 @@ impl Web {
pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
}
/// Both halves of the Access check, or None while either is unset.
pub fn access(&self) -> Option<(&str, &str)> {
(!self.access_team.is_empty() && !self.access_aud.is_empty())
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -147,6 +188,14 @@ pub struct Feed {
/// Preferred over `password`: name of an env var holding the password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_env: Option<String>,
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub listed: bool,
/// Never sent to the directory (#205), whatever its address looks like: someone's own
/// access to a paid show that `feed::url_looks_private` does not recognise.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub private: bool,
}
fn yes() -> bool {
@@ -164,19 +213,20 @@ impl Default for General {
max_age_days: 0,
max_new_per_check: 3,
media_types: vec!["audio".into(), "video".into()],
art_cache_mb: 500,
directory_url: String::new(),
directory_token: String::new(),
directory_share: false,
signals_on_server: false,
signal_days: 7,
fetch_internal: vec![],
}
}
}
impl Default for Torrent {
fn default() -> Self {
Self {
enabled: true,
seed_ratio: 1.0,
seed_time_mins: 60,
port_range: "6881-6889".into(),
stall_mins: 30,
}
Self { enabled: true, seed_ratio: 1.0, seed_time_mins: 60, port_range: "6881-6889".into(), stall_mins: 30 }
}
}
@@ -245,6 +295,32 @@ impl Feed {
}
self.password.clone()
}
/// What `feed::fetch` sends the feed's host: a username, and its password if there is one.
pub fn auth(&self) -> Option<(&str, Option<String>)> {
self.username.as_deref().map(|u| (u, self.password()))
}
/// `auth()`, for a URL on the feed's own host only. The credential is for whoever issued it,
/// and an enclosure, or a feed an OPML lists, can be on any host its author names (#327, #328).
pub fn auth_for(&self, url: &str) -> Option<(&str, Option<String>)> {
same_host(&self.url, url).then(|| self.auth()).flatten()
}
/// The username, password and password_env a feed at `url` takes from this one, its OPML or
/// Patreon list: all of them on the list's own host, none elsewhere.
pub fn credentials_for(&self, url: &str) -> (Option<String>, Option<String>, Option<String>) {
if same_host(&self.url, url) {
(self.username.clone(), self.password.clone(), self.password_env.clone())
} else {
(None, None, None)
}
}
}
fn same_host(a: &str, b: &str) -> bool {
let host = |u: &str| url::Url::parse(u).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase));
host(a).is_some_and(|h| host(b).as_ref() == Some(&h))
}
impl Config {
@@ -253,14 +329,11 @@ impl Config {
// ponytail: a missing config is an empty one, so `list`/`add` work on a fresh install.
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let mut cfg: Self =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut cfg: Self = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
cfg.general.download_dir = expand_tilde(&cfg.general.download_dir);
Ok(cfg)
}
}
/// What the database keeps of the configuration (issue #18): the server settings the admin page
@@ -274,10 +347,43 @@ pub struct Stored {
pub max_age_days: u64,
pub max_new_per_check: usize,
pub media_types: Vec<String>,
/// Settings saved before it existed have none: they get the default.
#[serde(default = "art_cache_mb")]
pub art_cache_mb: u64,
#[serde(default)]
pub directory_url: String,
#[serde(default)]
pub directory_token: String,
#[serde(default)]
pub directory_share: bool,
#[serde(default)]
pub signals_on_server: bool,
#[serde(default = "signal_days")]
pub signal_days: u64,
}
fn signal_days() -> u64 {
General::default().signal_days
}
fn art_cache_mb() -> u64 {
General::default().art_cache_mb
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
const STORED_KEYS: [&str; 11] = [
"schedule",
"max_total_gb",
"max_age_days",
"max_new_per_check",
"media_types",
"art_cache_mb",
"directory_url",
"directory_token",
"directory_share",
"signals_on_server",
"signal_days",
];
impl Stored {
pub fn of(cfg: &Config) -> Self {
@@ -288,6 +394,12 @@ impl Stored {
max_age_days: g.max_age_days,
max_new_per_check: g.max_new_per_check,
media_types: g.media_types.clone(),
art_cache_mb: g.art_cache_mb,
directory_url: g.directory_url.clone(),
directory_token: g.directory_token.clone(),
directory_share: g.directory_share,
signals_on_server: g.signals_on_server,
signal_days: g.signal_days,
}
}
@@ -298,6 +410,12 @@ impl Stored {
g.max_age_days = self.max_age_days;
g.max_new_per_check = self.max_new_per_check;
g.media_types = self.media_types;
g.art_cache_mb = self.art_cache_mb;
g.directory_url = self.directory_url;
g.directory_token = self.directory_token;
g.directory_share = self.directory_share;
g.signals_on_server = self.signals_on_server;
g.signal_days = self.signal_days;
}
}
@@ -404,20 +522,17 @@ pub fn slug(text: &str) -> String {
}
/// `slug`, with a numeric suffix if that id is taken.
pub fn unique_slug(text: &str, taken: &BTreeMap<String, Feed>) -> String {
pub fn unique_slug(text: &str, taken: impl Fn(&str) -> bool) -> String {
let base = slug(text);
if !taken.contains_key(&base) {
if !taken(&base) {
return base;
}
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken.contains_key(s)).unwrap()
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).unwrap()
}
/// `$var`, or `~/fallback` when it is unset or empty, as the XDG base directory spec says.
fn xdg(var: &str, fallback: &str) -> PathBuf {
std::env::var_os(var)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home().join(fallback))
std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home().join(fallback))
}
fn home() -> PathBuf {
@@ -435,6 +550,22 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests {
use super::*;
#[test]
fn a_feeds_password_goes_to_its_own_host_alone() {
let paid = Feed {
url: "https://Members.example.com/feed.xml".into(),
username: Some("me".into()),
password: Some("pw".into()),
..toml::from_str("url = 'x'").unwrap()
};
assert_eq!(paid.auth_for("https://members.example.com/ep1.mp3"), Some(("me", Some("pw".into()))));
assert_eq!(paid.auth_for("https://cdn.example.net/ep1.mp3"), None, "an enclosure elsewhere (#328)");
assert_eq!(paid.auth_for("https://members.example.com.evil.net/x"), None);
assert_eq!(paid.auth_for("not a url"), None);
assert_eq!(paid.credentials_for("https://members.example.com/other.xml").0.as_deref(), Some("me"));
assert_eq!(paid.credentials_for("https://elsewhere.org/feed.xml"), (None, None, None), "an OPML's feed (#327)");
}
#[test]
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
let cfg: Config = toml::from_str(
@@ -496,10 +627,20 @@ mod tests {
#[test]
fn intervals_parse_from_the_forms_people_actually_type() {
for (input, want) in [
("every 30m", 30), ("30m", 30), ("30", 30), ("every 30 minutes", 30),
("every 4h", 240), ("4h", 240), ("4 hours", 240), ("EVERY 4H", 240),
("1d", 1440), ("every 2 days", 2880), (" every 90m ", 90),
("1w", 10080), ("every 2 weeks", 20160), ("2 w", 20160),
("every 30m", 30),
("30m", 30),
("30", 30),
("every 30 minutes", 30),
("every 4h", 240),
("4h", 240),
("4 hours", 240),
("EVERY 4H", 240),
("1d", 1440),
("every 2 days", 2880),
(" every 90m ", 90),
("1w", 10080),
("every 2 weeks", 20160),
("2 w", 20160),
] {
assert_eq!(parse_interval(input), Some(want), "{input:?}");
}
@@ -555,6 +696,12 @@ mod tests {
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
}
#[test]
fn settings_saved_before_the_art_cache_keep_the_default() {
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
}
#[test]
fn slugs_are_readable_and_unique() {
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
@@ -565,12 +712,27 @@ mod tests {
assert!(slug(&"x".repeat(100)).len() <= 40);
let mut taken = BTreeMap::new();
taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None,
});
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
taken.insert(
"the-daily".to_string(),
Feed {
url: "u".into(),
folder: None,
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
category: None,
listed: false,
private: false,
},
);
assert_eq!(unique_slug("The Daily", |s| taken.contains_key(s)), "the-daily-2");
}
#[test]
@@ -589,6 +751,8 @@ mod tests {
password: Some("literal".into()),
password_env: None,
category: None,
listed: false,
private: false,
};
assert_eq!(f.password().as_deref(), Some("literal"));

4236
crates/ipx/src/db.rs Normal file

File diff suppressed because it is too large Load Diff

821
crates/ipx/src/deadair.rs Normal file
View File

@@ -0,0 +1,821 @@
//! DeadAir (issue #171): podcasts play with their long silences shortened, so they finish
//! sooner without sounding sped up.
//!
//! Nothing is copied (#181). DeadAir finds a file's silences once, in the background, and keeps a
//! `Plan` for each pause length a person can choose (#182): the frames kept, as byte ranges of
//! the original. The player gets an HLS playlist of those ranges (`playlist`), which Safari plays
//! itself and other browsers through hls.js, with no gap at the cuts. Seeking past each silence
//! instead left about 1.3 s of nothing at every one (#178); a copy made with ffmpeg cost as much
//! disk again as the original and could only have one pause length.
//!
//! Positions stay in the original's time. What a plan leaves out is stored as `Cut`s of the
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
//! Android apps save positions themselves, from their own player, so converting in the page
//! would miss them.
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use std::path::Path;
use std::sync::Arc;
use tracing::Instrument;
use crate::db::step;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::Ctx;
/// Quieter than this for longer than `MIN_SILENCE` is a silence: what the measurements on the
/// issue used. A podcast's room tone sits well below it and speech well above.
const THRESHOLD: &str = "-38dB";
const MIN_SILENCE: f64 = 0.35;
/// The pauses a person can have DeadAir leave where it cuts a silence, in ms (#182). Half of it
/// is kept at each end of the silence, so the frames on both sides of a cut are silent. Decoding
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
/// which nobody hears between two silent frames and everyone hears at the start of a word.
pub const PAUSES: [i64; 4] = [200, 300, 400, 500];
/// 0.2 s, the first DeadAir's, made speech sound rushed.
pub const DEFAULT_PAUSE: i64 = 300;
/// The longest piece of a playlist, in seconds: about what HLS players expect, so a long stretch
/// of talk is not one request for megabytes.
const MAX_PIECE: f64 = 10.0;
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
/// picked up this long after, at most.
const IDLE: std::time::Duration = std::time::Duration::from_secs(120);
/// A span of the original, in milliseconds, that DeadAir leaves out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cut(pub i64, pub i64);
/// What one pause length leaves of a file: what it cuts, and what it keeps as pieces of the
/// original, (byte offset, bytes, seconds).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Plan {
pub cuts: Vec<Cut>,
pub pieces: Vec<(u64, u64, f64)>,
}
impl Plan {
pub fn saved_ms(&self) -> i64 {
saved(&self.cuts)
}
}
/// The length of the ID3v2 tag a file starts with, 0 if it has none.
pub fn id3_len(head: &[u8]) -> u64 {
if head.len() < 10 || &head[..3] != b"ID3" {
return 0;
}
// Seven bits a byte, so no byte of the size looks like the start of a frame.
let size = head[6..10].iter().fold(0u64, |n, b| n << 7 | u64::from(b & 0x7f));
let footer = if head[5] & 0x10 != 0 { 10 } else { 0 };
10 + size + footer
}
/// A file as `plan` plays it, to save (#186): its ID3 tag, so the title and artwork come too, then
/// the pieces one after another. MP3 and ADTS frames each stand alone, so that is a file any
/// player plays. Read as it is sent and never stored, as the playlist is: (its length, its bytes).
pub async fn kept(
path: &Path,
plan: &Plan,
) -> std::io::Result<(u64, impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>> + use<>)> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(path).await?;
let mut head = [0u8; 10];
let n = file.read(&mut head).await?;
// Only up to the first frame kept: past the tag can be the Xing frame, which gives the
// original's length.
let first = plan.pieces.first().map_or(0, |p| p.0);
let tag = id3_len(&head[..n]).min(first);
let ranges: std::collections::VecDeque<(u64, u64)> =
std::iter::once((0, tag)).chain(plan.pieces.iter().map(|&(o, n, _)| (o, n))).filter(|r| r.1 > 0).collect();
let len = ranges.iter().map(|r| r.1).sum();
let body = futures_util::stream::try_unfold((file, ranges), |(mut f, mut left)| async move {
let Some((off, n)) = left.pop_front() else { return Ok(None) };
let mut buf = vec![0; n.min(1 << 16) as usize];
f.seek(std::io::SeekFrom::Start(off)).await?;
f.read_exact(&mut buf).await?;
let got = buf.len() as u64;
if got < n {
left.push_front((off + got, n - got));
}
Ok(Some((axum::body::Bytes::from(buf), (f, left))))
});
Ok((len, body))
}
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
/// the file has no end and is left alone.
pub fn parse_silences(log: &str) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut start = None;
for line in log.lines() {
if let Some(v) = after(line, "silence_start: ") {
start = Some(v);
} else if let Some(v) = after(line, "silence_end: ")
&& let Some(s) = start.take()
{
out.push((s, v));
}
}
out
}
fn after(line: &str, key: &str) -> Option<f64> {
let rest = &line[line.find(key)? + key.len()..];
rest.split_whitespace().next()?.parse().ok()
}
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
/// silence, `keep` seconds in from each end, go.
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64, keep: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
let spans: Vec<(u64, u64)> = silences
.iter()
.filter(|(s, e)| e - s >= MIN_SILENCE)
.filter_map(|&(s, e)| {
let a = ((s + keep - first) / frame).ceil().max(0.0) as u64;
let b = ((e - keep - first) / frame).floor() as i64 - 1;
(b >= a as i64).then_some((a, b as u64))
})
.collect();
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
(spans, cuts)
}
/// The frames kept, as pieces of the file: runs of frames lying end to end in it, none past
/// `MAX_PIECE`. `frames` is each frame's (byte offset, bytes); `drop` is from `plan`.
pub fn pieces(frames: &[(u64, u64)], drop: &[(u64, u64)], frame: f64) -> Vec<(u64, u64, f64)> {
let most = ((MAX_PIECE / frame).floor() as usize).max(1);
let mut out = Vec::new();
let push = |out: &mut Vec<(u64, u64, f64)>, (first, n): (usize, usize)| {
let (start, (pos, size)) = (frames[first].0, frames[first + n - 1]);
out.push((start, pos + size - start, n as f64 * frame));
};
let mut drops = drop.iter().peekable();
let mut run: Option<(usize, usize)> = None;
for (n, &(pos, _)) in frames.iter().enumerate() {
while drops.peek().is_some_and(|&&(_, b)| (b as usize) < n) {
drops.next();
}
let dropped = drops.peek().is_some_and(|&&(a, _)| a as usize <= n);
// Something between two frames (an ID3 tag partway through) ends a piece too.
let joins = !dropped && run.is_some_and(|(f, c)| c < most && frames[f + c - 1].0 + frames[f + c - 1].1 == pos);
if !joins && let Some(r) = run.take() {
push(&mut out, r);
}
if !dropped {
run = Some(run.map_or((n, 1), |(f, c)| (f, c + 1)));
}
}
if let Some(r) = run {
push(&mut out, r);
}
out
}
/// The HLS playlist for a plan: each piece a byte range of `uri`, the original.
pub fn playlist(plan: &Plan, uri: &str) -> String {
let longest = plan.pieces.iter().map(|p| p.2).fold(0.0, f64::max);
let mut s = format!(
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:{}\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n",
longest.ceil() as u64
);
for &(off, len, secs) in &plan.pieces {
let _ = write!(s, "#EXTINF:{secs:.6},\n#EXT-X-BYTERANGE:{len}@{off}\n{uri}\n");
}
s + "#EXT-X-ENDLIST\n"
}
/// A time in the original, in ms, as the same moment in the shortened version. A time inside a
/// cut is where the cut was made.
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms;
for &Cut(s, e) in cuts {
if ms >= e {
out -= e - s;
} else {
if ms > s {
out -= ms - s;
}
break;
}
}
out
}
/// A time in the shortened version, in ms, as the same moment in the original.
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
let mut out = ms;
for &Cut(s, e) in cuts {
if out >= s {
out += e - s;
} else {
break;
}
}
out
}
pub fn saved(cuts: &[Cut]) -> i64 {
cuts.iter().map(|Cut(s, e)| e - s).sum()
}
/// Whether someone with these settings plays a feed shortened. Off for every feed until they
/// turn it on for that one, or for the group it came in (an OPML or a Patreon creator), which a
/// feed of the group can still say otherwise to.
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
}
/// The settings of each person with DeadAir on for something, and each managed feed's group,
/// which is what `wants` and `pause` need.
pub struct Wanting {
pub people: HashMap<i64, HashMap<String, bool>>,
pub pauses: HashMap<(i64, String), i64>,
pub groups: HashMap<String, String>,
}
impl Wanting {
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new();
let mut pauses = HashMap::new();
for (user, feed, wanted, pause) in ctx.db.deadair_prefs(only).await? {
if let Some(p) = pause {
pauses.insert((user, feed.clone()), p);
}
people.entry(user).or_default().insert(feed, wanted);
}
people.retain(|_, p| p.values().any(|&w| w));
let groups = if people.is_empty() {
HashMap::new()
} else {
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
};
Ok(Self { people, pauses, groups })
}
pub fn wants(&self, user: i64, feed: &str) -> bool {
self.people.get(&user).is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
}
/// The pause this person has DeadAir leave in a feed: the feed's, else its group's.
pub fn pause(&self, user: i64, feed: &str) -> i64 {
let group = self.groups.get(feed);
self.pauses
.get(&(user, feed.to_owned()))
.or_else(|| group.and_then(|g| self.pauses.get(&(user, g.clone()))))
.copied()
.unwrap_or(DEFAULT_PAUSE)
}
}
/// The plan someone plays a file with: their pause's, for a file in a feed they have DeadAir on
/// for, once its silences are found. None if it cuts nothing.
fn chosen(wanting: &Wanting, user: i64, feed: &str, plans: &BTreeMap<i64, Plan>) -> Option<Plan> {
if !wanting.wants(user, feed) {
return None;
}
plans.get(&wanting.pause(user, feed)).filter(|p| !p.cuts.is_empty()).cloned()
}
/// What each person is playing shortened, by (person, feed, guid): set when they are handed the
/// playlist and cleared when `/media` serves them the original, so a saved position is converted
/// from the clock it was measured in, whether the page saved it or a native app did.
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
// for the playlist is then taken as the original's, which resumes a little early, never late.
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<Plan>>>>;
/// The plan for one file as this person would play it, for the playlist route.
pub async fn plan_for(ctx: &Ctx, user: i64, feed: &str, enclosure: i64) -> Result<Option<Plan>> {
let wanting = Wanting::load(ctx, Some(user)).await?;
let plans = ctx.db.plans(&[enclosure]).await?;
Ok(plans.get(&enclosure).and_then(|p| chosen(&wanting, user, feed, p)))
}
/// Puts a page of entries in the clock of what this person will play: for each file they play
/// shortened, `deadair` is the seconds it saves, and the entry's position and length are the
/// shortened version's. The page plays `/media/{id}/deadair.m3u8` for a file that has it.
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
let wanting = Wanting::load(ctx, Some(user)).await?;
if wanting.people.is_empty() {
return Ok(());
}
let ids: Vec<i64> = rows
.iter()
.filter(|r| wanting.wants(user, &r.feed_id))
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
.collect();
let plans = ctx.db.plans(&ids).await?;
for r in rows.iter_mut() {
// Heard through already: the maker passes it over, so nothing is coming.
let finished = r.duration.is_some_and(|d| d > 0 && r.position * 10 >= d * 9);
let mut converted = false;
for e in &mut r.enclosures {
e.deadair_pending = ids.contains(&e.id) && e.state == "done" && !plans.contains_key(&e.id) && !finished;
let Some(p) = plans.get(&e.id).and_then(|p| chosen(&wanting, user, &r.feed_id, p)) else { continue };
e.deadair = Some(p.saved_ms() / 1000);
// One position an item, however many files: the first played shortened sets the clock.
if !std::mem::replace(&mut converted, true) {
r.position = to_copy(&p.cuts, r.position * 1000) / 1000;
r.duration = r.duration.map(|d| to_copy(&p.cuts, d * 1000) / 1000);
}
}
}
Ok(())
}
/// A position and length a player saved, in the clock of what it was playing, as the
/// original's. Past 75% of the shortened version, the episode also counts towards the time
/// DeadAir has saved this person.
pub async fn on_save(
ctx: &Ctx,
playing: &Playing,
user: i64,
feed: &str,
guid: &str,
secs: i64,
duration: Option<i64>,
) -> Result<(i64, Option<i64>)> {
let plan = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
let Some(p) = plan else { return Ok((secs, duration)) };
let original = |s: i64| to_original(&p.cuts, s * 1000) / 1000;
let (secs, duration) = (original(secs), duration.map(original));
let len = match duration {
Some(d) => Some(d),
None => ctx.db.entry_duration(user, feed, guid).await?,
};
if let Some(len) = len
&& len > 0
&& secs * 4 >= len * 3
{
ctx.db.count_deadair(user, feed, guid, p.saved_ms()).await?;
}
Ok((secs, duration))
}
/// Finds silences until stopped: in the next file anyone wants shortened, or a wait of `IDLE`
/// when there is none.
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
if !have_ffmpeg().await {
tracing::warn!("ffmpeg is not installed, so DeadAir shortens nothing");
return;
}
// Files ffmpeg failed on, passed over until the next start. Not stored: a failure recorded
// as the file having nothing to cut was never tried again, even once a fix was deployed (#174).
let mut failed = std::collections::HashSet::new();
loop {
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
let done = tokio::select! {
_ = stop.changed() => return,
done = next(&ctx, &mut failed) => done,
};
match done {
Ok(true) => continue,
Ok(false) => {}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not read a file"),
}
tokio::select! {
_ = stop.changed() => return,
_ = ctx.deadair.notified() => {}
_ = tokio::time::sleep(IDLE) => {}
}
}
}
async fn have_ffmpeg() -> bool {
tokio::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.status()
.await
.is_ok_and(|s| s.success())
}
/// Plans one file someone wants shortened, if there is one. A file everyone who would play it
/// shortened has already heard through gets none: finished episodes are rarely played again.
// shortcut: those stay on the list and are looked at again each pass, one query each; record
// them as passed over if a large library makes the pass slow.
async fn next(ctx: &Ctx, failed: &mut std::collections::HashSet<i64>) -> Result<bool> {
// Nobody with DeadAir on for anything, which is most passes: nothing more to ask the database.
use std::sync::atomic::Ordering::Relaxed;
let st = &crate::metrics::STATS;
let wanting = Wanting::load(ctx, None).await?;
if wanting.people.is_empty() {
st.deadair_backlog.store(0, Relaxed);
return Ok(false);
}
let todo = ctx.db.deadair_todo().await?;
// For /metrics (#195): files of feeds someone has DeadAir on, from memory, without the
// per-file subscription and finished checks below.
let wanted = |t: &crate::db::DeadAirTodo| {
!failed.contains(&t.id) && wanting.people.keys().any(|&u| wanting.wants(u, &t.feed_id))
};
st.deadair_backlog.store(todo.iter().filter(|t| wanted(t)).count() as i64, Relaxed);
for t in todo {
if failed.contains(&t.id) {
continue;
}
let users: Vec<i64> = wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
continue;
}
let finished = ctx.db.finished(&t.feed_id, &t.guid).await?;
if users.iter().all(|u| finished.contains(u)) {
continue;
}
let src = crate::download::playable(Path::new(&t.path));
let started = std::time::Instant::now();
// A trace of its own for each file looked at, not for the passes that find nothing to do,
// which are most of them.
let span = tracing::info_span!("deadair", feed = t.feed_id, enclosure = t.id, cuts = tracing::field::Empty);
st.deadair_busy_since.store(crate::db::now(), Relaxed);
let analysed = analyse(&src).instrument(span.clone()).await;
st.deadair_busy_since.store(0, Relaxed);
let plans = match analysed {
Ok(p) => p.unwrap_or_default(),
Err(e) => {
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not read a file");
failed.insert(t.id);
return Ok(true);
}
};
ctx.db
.store_plans(t.id, &serde_json::to_string(&plans)?)
.instrument(span.in_scope(|| step("store_plans")))
.await?;
let usual = plans.get(&DEFAULT_PAUSE);
span.record("cuts", usual.map_or(0, |p| p.cuts.len()));
let saved_ms = usual.map_or(0, Plan::saved_ms);
ctx.out.emit(crate::ipc::Event::DeadairDone {
feed: t.feed_id.clone(),
enclosure: t.id,
saved_s: saved_ms / 1000,
});
// The event says it too (ev="deadair_done"); this line has how long it took and how many cuts.
tracing::info!(
feed = t.feed_id,
path = %src.display(),
cuts = usual.map_or(0, |p| p.cuts.len()),
saved_s = saved_ms / 1000,
took_ms = started.elapsed().as_millis() as u64,
"DeadAir found the silences"
);
return Ok(true);
}
Ok(false)
}
/// A plan for each of `PAUSES`, for one file. None for a file DeadAir does not apply to: video,
/// anything but MP3 or AAC on its own (ADTS; AAC in MP4 has no byte ranges a playlist can
/// use), or one with no silence long enough to cut.
pub async fn analyse(src: &Path) -> Result<Option<BTreeMap<i64, Plan>>> {
let Some((first, frame)) = probe(src).instrument(step("probe")).await? else { return Ok(None) };
let path = src.display().to_string();
let log = ffmpeg(&[
"-hide_banner",
"-nostats",
"-i",
&path,
"-map",
"0:a:0",
"-af",
&format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"),
"-f",
"null",
"-",
])
.instrument(step("silencedetect"))
.await
.context("finding the silences")?;
let silences = parse_silences(&log);
let frames = frame_bytes(&path).instrument(step("frame_bytes")).await?;
let plans: BTreeMap<i64, Plan> = PAUSES
.iter()
.map(|&pause| {
let (drop, cuts) = plan(&silences, first, frame, pause as f64 / 2000.0);
(pause, Plan { cuts, pieces: pieces(&frames, &drop, frame) })
})
.collect();
Ok(plans.values().any(|p| !p.cuts.is_empty()).then_some(plans))
}
/// Where each audio frame is in the file, as (byte offset, bytes), in order.
async fn frame_bytes(path: &str) -> Result<Vec<(u64, u64)>> {
let out = run_out(
"ffprobe",
&["-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pos,size", "-of", "compact=p=0", path],
)
.await?;
// ffprobe writes the fields in its own order, not the order asked for: by name, then.
out.lines()
.map(|l| {
let field = |k: &str| l.split('|').find_map(|f| f.strip_prefix(k)?.strip_prefix('=')?.parse::<u64>().ok());
field("pos").zip(field("size")).with_context(|| format!("reading ffprobe's frame: {l}"))
})
.collect()
}
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
/// can shorten; None for one it cannot. A cover picture in an MP3 is not video.
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
let path = src.display().to_string();
let out = run_out(
"ffprobe",
&[
"-v",
"error",
"-show_entries",
"format=format_name:stream=codec_type,codec_name:stream_disposition=attached_pic",
"-of",
"json",
&path,
],
)
.await?;
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
let streams = v["streams"].as_array().cloned().unwrap_or_default();
let video = streams.iter().any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
let format = v["format"]["format_name"].as_str().unwrap_or("");
let plain = |codec: &str| format == codec && audio.is_some_and(|a| a["codec_name"] == codec);
if video || !(plain("mp3") || plain("aac")) {
return Ok(None);
}
let out = run_out(
"ffprobe",
&[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"packet=pts_time,duration_time",
"-read_intervals",
"%+#1",
"-of",
"csv=p=0",
&path,
],
)
.await?;
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
match (f.next(), f.next()) {
(Some(Ok(first)), Some(Ok(frame))) if frame > 0.0 => Ok(Some((first, frame))),
_ => bail!("no audio frame in {path}"),
}
}
/// Runs ffmpeg and gives back what it wrote to stderr, where silencedetect reports.
async fn ffmpeg(args: &[&str]) -> Result<String> {
let out = command("ffmpeg").args(["-nostdin", "-threads", "1"]).args(args).output().await?;
let log = String::from_utf8_lossy(&out.stderr).into_owned();
if !out.status.success() {
bail!("ffmpeg failed: {}", log.lines().rev().take(3).collect::<Vec<_>>().join(" / "));
}
Ok(log)
}
async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
let out = command(prog).args(args).output().await.with_context(|| format!("running {prog}"))?;
if !out.status.success() {
bail!("{prog} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn command(prog: &str) -> tokio::process::Command {
let mut c = tokio::process::Command::new(prog);
c.kill_on_drop(true).stdin(std::process::Stdio::null());
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn silences_are_read_from_the_log_and_an_open_one_is_left() {
let log = "[silencedetect @ 0x1] silence_start: 1\n\
[silencedetect @ 0x1] silence_end: 3.00002 | silence_duration: 2.00002\n\
[silencedetect @ 0x1] silence_start: 10.5\n";
assert_eq!(parse_silences(log), vec![(1.0, 3.00002)]);
}
#[test]
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
// MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch; a 0.3 s pause.
let frame = 1152.0 / 44100.0;
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame, 0.15);
assert_eq!(frames, vec![(45, 108)]);
let Cut(s, e) = cuts[0];
assert!(s >= 1150 && e <= 2850, "{cuts:?}");
assert!(s - 1150 < 27 && 2850 - e < 27, "no more than a frame kept beyond half the pause: {cuts:?}");
// AAC starts a frame early, for the encoder's priming.
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0, 0.15);
assert_eq!(frames, vec![(51, 122)]);
// A silence the margins use up entirely is left alone.
assert!(plan(&[(1.0, 1.35)], 0.0, frame, 0.15).0.is_empty());
// A longer pause cuts less of the same silence.
assert!(saved(&plan(&[(1.0, 3.0)], 0.0, frame, 0.25).1) < saved(&cuts));
}
#[test]
fn pieces_are_the_frames_kept_end_to_end_and_never_too_long() {
// 1000 frames of 100 bytes after a 50-byte tag, an ID3 tag of 7 bytes before frame 600.
let frames: Vec<(u64, u64)> =
(0..1000u64).map(|n| (50 + n * 100 + if n >= 600 { 7 } else { 0 }, 100)).collect();
let frame = 0.026;
let p = pieces(&frames, &[(100, 199), (900, 999)], frame);
let at: Vec<u64> = p.iter().map(|x| x.0).collect();
// 0-99; 200-583 and 584-599, split at 384 frames (10 s); 600-899 after the tag.
assert_eq!(at, [50, 20_050, 58_450, 60_057]);
assert_eq!(p[0], (50, 10_000, 100.0 * frame));
assert_eq!(p[2].1, 1600, "frames 584-599");
assert!(p.iter().all(|x| x.2 <= MAX_PIECE));
let kept: u64 = p.iter().map(|x| x.1).sum();
assert_eq!(kept, 800 * 100, "every kept frame once, the tag in none");
}
#[test]
fn the_playlist_lists_each_piece_as_a_range_of_the_original() {
let plan = Plan { cuts: vec![Cut(1000, 2000)], pieces: vec![(50, 400, 1.0), (500, 4000, 9.5)] };
assert_eq!(
playlist(&plan, "deadair.mp3"),
"#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-TARGETDURATION:10\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-MEDIA-SEQUENCE:0\n\
#EXTINF:1.000000,\n#EXT-X-BYTERANGE:400@50\ndeadair.mp3\n\
#EXTINF:9.500000,\n#EXT-X-BYTERANGE:4000@500\ndeadair.mp3\n#EXT-X-ENDLIST\n"
);
}
#[test]
fn positions_convert_both_ways() {
let cuts = [Cut(10_000, 20_000), Cut(30_000, 35_000)];
assert_eq!(to_copy(&cuts, 5_000), 5_000);
assert_eq!(to_copy(&cuts, 15_000), 10_000, "inside a cut is where it was made");
assert_eq!(to_copy(&cuts, 25_000), 15_000);
assert_eq!(to_copy(&cuts, 40_000), 25_000);
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
let back = to_original(&cuts, to_copy(&cuts, t));
// A time inside a cut comes back as the cut's end: the shortened version has nothing between.
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
assert_eq!(back, expect, "{t}");
}
assert_eq!(saved(&cuts), 15_000);
}
#[test]
fn off_for_every_feed_until_turned_on_for_it_or_its_group() {
let p = |v: &[(&str, bool)]| v.iter().map(|(k, w)| (k.to_string(), *w)).collect::<HashMap<_, _>>();
assert!(!wants(&p(&[]), "a", None), "off until turned on for the feed");
assert!(!wants(&p(&[("b", true)]), "a", None), "another feed's says nothing of this one");
assert!(wants(&p(&[("a", true)]), "a", None));
assert!(wants(&p(&[("g", true)]), "a", Some("g")), "on for its group");
assert!(!wants(&p(&[("g", true), ("a", false)]), "a", Some("g")), "kept out of its group's");
}
#[test]
fn the_pause_is_the_feeds_then_its_groups_then_the_default() {
let w = Wanting {
people: HashMap::new(),
pauses: [((1, "a".to_string()), 500), ((1, "g".to_string()), 400)].into(),
groups: [("b".to_string(), "g".to_string())].into(),
};
assert_eq!(w.pause(1, "a"), 500);
assert_eq!(w.pause(1, "b"), 400, "its group's");
assert_eq!(w.pause(1, "c"), DEFAULT_PAUSE);
assert_eq!(w.pause(2, "a"), DEFAULT_PAUSE, "someone else's says nothing of yours");
}
#[test]
fn id3_len_reads_the_tags_size() {
assert_eq!(id3_len(b"ID3\x04\x00\x00\x00\x00\x02\x01xx"), 10 + 257);
assert_eq!(id3_len(b"ID3\x04\x00\x10\x00\x00\x02\x01"), 10 + 257 + 10, "with a footer");
assert_eq!(id3_len(b"\xff\xfb\x90\x00\x00\x00\x00\x00\x00\x00"), 0);
assert_eq!(id3_len(b"ID3"), 0);
}
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on,
/// played back through its playlist by ffmpeg's own HLS reader. Skipped where ffmpeg is not
/// installed.
#[tokio::test]
async fn the_playlist_of_a_file_is_shorter_by_what_was_cut_and_plays() {
if !have_ffmpeg().await {
eprintln!("no ffmpeg; skipped");
return;
}
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (ext, codec) in [("mp3", "libmp3lame"), ("aac", "aac"), ("m4a", "aac")] {
let src = dir.join(format!("t.{ext}"));
ffmpeg(&[
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
"-c:a",
codec,
&src.display().to_string(),
])
.await
.unwrap();
let plans = analyse(&src).await.unwrap();
if ext == "m4a" {
assert!(plans.is_none(), "AAC in MP4 has no byte ranges to play");
continue;
}
let plans = plans.expect("plans");
assert_eq!(plans.keys().copied().collect::<Vec<_>>(), PAUSES);
let p = &plans[&DEFAULT_PAUSE];
assert_eq!(p.cuts.len(), 4, "{ext}: {:?}", p.cuts);
// About 1.7 s from each of the four 2 s silences, and less for a longer pause.
let cut = p.saved_ms();
assert!((6_500..6_900).contains(&cut), "{ext}: {cut}");
assert!(plans[&500].saved_ms() < cut && cut < plans[&200].saved_ms());
let list = dir.join(format!("{ext}.m3u8"));
std::fs::write(&list, playlist(p, &format!("t.{ext}"))).unwrap();
// Its frames added up: a raw AAC file's duration is only guessed from its bitrate.
let len = |p: &Path| {
let p = p.display().to_string();
async move {
run_out(
"ffprobe",
&[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"packet=duration_time",
"-of",
"csv=p=0",
&p,
],
)
.await
.unwrap()
.lines()
.filter_map(|l| l.trim().trim_end_matches(',').parse::<f64>().ok())
.sum::<f64>()
}
};
let short = len(&src).await - len(&list).await;
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
// Decodes without a complaint.
assert!(
ffmpeg(&["-v", "error", "-i", &list.display().to_string(), "-f", "null", "-"])
.await
.unwrap()
.is_empty()
);
// Saved as a file of its own (#186): as long as the playlist, and it plays.
let (n, body) = kept(&src, p).await.unwrap();
let parts: Vec<_> = futures_util::TryStreamExt::try_collect(body).await.unwrap();
let bytes = parts.concat();
assert_eq!(bytes.len() as u64, n);
if ext == "mp3" {
assert!(bytes.starts_with(b"ID3"), "the original's tag comes too");
}
let saved = dir.join(format!("saved.{ext}"));
std::fs::write(&saved, &bytes).unwrap();
assert!((len(&saved).await - len(&list).await).abs() < 0.05, "{ext}");
assert!(
ffmpeg(&["-v", "error", "-i", &saved.display().to_string(), "-f", "null", "-"])
.await
.unwrap()
.is_empty()
);
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
/// A real episode, for trying the cut on something other than a test tone:
/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. Each pause's
/// playlist is left beside it, `ep.mp3.300.m3u8`, to listen to.
#[cfg(test)]
#[tokio::test]
#[ignore]
async fn real_file() {
let src = std::path::PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
let t = std::time::Instant::now();
let plans = analyse(&src).await.unwrap().expect("plans");
eprintln!("analysed in {:.1?}", t.elapsed());
let name = src.file_name().unwrap().to_string_lossy().into_owned();
for (pause, p) in &plans {
std::fs::write(src.with_file_name(format!("{name}.{pause}.m3u8")), playlist(p, &name)).unwrap();
eprintln!(
"{pause} ms: {} cuts, {:.1} min saved, {} pieces",
p.cuts.len(),
p.saved_ms() as f64 / 60_000.0,
p.pieces.len()
);
}
}

370
crates/ipx/src/directory.rs Normal file
View File

@@ -0,0 +1,370 @@
//! The Directory, from the directory service (#205): ipx asks it on its people's behalf, so the
//! page never talks to it and never sees its token, and sends it the public feeds people here
//! subscribe to when an admin has turned sharing on.
use std::collections::{HashMap, HashSet};
use ipx_directory::{FeedPage, Listing, Me, SnapshotEntry, SnapshotResult};
use serde::de::DeserializeOwned;
use crate::{Ctx, Sub, config, feed};
/// How often the snapshot goes when nothing has woken the sender sooner.
const SHARE_EVERY: std::time::Duration = std::time::Duration::from_secs(30 * 60);
/// What went wrong asking the directory, in words for an admin.
#[derive(Debug)]
pub enum DirError {
/// No address, or no token, in the settings.
NotConfigured,
/// It answered, refusing: 401 is a token it does not know, 403 one without the access asked for.
Refused(u16, String),
/// It did not answer, or not with what was asked for.
Unreachable(String),
}
impl std::fmt::Display for DirError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DirError::NotConfigured => {
write!(f, "No directory is set up on this server. An admin sets one in Admin, Directory.")
}
DirError::Refused(401, _) => {
write!(f, "The directory does not accept this server's token. It may have been revoked.")
}
DirError::Refused(403, msg) => write!(f, "The directory refused: {msg}."),
DirError::Refused(404, _) => write!(f, "That is not in the directory."),
DirError::Refused(code, msg) => write!(f, "The directory answered {code}: {msg}"),
DirError::Unreachable(e) => write!(f, "The directory could not be reached: {e}"),
}
}
}
/// When the snapshot last went, and what came of it, for the admin page.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Shared {
pub at: i64,
#[serde(flatten)]
pub result: Option<SnapshotResult>,
pub error: Option<String>,
}
fn target(cfg: &config::Config) -> Option<(String, String)> {
let url = cfg.general.directory_url.trim().trim_end_matches('/');
let token = cfg.general.directory_token.trim();
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
}
pub fn configured(cfg: &config::Config) -> bool {
target(cfg).is_some()
}
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
/// has answered, so a slow read of a large answer showed under no span (#250).
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
async fn call<T: DeserializeOwned>(
ctx: &Ctx,
method: reqwest::Method,
path: &str,
body: Option<&[SnapshotEntry]>,
) -> Result<T, DirError> {
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
let mut req = ctx
.local_client
.request(method, format!("{base}{path}"))
.bearer_auth(token)
.timeout(std::time::Duration::from_secs(20));
// The directory's spans join this request's trace (#213).
if let Some(tp) = ipx_directory::otel::traceparent() {
req = req.header("traceparent", tp);
}
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
if let Some(b) = body {
use std::io::Write as _;
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
req = req
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::CONTENT_ENCODING, "gzip")
.body(packed);
}
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let status = resp.status();
if !status.is_success() {
let msg = resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v["error"].as_str().map(str::to_owned))
.unwrap_or_default();
return Err(DirError::Refused(status.as_u16(), msg));
}
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
tracing::Span::current().record("http.response.body.size", body.len());
serde_json::from_slice(if body.is_empty() { b"null" } else { &body })
.map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
}
fn seg(s: &str) -> percent_encoding::PercentEncode<'_> {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC)
}
pub async fn me(ctx: &Ctx) -> Result<Me, DirError> {
call(ctx, reqwest::Method::GET, "/v1/me", None).await
}
pub async fn feeds(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/feeds", None).await?;
note_art(got.iter());
Ok(got)
}
pub async fn popular(ctx: &Ctx) -> Result<Vec<Listing>, DirError> {
let got: Vec<Listing> = call(ctx, reqwest::Method::GET, "/v1/popular?limit=10", None).await?;
note_art(got.iter());
Ok(got)
}
pub async fn page(ctx: &Ctx, id: &str) -> Result<FeedPage, DirError> {
let got: FeedPage = call(ctx, reqwest::Method::GET, &format!("/v1/feeds/{}", seg(id)), None).await?;
note_art(std::iter::once(&got.listing));
Ok(got)
}
/// Artwork the directory has listed, which `/api/art` serves though no feed here names it: the
/// Directory shows podcasts nobody here subscribes to, and drew their initials instead (#273).
///
/// shortcut: in memory, one address a listed feed, forgotten on a restart until the Directory is
/// next read, which the page does when it loads; a table if that ever shows as initials.
static LISTED_ART: std::sync::LazyLock<std::sync::Mutex<HashSet<String>>> = std::sync::LazyLock::new(Default::default);
fn note_art<'a>(listings: impl Iterator<Item = &'a Listing>) {
let mut art = LISTED_ART.lock().unwrap_or_else(|e| e.into_inner());
art.extend(listings.filter_map(|l| l.image.clone()));
}
pub fn listed_art(url: &str) -> bool {
LISTED_ART.lock().unwrap_or_else(|e| e.into_inner()).contains(url)
}
// Administering the directory (#206): each needs an rwa token, which the directory checks.
/// Removes a feed and blocks it, so no snapshot brings it back.
pub async fn remove(ctx: &Ctx, id: &str) -> Result<(), DirError> {
call(ctx, reqwest::Method::DELETE, &format!("/v1/feeds/{}", seg(id)), None).await
}
pub async fn refresh(ctx: &Ctx, id: &str) -> Result<serde::de::IgnoredAny, DirError> {
call(ctx, reqwest::Method::POST, &format!("/v1/feeds/{}/refresh", seg(id)), None).await
}
pub async fn blocked(ctx: &Ctx) -> Result<Vec<ipx_directory::Blocked>, DirError> {
call(ctx, reqwest::Method::GET, "/v1/blocked", None).await
}
pub async fn unblock(ctx: &Ctx, id: &str) -> Result<(), DirError> {
call(ctx, reqwest::Method::DELETE, &format!("/v1/blocked/{}", seg(id)), None).await
}
pub async fn instances(ctx: &Ctx) -> Result<Vec<ipx_directory::Instance>, DirError> {
call(ctx, reqwest::Method::GET, "/v1/instances", None).await
}
/// Drops one server's snapshot, and its counts with it. It comes back with that server's next
/// one: to keep a server out, revoke its token.
pub async fn drop_instance(ctx: &Ctx, name: &str) -> Result<(), DirError> {
call(ctx, reqwest::Method::DELETE, &format!("/v1/instances/{}", seg(name)), None).await
}
/// Has the snapshot sent again now, changed or not.
pub fn resend_now(ctx: &Ctx) {
ctx.directory_resend.store(true, std::sync::atomic::Ordering::Relaxed);
ctx.directory_wake.notify_one();
}
/// Whether this server's token may administer the directory.
pub async fn can_admin(ctx: &Ctx) -> bool {
me(ctx).await.is_ok_and(|m| m.access == ipx_directory::config::Access::Rwa)
}
/// Whether a feed is someone's own: marked private, given a username or password, or at an
/// address that carries a key (`feed::url_looks_private`).
pub fn looks_private(feed: &config::Feed) -> bool {
feed.private
|| feed.username.is_some()
|| feed.password.is_some()
|| feed.password_env.is_some()
|| feed::url_looks_private(&feed.url)
}
/// What the snapshot lists: every feed in the catalogue, with how many people here subscribe,
/// except an OPML or a Patreon creator itself, which is sent as the feeds in it, and anything
/// private, a feed inside a private OPML included. An OPML not read yet is not known to be one
/// and goes too; the directory lists only what it has read as a feed.
pub fn snapshot_entries(catalogue: &[Sub], counts: &HashMap<String, i64>) -> Vec<SnapshotEntry> {
let by_id: HashMap<&str, &config::Feed> = catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_group: HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
catalogue
.iter()
.filter(|s| {
!is_group.contains(s.id.as_str())
&& !feed::is_patreon_creator(&s.cfg.url)
&& !looks_private(&s.cfg)
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
})
.map(|s| SnapshotEntry {
url: s.cfg.url.clone(),
subscribers: counts.get(&s.id).copied().unwrap_or(0),
category: s.cfg.category.clone(),
})
.collect()
}
/// Sends the snapshot now.
async fn snapshot(ctx: &Ctx) -> Result<Vec<SnapshotEntry>, DirError> {
let catalogue = crate::subscriptions(ctx).await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
let counts = ctx.db.subscriber_counts().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
Ok(snapshot_entries(&catalogue, &counts))
}
pub async fn share(ctx: &Ctx, entries: &[SnapshotEntry]) -> Result<SnapshotResult, DirError> {
let r: SnapshotResult = call(ctx, reqwest::Method::POST, "/v1/snapshot", Some(entries)).await?;
if r.refused_private > 0 {
// The directory caught what this side should have: worth knowing which check differs.
tracing::warn!(refused = r.refused_private, "the directory refused feeds as private that ipx sent");
}
Ok(r)
}
/// Sends the snapshot every half hour while sharing is on, and at once when the settings change.
pub async fn run(ctx: std::sync::Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
// What went last, where, and when: a scan wakes this every half minute or so, and the same
// 1,500 feeds went each time, the directory rewriting its rows for nothing (#211).
let mut last: Option<(Vec<u8>, std::time::Instant)> = None;
loop {
let cfg = ctx.cfg();
if cfg.general.directory_share && configured(&cfg) {
let got = match snapshot(&ctx).await {
Ok(entries) => {
let key = serde_json::to_vec(&(target(&cfg), &entries)).unwrap_or_default();
let forced = ctx.directory_resend.swap(false, std::sync::atomic::Ordering::Relaxed);
if forced || resend(last.as_ref(), &key) {
let span =
tracing::info_span!(target: "ipx::directory", "directory_share", feeds = entries.len());
let got = tracing::Instrument::instrument(share(&ctx, &entries), span).await;
if got.is_ok() {
last = Some((key, std::time::Instant::now()));
}
Some(got)
} else {
None
}
}
Err(e) => Some(Err(e)),
};
if let Some(got) = got {
match &got {
Ok(r) => tracing::info!(
accepted = r.accepted,
refused_private = r.refused_private,
refused_blocked = r.refused_blocked,
"shared with the directory"
),
Err(e) => tracing::warn!(error = %e, "could not share with the directory"),
}
let (result, error) = match got {
Ok(r) => (Some(r), None),
Err(e) => (None, Some(e.to_string())),
};
*ctx.directory_shared.lock().unwrap() = Some(Shared { at: crate::db::now(), result, error });
}
}
tokio::select! {
_ = tokio::time::sleep(SHARE_EVERY) => {}
_ = ctx.directory_wake.notified() => {}
_ = stop.changed() => return,
}
}
}
/// Whether to send the snapshot `key` stands for: one that changed, or went to another directory
/// or with another token, goes now; the same again only once SHARE_EVERY has passed, so a
/// directory that lost it, or dropped this server's counts, has it back within the half hour.
fn resend(last: Option<&(Vec<u8>, std::time::Instant)>, key: &[u8]) -> bool {
last.is_none_or(|(k, at)| k != key || at.elapsed() >= SHARE_EVERY)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artwork_the_directory_listed_is_let_through_and_nothing_else() {
let listing = |image: Option<&str>| Listing {
id: "x".into(),
url: "https://x.example/feed".into(),
title: None,
image: image.map(str::to_owned),
category: None,
subcategory: None,
podcast: true,
subscribers: 1,
};
note_art([listing(Some("https://img.example/cover.jpg")), listing(None)].iter());
assert!(listed_art("https://img.example/cover.jpg"));
assert!(!listed_art("http://192.168.1.1/admin"));
}
fn sub(id: &str, url: &str, edit: impl FnOnce(&mut config::Feed)) -> Sub {
let mut cfg: config::Feed = serde_json::from_value(serde_json::json!({ "url": url })).unwrap();
edit(&mut cfg);
Sub { id: id.into(), cfg, managed: false }
}
#[test]
fn the_snapshot_holds_public_feeds_and_never_a_private_one() {
let catalogue = vec![
sub("public", "https://example.com/feed.xml", |_| {}),
sub("marked", "https://example.com/mine.xml", |f| f.private = true),
sub("password", "https://example.com/locked.xml", |f| f.username = Some("me".into())),
sub("paid", "https://feeds.supercast.com/feeds/abc", |_| {}),
sub("keyed", "https://example.com/feed?key=k", |_| {}),
// An OPML: sent as the feeds in it, never itself.
sub("list", "https://example.com/list.opml", |_| {}),
sub("in-list", "https://example.com/a.xml", |f| f.group = Some("list".into())),
// Inside a private OPML: as private as the OPML.
sub("secret-list", "https://example.com/l.opml?token=t", |_| {}),
sub("in-secret", "https://example.com/b.xml", |f| f.group = Some("secret-list".into())),
];
let counts = HashMap::from([("public".to_string(), 3), ("marked".to_string(), 1)]);
let got = snapshot_entries(&catalogue, &counts);
let urls: Vec<(&str, i64)> = got.iter().map(|e| (e.url.as_str(), e.subscribers)).collect();
assert_eq!(urls, [("https://example.com/feed.xml", 3), ("https://example.com/a.xml", 0)]);
let patreon = vec![sub("creator", "https://www.patreon.com/c/someone", |_| {})];
assert!(snapshot_entries(&patreon, &counts).is_empty(), "a Patreon creator goes as its shows");
}
#[test]
fn nothing_is_asked_without_an_address_and_a_token() {
let mut cfg = config::Config::default();
assert!(!configured(&cfg));
cfg.general.directory_url = "https://ipx-dir.example/".into();
assert!(!configured(&cfg));
cfg.general.directory_token = "t".into();
assert_eq!(target(&cfg), Some(("https://ipx-dir.example".into(), "t".into())));
}
#[test]
fn the_same_snapshot_is_not_sent_again_within_the_half_hour() {
let now = std::time::Instant::now();
assert!(resend(None, b"a"));
assert!(!resend(Some(&(b"a".to_vec(), now)), b"a"));
assert!(resend(Some(&(b"a".to_vec(), now)), b"b"));
if let Some(long_ago) = now.checked_sub(SHARE_EVERY) {
assert!(resend(Some(&(b"a".to_vec(), long_ago)), b"a"));
}
}
}

View File

@@ -85,16 +85,8 @@ pub fn filename_for(url: &str, content_disposition: Option<&str>) -> String {
}
let from_url = url::Url::parse(url)
.ok()
.and_then(|u| {
u.path_segments()
.and_then(|s| s.filter(|p| !p.is_empty()).next_back())
.map(str::to_owned)
})
.map(|seg| {
percent_encoding::percent_decode_str(&seg)
.decode_utf8_lossy()
.into_owned()
})
.and_then(|u| u.path_segments().and_then(|mut s| s.rfind(|p| !p.is_empty())).map(str::to_owned))
.map(|seg| percent_encoding::percent_decode_str(&seg).decode_utf8_lossy().into_owned())
.unwrap_or_default();
sanitize(&from_url)
}
@@ -105,11 +97,7 @@ fn disposition_filename(header: &str) -> Option<String> {
// RFC 5987 form wins when both are present.
if let Some(v) = part.strip_prefix("filename*=") {
let raw = v.rsplit('\'').next().unwrap_or(v);
return Some(
percent_encoding::percent_decode_str(raw)
.decode_utf8_lossy()
.into_owned(),
);
return Some(percent_encoding::percent_decode_str(raw).decode_utf8_lossy().into_owned());
}
}
for part in header.split(';') {
@@ -145,8 +133,8 @@ pub async fn download(
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<Downloaded> {
let mut req = client.get(url);
if let Some(user) = &feed_cfg.username {
req = req.basic_auth(user, feed_cfg.password());
if let Some((user, password)) = feed_cfg.auth_for(url) {
req = req.basic_auth(user, password);
}
let resp = req.send().await.context("connecting")?;
if !resp.status().is_success() {
@@ -155,27 +143,23 @@ pub async fn download(
let name = filename_for(
resp.url().as_str(),
resp.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok()),
resp.headers().get(reqwest::header::CONTENT_DISPOSITION).and_then(|v| v.to_str().ok()),
);
let total = resp.content_length();
let tmp_dir = cfg.general.download_dir.join(".ipx-incomplete");
tokio::fs::create_dir_all(&tmp_dir)
.await
.with_context(|| format!("creating {}", tmp_dir.display()))?;
tokio::fs::create_dir_all(&tmp_dir).await.with_context(|| format!("creating {}", tmp_dir.display()))?;
let tmp = tmp_dir.join(&name);
let mut file = tokio::fs::File::create(&tmp)
.await
.with_context(|| format!("creating {}", tmp.display()))?;
let mut file = tokio::fs::File::create(&tmp).await.with_context(|| format!("creating {}", tmp.display()))?;
let mut done: u64 = 0;
let mut stream = resp.bytes_stream();
let _active = crate::metrics::busy(&crate::metrics::STATS.downloads_active);
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("reading body")?;
file.write_all(&chunk).await?;
done += chunk.len() as u64;
crate::metrics::STATS.download_bytes.fetch_add(chunk.len() as u64, std::sync::atomic::Ordering::Relaxed);
on_progress(done, total);
}
file.flush().await?;
@@ -197,16 +181,42 @@ pub async fn download(
/// Moves a completed download into its folder. Same filesystem as the incomplete dir, so
/// this is a rename rather than the original's copy-then-unlink.
pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
tokio::fs::create_dir_all(dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
tokio::fs::create_dir_all(dir).await.with_context(|| format!("creating {}", dir.display()))?;
let dest = unique_path(dir, &d.name);
tokio::fs::rename(&d.tmp, &dest)
.await
.with_context(|| format!("moving into {}", dest.display()))?;
tokio::fs::rename(&d.tmp, &dest).await.with_context(|| format!("moving into {}", dest.display()))?;
Ok(dest)
}
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
/// and Delete failed (#158).
pub fn remove(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
return std::fs::remove_dir_all(path);
}
std::fs::remove_file(path)
}
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
/// the largest file in its folder, which is the episode (#158).
pub fn playable(path: &Path) -> PathBuf {
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
match e.metadata() {
Ok(m) if m.is_dir() => largest(&e.path(), best),
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
_ => {}
}
}
}
let mut best = None;
if path.is_dir() {
largest(path, &mut best);
}
best.map_or_else(|| path.to_owned(), |(_, p)| p)
}
/// Advertised as a torrent, before spending any bandwidth on it.
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
@@ -249,15 +259,8 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
if !candidate.exists() {
return candidate;
}
let stem = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(name);
let ext = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}"))
.unwrap_or_default();
let stem = Path::new(name).file_stem().and_then(|s| s.to_str()).unwrap_or(name);
let ext = Path::new(name).extension().and_then(|e| e.to_str()).map(|e| format!(".{e}")).unwrap_or_default();
for n in 2..1000 {
let candidate = dir.join(format!("{stem}-{n}{ext}"));
if !candidate.exists() {
@@ -276,12 +279,7 @@ pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str
match cfg.general.organize {
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
Organize::Feed => {
let raw = feed_cfg
.folder
.as_deref()
.or(title)
.filter(|s| !s.trim().is_empty())
.unwrap_or(id);
let raw = feed_cfg.folder.as_deref().or(title).filter(|s| !s.trim().is_empty()).unwrap_or(id);
raw.split('/')
.map(str::trim)
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
@@ -305,6 +303,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
})
}
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
/// "Trump's" is by "trump".
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
// Letters and digits only, each run of anything else one space, padded so a phrase matches
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
let norm = |s: &str| {
let mut out = String::from(" ");
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
out.push_str(&w.to_lowercase());
out.push(' ');
}
out
};
let hay = norm(&haystacks.join(" "));
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -359,19 +376,13 @@ mod tests {
"ep1.mp3",
"query string is not part of the name"
);
assert_eq!(
filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None),
"My Show Ep 1.mp3"
);
assert_eq!(filename_for("https://x.com/media/My%20Show%20Ep%201.mp3", None), "My Show Ep 1.mp3");
assert_eq!(
filename_for("https://x.com/dl?id=9", Some("attachment; filename=\"real name.mp3\"")),
"real name.mp3",
"Content-Disposition overrides the URL"
);
assert_eq!(
filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")),
"café.mp3"
);
assert_eq!(filename_for("https://x.com/dl", Some("attachment; filename*=UTF-8''caf%C3%A9.mp3")), "café.mp3");
assert_eq!(filename_for("https://x.com/", None), "download");
assert_eq!(
filename_for("https://x.com/a/../../etc/passwd", None),
@@ -385,9 +396,21 @@ mod tests {
let mut cfg = Config::default();
cfg.general.download_dir = "/tmp".into();
let mut f = crate::config::Feed {
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
max_new_per_check: None, username: None, password: None, password_env: None, category: None,
url: "u".into(),
folder: Some("Subscriptions/Some | Show".into()),
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
category: None,
listed: false,
private: false,
};
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
@@ -396,6 +419,20 @@ mod tests {
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
}
#[test]
fn a_torrent_folder_plays_its_largest_file_and_deletes_whole() {
let d = std::env::temp_dir().join(format!("ipx-torrent-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("show/extras")).unwrap();
std::fs::write(d.join("show/cover.jpg"), [0; 10]).unwrap();
std::fs::write(d.join("show/extras/episode.mp3"), [0; 100]).unwrap();
assert_eq!(playable(&d.join("show")), d.join("show/extras/episode.mp3"));
assert_eq!(playable(&d.join("show/cover.jpg")), d.join("show/cover.jpg"), "a file is itself");
remove(&d.join("show")).unwrap();
assert!(!d.join("show").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
let kws = vec!["deep dive".to_string(), "interview".to_string()];
@@ -404,4 +441,16 @@ mod tests {
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
}
#[test]
fn blocking_matches_whole_words_and_phrases() {
let words = vec!["AI".to_string(), "new york".to_string()];
assert!(blocked(&words, &["What AI means now"]));
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
assert!(!blocked(&words, &["New Yorkshire"]));
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
}

View File

@@ -19,6 +19,10 @@ pub mod feeds {
/// The channel's first <itunes:category>, for the Directory.
#[sea_orm(column_type = "Text", nullable)]
pub category: Option<String>,
/// What the feed says it is, as it says it, for its page in the Directory. Added after
/// the table was, so `create_missing` adds it to a database without it.
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub etag: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
@@ -42,6 +46,12 @@ pub mod feeds {
/// are re-derived instead. Customising one promotes it to config.
#[sea_orm(default_value = false)]
pub managed: bool,
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
#[sea_orm(column_type = "Text", nullable)]
pub body_hash: Option<String>,
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
pub same_since: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -68,6 +78,9 @@ pub mod entries {
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub first_seen: i64,
/// The item's Markdown, when its feed sends it beside the HTML (#285).
#[sea_orm(column_type = "Text", nullable)]
pub markdown: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub image: Option<String>,
pub duration: Option<i64>,
@@ -102,6 +115,10 @@ pub mod enclosures {
pub length: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>,
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
/// why), then 'downloading', 'done', 'error', 'reaped'.
#[sea_orm(column_type = "Text")]
pub state: String,
#[sea_orm(default_value = 0)]
@@ -143,6 +160,12 @@ pub mod users {
pub theme: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub theme_mode: Option<String>,
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
/// back to the page.
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
// server's if the database is ever shared with anything else.
#[sea_orm(column_type = "Text", nullable)]
pub typesafe_key: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -196,6 +219,21 @@ pub mod subscriptions {
/// Pinned to the top of this person's feed list, a feed inside a folder included.
#[sea_orm(default_value = false)]
pub pinned: bool,
/// A feed from an OPML or Patreon list this person keeps when the list drops it, or when
/// they leave the list: it becomes a feed of its own instead of going (#280).
#[sea_orm(default_value = false)]
pub kept: bool,
/// Left out of All Subscriptions: this feed, or on an OPML or Patreon creator every show
/// in it (#288).
#[sea_orm(default_value = false)]
pub hide_from_all: bool,
/// Left out of every Signal, and never put to Jev for them, as `hide_from_all` (#289).
#[sea_orm(default_value = false)]
pub hide_from_signals: bool,
/// Every item of the feed first seen before this is read by this person (#347): unread
/// lists start here instead of reading the whole back catalogue. NULL knows nothing, and
/// the daily reap moves it up (`Db::advance_unread_from`).
pub unread_from: Option<i64>,
}
owned_by_user!();
@@ -228,6 +266,111 @@ pub mod entry_state {
owned_by_user!();
}
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
/// every feed they read.
pub mod blocklists {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "blocklists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub words: String,
}
owned_by_user!();
}
/// The items someone's block lists hide from them, worked out whenever a list or the feed
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
pub mod hidden {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "hidden")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
}
owned_by_user!();
}
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
/// keywords, whichever feed they came from.
pub mod signals {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signals")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
#[sea_orm(column_type = "Text")]
pub name: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub keywords: String,
/// What its owner means by it, in a sentence of their own, when the name and the words
/// are not enough (#259).
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
/// initials (#261).
#[sea_orm(column_type = "Text", nullable)]
pub icon: Option<String>,
pub created: i64,
}
owned_by_user!();
}
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
/// here is still to be asked.
pub mod signal_verdicts {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "signal_verdicts")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub signal_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
pub hit: bool,
/// A picked-out item to ask about again, since its Signal changed (#276). It stays in the
/// Signal's list until Jev has answered the new question.
#[sea_orm(default_value = false)]
pub recheck: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::signals::Entity",
from = "Column::SignalId",
to = "super::signals::Column::Id",
on_delete = "Cascade"
)]
Signal,
}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod sessions {
use sea_orm::entity::prelude::*;
@@ -243,6 +386,29 @@ pub mod sessions {
owned_by_user!();
}
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
pub mod api_tokens {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "api_tokens")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub user_id: i64,
/// What its owner called it, to tell one from another when revoking.
#[sea_orm(column_type = "Text")]
pub name: String,
#[sea_orm(unique, column_type = "Text")]
pub hash: String,
pub created: i64,
pub last_used: Option<i64>,
}
owned_by_user!();
}
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
pub mod catalogue {
@@ -283,3 +449,67 @@ pub mod settings {
impl ActiveModelBehavior for ActiveModel {}
}
/// What DeadAir found in an enclosure (#181): `plans` is a `deadair::Plan` for each pause length,
/// as JSON keyed by the pause in ms, and `{}` for a file it does not apply to (video, not MP3 or
/// AAC, nothing to cut), so that one is looked at once and not every pass.
pub mod deadair_plans {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_plans")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub enclosure_id: i64,
#[sea_orm(column_type = "Text")]
pub plans: String,
/// Against the enclosure's `downloaded_at`: a file downloaded again is looked at again.
pub made_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// Who has DeadAir on, for which feeds: it is off for every feed until someone turns it on for
/// that one or its group, and `wanted` false keeps one feed of a group out of the group's.
pub mod deadair_prefs {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_prefs")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
pub wanted: bool,
/// The pause DeadAir leaves where it cuts a silence, in ms (#182); NULL for the usual.
pub pause_ms: Option<i64>,
}
owned_by_user!();
}
/// The time DeadAir has saved someone: one row per episode they got 75% through on a copy, so
/// replaying it never counts twice. Their total is the sum.
pub mod deadair_saved {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "deadair_saved")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
pub saved_ms: i64,
pub at: i64,
}
owned_by_user!();
}

655
crates/ipx/src/ipc.rs Normal file
View File

@@ -0,0 +1,655 @@
//! Unix-socket control and event stream. Replaces printMSG's `;;1;;1;;100.00;;42.31`.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc};
/// One JSON object per line, `ev` naming the variant.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "ev", rename_all = "snake_case")]
pub enum Event {
FeedStart {
feed: String,
},
FeedSkip {
feed: String,
reason: String,
},
FeedDone {
feed: String,
new: usize,
downloaded: usize,
failed: usize,
torrents: usize,
},
FeedError {
feed: String,
msg: String,
},
/// The feed answered from a new address after redirects that all said it moved for good,
/// and the catalogue now has that address (#115).
FeedMoved {
feed: String,
from: String,
to: String,
},
Progress {
feed: String,
/// Which enclosure this is about. Without it a UI cannot tell one download's
/// progress from another's and ends up animating every pending row.
enclosure: i64,
url: String,
file: String,
done: u64,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<u64>,
},
DownloadDone {
feed: String,
enclosure: i64,
url: String,
path: String,
bytes: u64,
},
DownloadError {
feed: String,
enclosure: i64,
url: String,
msg: String,
},
TorrentDeferred {
feed: String,
url: String,
},
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
DeadairDone {
feed: String,
enclosure: i64,
saved_s: i64,
},
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
SignalHits {
user: i64,
hits: usize,
},
Reaped {
path: String,
bytes: u64,
},
/// Terminal: a client that asked for work stops reading here.
ScanDone {
feeds: usize,
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
#[serde(default)]
only: Option<String>,
#[serde(default)]
forced: bool,
},
ReapDone {
files: usize,
bytes: u64,
},
Status {
feeds: usize,
pending: i64,
downloaded: i64,
},
Error {
msg: String,
},
}
impl Event {
/// The feed this is about, for sending it only to those who read that feed.
pub fn feed(&self) -> Option<&str> {
match self {
Event::FeedStart { feed }
| Event::FeedSkip { feed, .. }
| Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. }
| Event::Progress { feed, .. }
| Event::DownloadDone { feed, .. }
| Event::DownloadError { feed, .. }
| Event::TorrentDeferred { feed, .. }
| Event::DeadairDone { feed, .. } => Some(feed),
_ => None,
}
}
/// Whether a page signed in as this person is sent it. Everyone had every event, so a key in
/// a paid feed's path, which `redacted` keeps, reached people who do not read the feed (#325).
/// An admin has them all, for the log.
pub fn is_for(&self, user: &crate::db::User, mine: &std::collections::HashSet<String>) -> bool {
if user.is_admin {
return true;
}
match self {
Event::SignalHits { user: u, .. } => *u == user.id,
// Paths of files on disk, everyone's.
Event::Reaped { .. } => false,
ev => ev.feed().is_none_or(|f| mine.contains(f)),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
}
/// The human rendering, for a terminal rather than a UI.
pub fn human(&self) -> Option<String> {
Some(match self {
Event::FeedSkip { feed, reason } => format!("{feed}: {reason}"),
Event::FeedDone { feed, new, downloaded, failed, torrents } => format!(
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
),
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
Event::Progress { file, done, total, .. } => match total {
Some(t) if *t > 0 => format!(
" {file}: {:.1}% ({:.1}/{:.1} MB)",
*done as f64 / *t as f64 * 100.0,
*done as f64 / 1_048_576.0,
*t as f64 / 1_048_576.0
),
_ => format!(" {file}: {:.1} MB", *done as f64 / 1_048_576.0),
},
Event::DownloadDone { path, .. } => format!(" saved {path}"),
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
Event::Reaped { path, bytes } => {
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
}
Event::ReapDone { files, bytes } => {
format!("deleted {files} old file(s), {:.1} MB", *bytes as f64 / 1_048_576.0)
}
Event::Status { feeds, pending, downloaded } => {
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
}
Event::Error { msg } => format!("error: {msg}"),
// Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => {
format!("{feed}: DeadAir found the silences, {saved_s} s shorter")
}
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
Fetch {
#[serde(default)]
feed: Option<String>,
#[serde(default)]
force: bool,
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
/// Empty is every feed, as the schedule and the CLI mean it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
feeds: Vec<String>,
},
Reap {
#[serde(default)]
dry_run: bool,
},
/// Fetch one specific enclosure now, ignoring max_new_per_check and the queue order.
/// A scan cannot express "this one, now": it takes the lowest-id pending rows up to
/// the per-scan cap, so an explicit request has to bypass both.
Download {
enclosure: i64,
},
Status,
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
/// and `import` change in a process of their own. A daemon keeps them in memory, and with
/// its old copy wrote a removed feed back, and dropped an added one, at its next change of
/// its own (#120). Answered, as status is, with the counts after it.
Reload,
}
/// Where events go: the socket, the terminal, or both.
#[derive(Clone)]
pub struct Emitter {
tx: Option<broadcast::Sender<Event>>,
print: bool,
}
impl Emitter {
pub fn terminal() -> Self {
Self { tx: None, print: true }
}
pub fn socket(tx: broadcast::Sender<Event>, print: bool) -> Self {
Self { tx: Some(tx), print }
}
pub fn emit(&self, e: Event) {
let e = e.redacted();
// Also log it. Scans and downloads travel as events, not tracing calls, so
// without this the log view shows only startup and HTTP lines and none of the
// work the daemon is actually doing. Progress goes to debug: it fires on every
// whole percent and would otherwise crowd everything else out of the buffer.
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
// line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes.
log_event(&e, self.tx.is_some());
// A feed not due changes nothing a listener shows, and a scan has one for nearly every
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
if let Some(tx) = &self.tx
&& !routine
{
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}
if self.print
&& let Some(line) = e.human()
{
println!("{line}");
}
}
}
impl Event {
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
/// so do the ones in an error's words (#238).
fn redacted(mut self) -> Self {
use crate::feed::{redact, redact_text};
match &mut self {
Event::DownloadError { url, msg, .. } => {
*url = redact(url);
*msg = redact_text(msg);
}
// Progress and DownloadDone too: a Patreon file's address carries its signature (#292).
Event::TorrentDeferred { url, .. } | Event::Progress { url, .. } | Event::DownloadDone { url, .. } => {
*url = redact(url)
}
Event::FeedMoved { from, to, .. } => {
*from = redact(from);
*to = redact(to);
}
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
_ => {}
}
self
}
}
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
fn log_event(e: &Event, wire: bool) {
let json = serde_json::to_string(e).unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
let (kind, code) = match e {
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
let (k, c) = crate::feed::failure_kind(msg);
(Some(k), c)
}
_ => (None, None),
};
let routine = match e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
macro_rules! line {
($level:ident, $target:literal, $text:expr) => {
tracing::$level!(
target: $target,
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
"error.type" = kind, "http.response.status_code" = code,
"{}", $text
)
};
}
// `ipx status`'s answer: a reply on the socket rather than work done, so it
// stays with the rest of the conversation, and the Scans tab stays about scans.
if matches!(e, Event::Status { .. }) {
return line!(info, "ipx::io", format!("<- {json}"));
}
if wire {
tracing::debug!(target: "ipx::io", "<- {json}");
}
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
if kind.is_some() {
line!(warn, "ipx::scan", text)
} else if routine {
line!(debug, "ipx::scan", text)
} else {
line!(info, "ipx::scan", text)
}
}
/// True when something is already listening -- i.e. a daemon owns this socket.
pub async fn daemon_is_live(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok()
}
/// Answers `status` and `reload` for the socket, without the worker. The worker runs one job at a
/// time, and a healthcheck left waiting behind a scan or a long download timed out and called a
/// busy daemon dead; a reload left waiting would let the web UI write the old catalogue back in
/// the meantime. The answer goes to the client that asked and no one else: broadcast, it ended
/// any `ipx fetch` that was watching a scan, since `status` is a terminal event.
/// A future, since reading the counts is a database query.
pub type StatusFn =
std::sync::Arc<dyn Fn(Command) -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve(
path: PathBuf,
events: broadcast::Sender<Event>,
cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> {
// A socket file left by a crashed daemon would block the bind; a live one was already
// rejected by the caller's daemon_is_live() check.
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("removing stale socket {}", path.display()))?;
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = UnixListener::bind(&path).with_context(|| format!("binding {}", path.display()))?;
tracing::info!(socket = %path.display(), "listening");
loop {
let (stream, _) = listener.accept().await?;
let rx = events.subscribe();
let cmds = cmds.clone();
let status = status.clone();
tokio::spawn(async move {
if let Err(e) = handle(stream, rx, cmds, status).await {
tracing::debug!(error = %e, "client gone");
}
});
}
}
async fn handle(
stream: UnixStream,
mut rx: broadcast::Receiver<Event>,
cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> {
let (read, mut write) = stream.into_split();
// Events out: everything broadcast, and the answers meant for this client alone.
let (reply, mut replies) = mpsc::channel::<Event>(4);
let writer = tokio::spawn(async move {
loop {
let ev = tokio::select! {
Some(ev) = replies.recv() => ev,
got = rx.recv() => match got {
Ok(ev) => ev,
Err(_) => break,
},
};
let mut line = serde_json::to_string(&ev).unwrap_or_default();
line.push('\n');
if write.write_all(line.as_bytes()).await.is_err() {
break;
}
}
});
// Commands in.
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await? {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<Command>(line) {
// Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(cmd @ (Command::Status | Command::Reload)) => {
tracing::info!(target: "ipx::io", "-> {line}");
let ev = status(cmd).await;
log_event(&ev, true);
let _ = reply.send(ev).await;
}
Ok(cmd) => {
if cmds.send(cmd).await.is_err() {
break; // Worker is gone; so are we.
}
}
Err(e) => tracing::warn!(error = %e, line, "bad command"),
}
}
writer.abort();
Ok(())
}
/// Tells a running daemon to read the catalogue again (see Command::Reload), and waits for it.
pub async fn reload(path: &Path) -> Result<()> {
let stream = UnixStream::connect(path).await?;
let (read, mut write) = stream.into_split();
write.write_all(b"{\"cmd\":\"reload\"}\n").await?;
let mut lines = BufReader::new(read).lines();
let answer = async {
// Other clients' events come here too; the answer is the status, or an error.
while let Some(line) = lines.next_line().await? {
match serde_json::from_str::<Event>(&line) {
Ok(Event::Status { .. }) => return Ok(()),
Ok(Event::Error { msg }) => anyhow::bail!("{msg}"),
_ => continue,
}
}
anyhow::bail!("the daemon closed the connection without answering")
};
tokio::time::timeout(std::time::Duration::from_secs(10), answer)
.await
.context("the daemon did not answer in 10s")?
}
/// Sends one command to a running daemon and prints the events it produces.
pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
let stream = UnixStream::connect(path).await?;
let (read, mut write) = stream.into_split();
let mut line = serde_json::to_string(cmd)?;
line.push('\n');
write.write_all(line.as_bytes()).await?;
// ponytail: the event stream is a broadcast, so a busy daemon's other work shows up
// here too. Fine for a CLI; a UI that cares would want per-request ids.
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await? {
let Ok(ev) = serde_json::from_str::<Event>(&line) else {
continue;
};
if let Some(text) = ev.human() {
println!("{text}");
}
if ev.is_terminal() {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_feeds_events_go_to_those_who_read_it() {
let person = |id, is_admin| crate::db::User {
id,
name: "x".into(),
pass_hash: None,
is_admin,
created: None,
last_login: None,
};
let (me, admin) = (person(1, false), person(2, true));
let mine: std::collections::HashSet<String> = ["mine".to_string()].into();
let error = |feed: &str| Event::FeedError {
feed: feed.into(),
msg: "error sending request for url (https://feeds.supercast.com/feeds/KEY)".into(),
};
assert!(error("mine").is_for(&me, &mine));
assert!(!error("theirs").is_for(&me, &mine), "a paid feed's key in its path (#325)");
assert!(error("theirs").is_for(&admin, &mine), "an admin's log has everything");
assert!(Event::ScanDone { feeds: 3, only: None, forced: false }.is_for(&me, &mine));
assert!(Event::SignalHits { user: 1, hits: 2 }.is_for(&me, &mine));
assert!(!Event::SignalHits { user: 3, hits: 2 }.is_for(&me, &mine));
assert!(!Event::Reaped { path: "/downloads/Theirs/ep.mp3".into(), bytes: 1 }.is_for(&me, &mine));
}
#[test]
fn a_feed_not_due_stays_off_the_live_updates() {
let (tx, mut rx) = broadcast::channel(4);
let out = Emitter::socket(tx, false);
for i in 0..10 {
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
}
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
}
#[test]
fn what_goes_out_names_no_private_feeds_key() {
let (tx, mut rx) = broadcast::channel(8);
let out = Emitter::socket(tx, false);
let key = "auth=SECRET";
out.emit(Event::FeedError {
feed: "f".into(),
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
});
out.emit(Event::DownloadError {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
});
out.emit(Event::FeedMoved {
feed: "f".into(),
from: format!("https://x/rss?{key}"),
to: format!("https://y/rss?{key}"),
});
out.emit(Event::Progress {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
file: "ep.mp3".into(),
done: 1,
total: None,
});
out.emit(Event::DownloadDone {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
path: "/downloads/ep.mp3".into(),
bytes: 1,
});
// What every open page is sent, whoever's feed it is (#238, #292).
for _ in 0..5 {
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
assert!(!sent.contains("SECRET"), "{sent}");
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
}
}
#[test]
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
let wire = serde_json::to_value(&e).unwrap();
assert_eq!(
(wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()),
(Some("feed_moved"), Some("http://a/f"), Some("https://a/f"))
);
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
}
#[test]
fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true }));
// "Download this one now" is its own command precisely because a scan cannot
// express it: a scan takes the lowest-id pending rows up to max_new_per_check.
let got: Command = serde_json::from_str(r#"{"cmd":"download","enclosure":11}"#).unwrap();
assert!(matches!(got, Command::Download { enclosure: 11 }));
assert!(serde_json::from_str::<Command>(r#"{"cmd":"nope"}"#).is_err());
}
#[test]
fn events_serialise_to_the_documented_shape() {
let ev = Event::Progress {
feed: "atp".into(),
enclosure: 42,
url: "https://x/ep.mp3".into(),
file: "ep.mp3".into(),
done: 10_485_760,
total: Some(52_428_800),
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}");
assert!(json.contains(r#""done":10485760"#));
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
// total is omitted rather than null when the server sent no length.
let ev =
Event::Progress { feed: "a".into(), enclosure: 1, url: "u".into(), file: "f".into(), done: 1, total: None };
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
}
#[test]
fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
assert!(!Event::FeedDone { feed: "a".into(), new: 0, downloaded: 0, failed: 0, torrents: 0 }.is_terminal());
}
#[tokio::test]
async fn status_is_answered_while_the_worker_is_busy() {
// The queue is full and nobody drains it, as when the worker is deep in a long download:
// anything sent to it would wait for ever.
let (cmds, _worker) = mpsc::channel::<Command>(1);
cmds.send(Command::Reap { dry_run: true }).await.unwrap();
let (events, _) = broadcast::channel::<Event>(8);
// Another client, watching a scan: it must not be handed someone else's answer, which
// would end its session.
let mut watcher = events.subscribe();
let status: StatusFn =
std::sync::Arc::new(|_| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status));
let (read, mut write) = client.into_split();
write.write_all(b"{\"cmd\":\"status\"}\n").await.unwrap();
let line = tokio::time::timeout(std::time::Duration::from_secs(2), BufReader::new(read).lines().next_line())
.await
.expect("status waited behind the worker")
.unwrap()
.unwrap();
assert!(line.contains(r#""ev":"status""#), "{line}");
assert!(watcher.try_recv().is_err(), "the answer went to every client, not just the one asking");
}
}

View File

@@ -27,9 +27,8 @@ struct Ring {
next_seq: u64,
}
static BUF: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 })
});
static BUF: LazyLock<Mutex<Ring>> =
LazyLock::new(|| Mutex::new(Ring { lines: VecDeque::with_capacity(CAPACITY), next_seq: 1 }));
pub fn push(level: &str, target: &str, msg: String) {
let mut ring = match BUF.lock() {
@@ -57,12 +56,7 @@ pub fn since(after: u64, limit: usize) -> (Vec<LogLine>, u64) {
Err(p) => p.into_inner(),
};
let latest = ring.next_seq.saturating_sub(1);
let mut out: Vec<LogLine> = ring
.lines
.iter()
.filter(|l| l.seq > after)
.cloned()
.collect();
let mut out: Vec<LogLine> = ring.lines.iter().filter(|l| l.seq > after).cloned().collect();
// On a first load (after = 0) the tail is what matters, not the head.
if out.len() > limit {
out.drain(..out.len() - limit);
@@ -130,10 +124,7 @@ mod tests {
let (all, latest) = since(0, CAPACITY * 2);
assert_eq!(all.len(), CAPACITY, "bounded");
assert!(latest >= (CAPACITY + 50) as u64);
assert!(
all.first().unwrap().seq < all.last().unwrap().seq,
"oldest first"
);
assert!(all.first().unwrap().seq < all.last().unwrap().seq, "oldest first");
// "everything after the last one I saw" must return nothing new.
let (none, _) = since(latest, 100);

3208
crates/ipx/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

515
crates/ipx/src/metrics.rs Normal file
View File

@@ -0,0 +1,515 @@
//! Prometheus metrics for what logs and traces cannot show: the daemon's own state between
//! requests, such as how many database connections are in use (#194). Served on a port and a
//! thread of their own, with a runtime of their own, and made only of what is already in memory:
//! a scrape neither waits for the web server's threads or the database, whose pool running dry
//! is one of the things it is there to show, nor holds anything up itself. No sign-in, as
//! Prometheus exporters have none: checking a token is a database lookup. Written out by hand:
//! the text format is a few lines, and a client library would be a dependency for them.
use std::fmt::Write as _;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering::Relaxed};
/// What the daemon counts as it works, for /metrics to read without asking anything (#195).
/// Global: the web layer, the worker, downloads, artwork and DeadAir each add to it, and a handle
/// threaded through all of them was more code than the counts.
pub static STATS: Stats = Stats::new();
pub struct Stats {
pub http_in_flight: AtomicI64,
/// Live updates dropped for a page or socket client that fell behind the broadcast.
pub events_missed: AtomicU64,
pub downloads_active: AtomicI64,
pub download_bytes: AtomicU64,
/// Feeds a scan is fetching at this moment, up to its AHEAD.
pub feeds_fetching: AtomicI64,
/// When a scan last finished, Unix seconds; 0 for not since starting.
pub last_scan_done: AtomicI64,
/// Files someone wants shortened that DeadAir has not looked at, as of its last pass.
pub deadair_backlog: AtomicI64,
/// When DeadAir started on the file it is on, Unix seconds; 0 when it is on none.
pub deadair_busy_since: AtomicI64,
pub art_cache_bytes: AtomicU64,
/// Images the sweep threw out to keep under the limit: kept bytes sit at the limit by design,
/// so this is what says whether the limit is too small.
pub art_evicted: AtomicU64,
/// Artwork served from disk, and fetched from the publisher because it was not there.
pub art_hits: AtomicU64,
pub art_misses: AtomicU64,
/// The library as the daemon last counted it, every 30 s; -1 until it has.
pub feeds: AtomicI64,
pub pending: AtomicI64,
pub downloaded: AtomicI64,
/// When the Signals judge last finished a pass, Unix seconds; 0 for not since starting.
pub signals_last_pass: AtomicI64,
/// Calls to Jev by how they ended, in `JEV_RESULTS`' order, and their microseconds all told.
pub jev_calls: [AtomicU64; 5],
pub jev_micros: AtomicU64,
/// Microseconds calls waited for their turn under TypeSafe's limits (#277), part of `jev_micros`.
pub jev_paced_micros: AtomicU64,
/// What each person's Signals, icons and key checks used, and whose key paid (#278): items
/// judged, Signals they fit, and TypeSafe's input and output tokens. Floats: a call shared by
/// several people on the server's key is split between them.
people: std::sync::Mutex<std::collections::BTreeMap<(String, &'static str), [f64; 4]>>,
/// The worker's job now, and since when.
job: std::sync::Mutex<Option<(&'static str, std::time::Instant)>>,
/// Jobs finished, by kind: how many, and their seconds all told.
jobs: std::sync::Mutex<std::collections::BTreeMap<&'static str, (u64, f64)>>,
}
impl Stats {
pub const fn new() -> Self {
Self {
http_in_flight: AtomicI64::new(0),
events_missed: AtomicU64::new(0),
downloads_active: AtomicI64::new(0),
download_bytes: AtomicU64::new(0),
feeds_fetching: AtomicI64::new(0),
last_scan_done: AtomicI64::new(0),
deadair_backlog: AtomicI64::new(0),
deadair_busy_since: AtomicI64::new(0),
art_cache_bytes: AtomicU64::new(0),
art_evicted: AtomicU64::new(0),
art_hits: AtomicU64::new(0),
art_misses: AtomicU64::new(0),
feeds: AtomicI64::new(-1),
pending: AtomicI64::new(-1),
downloaded: AtomicI64::new(-1),
signals_last_pass: AtomicI64::new(0),
jev_calls: [const { AtomicU64::new(0) }; 5],
jev_micros: AtomicU64::new(0),
jev_paced_micros: AtomicU64::new(0),
people: std::sync::Mutex::new(std::collections::BTreeMap::new()),
job: std::sync::Mutex::new(None),
jobs: std::sync::Mutex::new(std::collections::BTreeMap::new()),
}
}
/// Adds to what `who` used on `key` ("server" or "own"): items judged, fits, input and output
/// tokens.
pub fn bill(&self, who: &str, key: &'static str, used: [f64; 4]) {
let mut people = self.people.lock().unwrap();
let row = people.entry((who.to_owned(), key)).or_default();
for (r, u) in row.iter_mut().zip(used) {
*r += u;
}
}
/// Marks the worker as on a job of `kind` until what it returns is dropped, however the job
/// ends: finished, failed or stopped.
pub fn job(&'static self, kind: &'static str) -> Job {
*self.job.lock().unwrap() = Some((kind, std::time::Instant::now()));
Job(self)
}
}
pub struct Job(&'static Stats);
impl Drop for Job {
fn drop(&mut self) {
if let Some((kind, since)) = self.0.job.lock().unwrap().take() {
let mut jobs = self.0.jobs.lock().unwrap();
let e = jobs.entry(kind).or_default();
e.0 += 1;
e.1 += since.elapsed().as_secs_f64();
}
}
}
/// How a call to Jev ended: answered, its key refused (401-403), the request refused as
/// malformed (400, 422), over TypeSafe's rate limits (429), or anything else, TypeSafe's 5xx and
/// timeouts.
pub const JEV_RESULTS: [&str; 5] = ["ok", "key_refused", "malformed", "rate_limited", "error"];
/// Counts one more of something in progress until dropped, however it ends.
pub struct Busy(&'static AtomicI64);
pub fn busy(gauge: &'static AtomicI64) -> Busy {
gauge.fetch_add(1, Relaxed);
Busy(gauge)
}
impl Drop for Busy {
fn drop(&mut self) {
self.0.fetch_sub(1, Relaxed);
}
}
/// What the metrics are read from.
pub struct Sources {
pub ctx: Arc<crate::Ctx>,
pub cmds: tokio::sync::mpsc::Sender<crate::ipc::Command>,
pub events: tokio::sync::broadcast::Sender<crate::ipc::Event>,
pub playing: crate::deadair::Playing,
/// The daemon's runtime, not the metrics thread's own.
pub runtime: tokio::runtime::Handle,
pub started: std::time::Instant,
}
/// Starts serving /metrics on `bind`, on a thread of its own, and says where.
pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
use anyhow::Context as _;
// Bound here, so a port already taken is the daemon's error at start, not a thread's later.
let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding metrics to {bind}"))?;
listener.set_nonblocking(true)?;
let addr = listener.local_addr()?;
let s = Arc::new(s);
std::thread::Builder::new().name("metrics".into()).spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => return tracing::error!(error = %e, "metrics could not start"),
};
rt.block_on(async move {
let app = axum::Router::new()
.route(
"/metrics",
axum::routing::get(move || {
let s = s.clone();
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
}),
)
// Prometheus asks for gzip, every 15 s (#253).
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
match tokio::net::TcpListener::from_std(listener) {
Ok(l) => {
if let Err(e) = axum::serve(l, app).await {
tracing::error!(error = %e, "metrics stopped");
}
}
Err(e) => tracing::error!(error = %e, "metrics could not listen"),
}
});
})?;
Ok(addr)
}
/// Every metric, from memory alone.
pub fn render(s: &Sources) -> String {
let mut out = String::new();
let (open, idle, max) = s.ctx.db.pool();
gauge(
&mut out,
"ipx_db_connections",
"Database connections open, in use or idle.",
&[("state=\"in_use\"", open as f64 - idle as f64), ("state=\"idle\"", idle as f64)],
);
gauge(&mut out, "ipx_db_connections_max", "The most database connections the pool opens.", &[("", max as f64)]);
gauge(
&mut out,
"ipx_worker_queue",
"Commands waiting for the worker: scans, downloads and the like.",
&[("", (s.cmds.max_capacity() - s.cmds.capacity()) as f64)],
);
gauge(
&mut out,
"ipx_event_listeners",
"Open pages and socket clients listening for live updates.",
&[("", s.events.receiver_count() as f64)],
);
gauge(
&mut out,
"ipx_torrent_slots_free",
"Torrents that could start now.",
&[("", s.ctx.torrent_slots.available_permits() as f64)],
);
gauge(
&mut out,
"ipx_deadair_playing",
"Episodes someone is playing through DeadAir's playlist.",
&[("", s.playing.lock().map_or(0, |p| p.len()) as f64)],
);
work(
&mut out,
&STATS,
s.events.len(),
s.ctx.retry.lock().map_or(0, |r| r.len()),
s.ctx.cfg().general.art_cache_mb * 1_048_576,
);
gauge(
&mut out,
"ipx_signals_keys_refused",
"People whose TypeSafe key was refused, whose Signals wait until they save another.",
&[("", s.ctx.signals_refused.lock().map_or(0, |r| r.len()) as f64)],
);
let rt = s.runtime.metrics();
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
gauge(
&mut out,
"ipx_runtime_queue",
"Tasks waiting in the runtime's global queue.",
&[("", rt.global_queue_depth() as f64)],
);
let (rss, fds) = ipx_directory::metrics::process();
if let Some(v) = rss {
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
}
if let Some(v) = fds {
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
}
counter(
&mut out,
"ipx_feed_fetches_total",
"Feeds fetched, answered 304 included.",
&[("", ipx_feed::FETCHES.load(Relaxed) as f64)],
);
counter(
&mut out,
"ipx_feed_bytes_total",
"Bytes of feed bodies fetched.",
&[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)],
);
gauge(
&mut out,
"ipx_uptime_seconds",
"Seconds since the daemon started.",
&[("", s.started.elapsed().as_secs() as f64)],
);
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
out
}
/// The work in progress and done, from `STATS`: jobs, queues and what is in flight (#195).
fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_limit: u64) {
let now = crate::db::now();
let job = *st.job.lock().unwrap();
gauge(out, "ipx_worker_busy", "1 while the worker is on a job.", &[("", job.is_some() as u8 as f64)]);
if let Some((kind, since)) = job {
let label = format!("kind=\"{kind}\"");
gauge(
out,
"ipx_worker_job_seconds",
"How long the worker has been on its job, by the job's kind.",
&[(&label, since.elapsed().as_secs_f64())],
);
}
let jobs = st.jobs.lock().unwrap().clone();
let labels: Vec<String> = jobs.keys().map(|k| format!("kind=\"{k}\"")).collect();
let pairs = |f: fn(&(u64, f64)) -> f64| {
labels.iter().zip(jobs.values()).map(|(l, v)| (l.as_str(), f(v))).collect::<Vec<_>>()
};
counter(out, "ipx_jobs_total", "Jobs the worker has finished, by kind.", &pairs(|v| v.0 as f64));
counter(out, "ipx_job_seconds_total", "Seconds the worker has spent on jobs, by kind.", &pairs(|v| v.1));
let last = st.last_scan_done.load(Relaxed);
if last > 0 {
gauge(out, "ipx_last_scan_age_seconds", "Seconds since a scan last finished.", &[("", (now - last) as f64)]);
}
gauge(
out,
"ipx_http_requests_in_flight",
"Web requests being answered.",
&[("", st.http_in_flight.load(Relaxed) as f64)],
);
gauge(
out,
"ipx_scan_feeds_fetching",
"Feeds a scan is fetching at once.",
&[("", st.feeds_fetching.load(Relaxed) as f64)],
);
gauge(out, "ipx_downloads_active", "Files downloading now.", &[("", st.downloads_active.load(Relaxed) as f64)]);
counter(
out,
"ipx_download_bytes_total",
"Bytes downloaded since the daemon started.",
&[("", st.download_bytes.load(Relaxed) as f64)],
);
gauge(out, "ipx_event_queue", "Live updates sent and not yet read by every listener.", &[("", event_queue as f64)]);
counter(
out,
"ipx_events_missed_total",
"Live updates dropped for a listener that fell behind.",
&[("", st.events_missed.load(Relaxed) as f64)],
);
gauge(
out,
"ipx_deadair_backlog",
"Files of shows someone has DeadAir on that it has not looked at, those already heard through included.",
&[("", st.deadair_backlog.load(Relaxed) as f64)],
);
let since = st.deadair_busy_since.load(Relaxed);
gauge(
out,
"ipx_deadair_busy_seconds",
"How long DeadAir has been on its file; 0 when on none.",
&[("", if since > 0 { (now - since) as f64 } else { 0.0 })],
);
gauge(out, "ipx_feed_retries", "Feeds waiting to try a failed name lookup again.", &[("", retries as f64)]);
gauge(
out,
"ipx_art_cache_bytes",
"Artwork kept on disk, as of the last sweep.",
&[("", st.art_cache_bytes.load(Relaxed) as f64)],
);
gauge(out, "ipx_art_cache_limit_bytes", "The most artwork kept.", &[("", art_limit as f64)]);
counter(
out,
"ipx_art_evicted_total",
"Images thrown out to keep the artwork under its limit.",
&[("", st.art_evicted.load(Relaxed) as f64)],
);
counter(
out,
"ipx_art_requests_total",
"Artwork asked for: served from disk, or fetched from the publisher.",
&[
("result=\"hit\"", st.art_hits.load(Relaxed) as f64),
("result=\"miss\"", st.art_misses.load(Relaxed) as f64),
],
);
// By person and whose key paid (#278). An account's name is often an email address: /metrics
// is on the LAN only, as Prometheus is.
let people = st.people.lock().unwrap().clone();
let labels: Vec<String> =
people.keys().map(|(who, key)| format!("user=\"{}\",key=\"{key}\"", label(who))).collect();
let by = |n: usize| labels.iter().zip(people.values()).map(|(l, v)| (l.as_str(), v[n])).collect::<Vec<_>>();
counter(
out,
"ipx_signals_judged_total",
"Items Jev has judged for someone's Signals, by person and whose key paid.",
&by(0),
);
counter(out, "ipx_signals_hits_total", "Signals an item was found to fit, by person and whose key paid.", &by(1));
let tokens: Vec<String> =
labels.iter().flat_map(|l| [format!("kind=\"input\",{l}"), format!("kind=\"output\",{l}")]).collect();
let used: Vec<(&str, f64)> =
tokens.iter().zip(people.values().flat_map(|v| [v[2], v[3]])).map(|(l, v)| (l.as_str(), v)).collect();
counter(
out,
"ipx_jev_tokens_total",
"Tokens TypeSafe counted for calls to Jev, what they cost, by person and whose key paid.",
&used,
);
let last = st.signals_last_pass.load(Relaxed);
if last > 0 {
gauge(
out,
"ipx_signals_last_pass_age_seconds",
"Seconds since the Signals judge last finished a pass.",
&[("", (now - last) as f64)],
);
}
let labels: Vec<String> = JEV_RESULTS.iter().map(|r| format!("result=\"{r}\"")).collect();
let calls: Vec<(&str, f64)> =
labels.iter().zip(&st.jev_calls).map(|(l, c)| (l.as_str(), c.load(Relaxed) as f64)).collect();
counter(
out,
"ipx_jev_requests_total",
"Calls to TypeSafe's Jev, Signals' and their icons', by how they ended.",
&calls,
);
counter(
out,
"ipx_jev_seconds_total",
"Seconds spent on calls to Jev, their wait for a turn included.",
&[("", st.jev_micros.load(Relaxed) as f64 / 1e6)],
);
counter(
out,
"ipx_jev_paced_seconds_total",
"Seconds calls to Jev waited for their turn under TypeSafe's rate limits.",
&[("", st.jev_paced_micros.load(Relaxed) as f64 / 1e6)],
);
for (name, help, v) in [
("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
("ipx_files_downloaded", "Files on disk, as last counted.", &st.downloaded),
] {
let v = v.load(Relaxed);
if v >= 0 {
gauge(out, name, help, &[("", v as f64)]);
}
}
}
/// A label's value as Prometheus's text format reads it.
fn label(v: &str) -> String {
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
}
/// A counter, which only goes up while the daemon runs, in Prometheus's text format.
pub fn counter(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
write(out, "counter", name, help, samples);
}
/// One gauge in Prometheus's text format: its help, its type, and a line for each set of labels
/// (`state="idle"`, or "" for none).
pub fn gauge(out: &mut String, name: &str, help: &str, samples: &[(&str, f64)]) {
write(out, "gauge", name, help, samples);
}
fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str, f64)]) {
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}");
for (labels, value) in samples {
let labels = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
let _ = writeln!(out, "{name}{labels} {value}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gauges_are_written_as_prometheus_reads_them() {
let mut out = String::new();
gauge(&mut out, "ipx_db_connections", "Connections.", &[("state=\"idle\"", 3.0), ("state=\"in_use\"", 1.0)]);
gauge(&mut out, "ipx_feeds", "Feeds.", &[("", 1535.0)]);
assert_eq!(
out,
"# HELP ipx_db_connections Connections.\n# TYPE ipx_db_connections gauge\n\
ipx_db_connections{state=\"idle\"} 3\nipx_db_connections{state=\"in_use\"} 1\n\
# HELP ipx_feeds Feeds.\n# TYPE ipx_feeds gauge\nipx_feeds 1535\n"
);
}
#[test]
fn jobs_and_work_in_progress_are_counted_however_they_end() {
static T: Stats = Stats::new();
{
let _j = T.job("scan");
let _b = busy(&T.downloads_active);
assert_eq!(T.downloads_active.load(Relaxed), 1);
let mut out = String::new();
work(&mut out, &T, 0, 0, 0);
assert!(
out.contains("ipx_worker_busy 1\n") && out.contains("ipx_worker_job_seconds{kind=\"scan\"}"),
"{out}"
);
}
// Dropped, as an error or a stop drops them: nothing left in progress, one scan done.
assert_eq!(T.downloads_active.load(Relaxed), 0);
let mut out = String::new();
work(&mut out, &T, 3, 1, 10);
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
assert!(
out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"),
"{out}"
);
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
assert!(
out.contains("ipx_jev_requests_total{result=\"key_refused\"} 0\n")
&& !out.contains("ipx_signals_last_pass_age_seconds"),
"{out}"
);
T.bill("sam@example.net", "server", [1.0, 1.0, 600.0, 50.0]);
T.bill("sam@example.net", "server", [1.0, 0.0, 300.5, 25.0]);
T.bill("a \"b\"", "own", [0.0, 0.0, 10.0, 1.0]);
let mut out = String::new();
work(&mut out, &T, 0, 0, 0);
assert!(out.contains("ipx_signals_judged_total{user=\"sam@example.net\",key=\"server\"} 2\n"), "{out}");
assert!(
out.contains("ipx_jev_tokens_total{kind=\"input\",user=\"sam@example.net\",key=\"server\"} 900.5\n"),
"{out}"
);
assert!(out.contains("ipx_jev_tokens_total{kind=\"output\",user=\"a \\\"b\\\"\",key=\"own\"} 1\n"), "{out}");
// The library's counts are left out until the daemon has counted it.
assert!(!out.contains("ipx_feeds "));
}
#[test]
fn the_process_reads_its_memory_and_files_on_linux() {
let (rss, fds) = ipx_directory::metrics::process();
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
}
}

View File

@@ -1,9 +1,10 @@
//! Disk quota and age sweeps. Replaces iPXQuotaManager.py ("SmartSpace").
use anyhow::Result;
use tracing::Instrument;
use crate::config::Config;
use crate::db::{Candidate, Db, now};
use crate::db::{Candidate, Db, now, step};
/// Headroom kept below the quota, as getToQuota()'s `1048576 * 50` did.
pub const PAD_BYTES: u64 = 50 * 1024 * 1024;
@@ -38,18 +39,23 @@ pub fn pick(candidates: &[Candidate], total: u64, limit: u64) -> Vec<Candidate>
/// Files older than the cutoff. Flagged ones are already excluded by the query.
pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
candidates
.iter()
.filter(|c| c.age_key > 0 && c.age_key < cutoff)
.cloned()
.collect()
candidates.iter().filter(|c| c.age_key > 0 && c.age_key < cutoff).cloned().collect()
}
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
let mut report = Report::default();
// Someone may have deleted a file by hand; the row must stop claiming it exists.
for (id, path) in db.missing_files().await? {
// Someone may have deleted a file by hand; the row must stop claiming it exists. Not while
// the folder itself is gone (the array stopped, shfs dropped): every file would look deleted
// and the whole library be forgotten at once (#293).
// Each step is a span of the reap's trace, so a slow one says which part was slow.
let missing = if cfg.general.download_dir.is_dir() {
db.missing_files().instrument(step("missing_files")).await?
} else {
tracing::warn!(dir = %cfg.general.download_dir.display(), "the downloads folder is not there; left every file as it was");
vec![]
};
for (id, path) in missing {
if !dry_run {
db.mark_reaped(id).await?;
}
@@ -57,31 +63,32 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
report.reconciled += 1;
}
let candidates = db.reap_candidates().await?;
for id in db.prune_abandoned_failures().instrument(step("prune_abandoned_failures")).await? {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
}
let candidates = db.reap_candidates().instrument(step("reap_candidates")).await?;
if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
report.aged_out = aged(&candidates, cutoff);
for c in &report.aged_out {
report.bytes_freed += remove(db, c, dry_run).await?;
report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_aged")).await?;
}
if !dry_run {
report.entries_pruned = db.prune_entries(cutoff).await?;
report.entries_pruned = db.prune_entries(cutoff).instrument(step("prune_entries")).await?;
}
}
if cfg.general.max_total_gb > 0.0 {
let limit = (cfg.general.max_total_gb * 1_073_741_824.0) as u64;
// Re-read: the age sweep may already have freed enough.
let remaining: Vec<Candidate> = candidates
.iter()
.filter(|c| !report.aged_out.iter().any(|a| a.id == c.id))
.cloned()
.collect();
let remaining: Vec<Candidate> =
candidates.iter().filter(|c| !report.aged_out.iter().any(|a| a.id == c.id)).cloned().collect();
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
report.over_quota = pick(&remaining, total, limit);
for c in &report.over_quota {
report.bytes_freed += remove(db, c, dry_run).await?;
report.bytes_freed += remove(db, c, dry_run).instrument(step("remove_over_quota")).await?;
}
}
@@ -92,9 +99,10 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
if dry_run {
return Ok(c.bytes.max(0) as u64);
}
let size = std::fs::metadata(&c.path).map(|m| m.len()).unwrap_or(c.bytes.max(0) as u64);
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
let size = std::fs::metadata(&c.path).ok().filter(|m| m.is_file()).map_or(c.bytes.max(0) as u64, |m| m.len());
// Missing is fine: the point is that it is gone.
if let Err(e) = std::fs::remove_file(&c.path)
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(path = c.path, error = %e, "could not delete");
@@ -109,14 +117,7 @@ mod tests {
use super::*;
fn cand(id: i64, bytes: i64, age_key: i64, read: bool) -> Candidate {
Candidate {
id,
url: format!("https://x/{id}"),
path: format!("/tmp/{id}"),
bytes,
age_key,
read,
}
Candidate { id, url: format!("https://x/{id}"), path: format!("/tmp/{id}"), bytes, age_key, read }
}
#[test]
@@ -173,7 +174,8 @@ mod tests {
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
).await
)
.await
.unwrap();
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
@@ -185,11 +187,54 @@ mod tests {
);
}
#[tokio::test]
async fn the_marks_of_someone_who_left_count_for_nothing() {
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(3, 'f', 'heard', true, false),
(1, 'f', 'heard', true, false),
(3, 'f', 'pinned', false, true),
(3, 'f', 'old', false, true);
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
)
.await
.unwrap();
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
}
#[tokio::test]
async fn a_downloads_folder_that_is_gone_forgets_no_file() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'a', 'u1', '/nowhere/ipx-test/a.mp3', 'done');",
)
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = "/nowhere/ipx-test".into();
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 0, "the folder is gone, not the file");
cfg.general.download_dir = std::env::temp_dir();
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
}
#[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f');
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100),
@@ -198,7 +243,8 @@ mod tests {
('f', 'recent', 900);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
).await
)
.await
.unwrap();
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");

680
crates/ipx/src/signals.rs Normal file
View File

@@ -0,0 +1,680 @@
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
//! question for each of their Signals that has not had it yet, and what it says is kept in
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use futures_util::StreamExt as _;
use anyhow::Result;
use tracing::Instrument;
use std::sync::atomic::Ordering::Relaxed;
use crate::Ctx;
use crate::db::{Ask, Unjudged, step};
use crate::feed;
use crate::metrics::STATS;
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
/// long the queue is held before people who got a key later are looked at.
const BATCH: i64 = 50;
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
/// Calls to Jev in flight at once (#275). One at a time, about 150 ms each, a new Signal's month
/// of items took twenty minutes. Enough for `MAX_RATE` at that pace; the pace is `pace`'s to keep.
const IN_FLIGHT: usize = 10;
/// TypeSafe's default limits are 80 requests and 100,000 input tokens a second (#277). Calls are
/// paced under both, with room left for the icon picks and key checks that share them.
const MAX_RATE: f64 = 60.0;
const MAX_TOKENS: f64 = 80_000.0;
/// When the next call may start, for every call ipx makes to Jev.
static NEXT_CALL: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
/// How sure Jev has to be that an item is about the keywords.
const THRESHOLD: f64 = 0.5;
/// Of an item's text, which is a podcast's whole show notes at times. 2000 until #275: on 55 real
/// items and six Signals, 1000 changed none of 330 verdicts and saved an eighth of the input
/// tokens; 500 changed one, an Apple podcast's episode no longer about Apple.
const TEXT_CHARS: usize = 1000;
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
loop {
let done = tokio::select! {
_ = stop.changed() => return,
done = pass(&ctx) => done,
};
crate::metrics::STATS.signals_last_pass.store(crate::db::now(), Relaxed);
match done {
Ok(true) => continue,
Ok(false) => {}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
}
tokio::select! {
_ = stop.changed() => return,
_ = ctx.signals.notified() => {}
_ = tokio::time::sleep(IDLE) => {}
}
}
}
/// The key every Signal is judged on, when an admin has chosen that the server pays for Jev
/// (#271) and the server has a key; None leaves each person to their own.
pub fn server_key(ctx: &Ctx) -> Option<String> {
if !ctx.cfg().general.signals_on_server {
return None;
}
typesafe_key()
}
/// The server's own TypeSafe key, `TYPESAFE_KEY`; set but empty is none, as the browser suite
/// sets it so the test daemon never spends a real one.
pub fn typesafe_key() -> Option<String> {
std::env::var("TYPESAFE_KEY").ok().filter(|k| !k.trim().is_empty())
}
/// One batch. True when there may be more waiting.
async fn pass(ctx: &Ctx) -> Result<bool> {
let server = server_key(ctx);
// People's refused keys are not what is asked with when the server's is.
let skip: Vec<i64> =
if server.is_some() { Vec::new() } else { ctx.signals_refused.lock().unwrap().iter().copied().collect() };
let days = ctx.cfg().general.signal_days;
let items = ctx.db.unjudged(&skip, BATCH, server.is_some(), days).await?;
if items.is_empty() {
return Ok(false);
}
let span = tracing::info_span!(
"signals",
entries = items.len(),
calls = tracing::field::Empty,
judged = tracing::field::Empty,
hits = tracing::field::Empty
);
async {
let groups = calls(&items);
let paid = if server.is_some() { "server" } else { "own" };
tracing::Span::current().record("calls", groups.len());
let refused = std::sync::Mutex::new(HashSet::new());
let stopped = AtomicBool::new(false);
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
let mut judged = 0;
// Each call starts only when a place is free, so one that sees a key refused or the pass
// stopped is not sent; those already in flight are let finish, and their answers kept.
// By index: a closure taking a reference here is not Send for every lifetime, which
// tokio::spawn asks of the judge's future.
let mut answers = futures_util::stream::iter(0..groups.len())
.map(|n| {
let (group, refused, stopped, server) = (&groups[n], &refused, &stopped, &server);
async move {
let first = group[0];
if stopped.load(Relaxed) || refused.lock().unwrap().contains(&first.user_id) {
return None;
}
let key = server.as_deref().or(first.key.as_deref())?;
Some((group, judge(ctx, group, key, paid).await))
}
})
.buffer_unordered(IN_FLIGHT);
while let Some(answer) = answers.next().await {
let Some((group, answer)) = answer else { continue };
let first = group[0];
match answer {
Ok(verdicts) => {
for (item, verdicts) in group.iter().zip(verdicts) {
judged += 1;
let hits = verdicts.iter().filter(|(_, h)| *h).count();
STATS.bill(&item.user_name, paid, [1.0, hits as f64, 0.0, 0.0]);
if hits > 0 {
*hits_for.entry(item.user_id).or_default() += hits;
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
}
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).instrument(step("record_verdicts")).await?;
}
}
// The server's key refused is everyone's: the queue waits for the next pass.
Err(e) if refuses_key(&e) && server.is_some() => {
if !stopped.swap(true, Relaxed) {
tracing::warn!(error = %format!("{e:#}"), "TypeSafe refused the server's key for Signals");
}
}
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
Err(e) if refuses_key(&e) => {
if refused.lock().unwrap().insert(first.user_id) {
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
}
}
// TypeSafe says the request itself is wrong, so every other item's would be too:
// the rest wait for the next pass rather than each failing the same way (#257).
Err(e) if malformed(&e) => {
if !stopped.swap(true, Relaxed) {
tracing::warn!(user = first.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
}
}
// Over TypeSafe's limits: what is in flight finishes, nothing more starts, and
// the next pass waits for `back_off`.
Err(e) if rate_limited(&e) => {
if !stopped.swap(true, Relaxed) {
tracing::warn!(error = %format!("{e:#}"), "TypeSafe asked Signals to slow down");
}
}
// Left unjudged, so it is asked again next pass.
Err(e) => tracing::warn!(user = first.user_id, feed = %first.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
}
}
drop(answers);
let span = tracing::Span::current();
span.record("judged", judged);
span.record("hits", hits_for.values().sum::<usize>());
// Once a pass, not once an item: a page fetches its counts again on each.
for (user, hits) in hits_for {
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
}
let refused = refused.into_inner().unwrap();
let some_refused = !refused.is_empty();
ctx.signals_refused.lock().unwrap().extend(refused);
// A pass that only failed would otherwise go straight round again on the same items, and
// one TypeSafe refused as malformed would ask again at once, however full.
anyhow::Ok(!stopped.into_inner() && (full(&items) || some_refused))
}
.instrument(span)
.await
}
/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
/// then one person's description was in the question that decided another's verdicts (#343).
fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
items.iter().map(|item| vec![item]).collect()
}
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
/// alone said only 422 (#257).
#[derive(Debug)]
struct Refused {
status: u16,
body: String,
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
}
}
impl std::error::Error for Refused {}
fn status(e: &anyhow::Error) -> Option<u16> {
e.downcast_ref::<Refused>().map(|r| r.status)
}
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
fn full(items: &[Unjudged]) -> bool {
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
}
pub fn refuses_key(e: &anyhow::Error) -> bool {
matches!(status(e), Some(401..=403))
}
fn rate_limited(e: &anyhow::Error) -> bool {
status(e) == Some(429)
}
/// A call's turn: it starts at `next` or now, whichever is later, and holds the line for its
/// share of a second under both limits. Gives when it starts and when the one after may.
fn slot(
next: tokio::time::Instant,
now: tokio::time::Instant,
tokens: f64,
) -> (tokio::time::Instant, tokio::time::Instant) {
let start = next.max(now);
(start, start + std::time::Duration::from_secs_f64((1.0 / MAX_RATE).max(tokens / MAX_TOKENS)))
}
/// Waits for this call's turn. Its tokens are guessed from the request, a token to three bytes
/// of JSON, on the high side: TypeSafe says the true count only in the answer.
async fn pace(body: &serde_json::Value) {
let tokens = body.to_string().len() as f64 / 3.0;
let start = {
let mut next = NEXT_CALL.lock().await;
let now = tokio::time::Instant::now();
let (start, after) = slot(next.unwrap_or(now), now, tokens);
*next = Some(after);
start
};
let waited = start.saturating_duration_since(tokio::time::Instant::now());
tokio::time::sleep_until(start).await;
// How long the limits held this call back: near nothing unless ipx is near them (#277).
STATS.jev_paced_micros.fetch_add(waited.as_micros() as u64, Relaxed);
tracing::Span::current().record("paced_ms", waited.as_millis() as u64);
}
/// TypeSafe said too many: nobody calls again until it says, or a second.
async fn back_off(retry_after: Option<u64>) {
let until = tokio::time::Instant::now() + std::time::Duration::from_secs(retry_after.unwrap_or(1).min(60));
let mut next = NEXT_CALL.lock().await;
*next = Some(next.map_or(until, |n| n.max(until)));
}
fn malformed(e: &anyhow::Error) -> bool {
matches!(status(e), Some(400 | 422))
}
/// One call about one item, for everyone in `group`: their verdicts, in the group's order.
async fn judge(ctx: &Ctx, group: &[&Unjudged], key: &str, paid: &'static str) -> Result<Vec<Vec<(i64, bool)>>> {
let asks: Vec<&Ask> = group.iter().flat_map(|i| &i.signals).collect();
let span = tracing::info_span!(
"jev",
user = group[0].user_id,
people = group.len(),
signal_count = asks.len(),
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
paced_ms = tracing::field::Empty,
);
let answer = called(span, ask(ctx, key, &request(group[0], &asks))).await?;
// A shared call is billed by each person's share of its questions, which are most of it.
let [input, output] = usage(&answer);
for item in group {
let share = item.signals.len() as f64 / asks.len() as f64;
STATS.bill(&item.user_name, paid, [0.0, 0.0, input * share, output * share]);
}
// A question Jev left unanswered stays unjudged and is asked again.
Ok(group.iter().map(|i| i.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect()).collect())
}
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
fn request(item: &Unjudged, asks: &[&Ask]) -> serde_json::Value {
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
let state = format!(
"Feed: {}\nTitle: {}\n\n{text}",
item.feed_title.as_deref().unwrap_or(""),
item.title.as_deref().unwrap_or("")
);
let questions: serde_json::Map<String, serde_json::Value> = asks.iter().map(|a| (key(a.id), question(a))).collect();
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
}
fn key(id: i64) -> String {
format!("signal_{id}")
}
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
/// whether an item was about any one of its words, AI Development (ai, development, programming)
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
/// mean: a sentence on its own was refused with a 422 (#257).
fn question(a: &Ask) -> serde_json::Value {
let mut said = format!(
"The reader follows a subject they call \"{}\", described by these words: {}.",
a.name,
a.keywords.join(", ")
);
if let Some(d) = &a.description {
said.push_str(&format!(" In their own words: {d}"));
}
serde_json::json!({
"type": "noul",
"instructions": format!("{said} Is this item about that subject?"),
"criteria": {
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
"false": "It is about something else, or touches the subject only in passing.",
},
})
}
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
}
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
/// server's page doing it, once a Signal.
pub async fn pick_icon(
ctx: &Ctx,
who: &str,
key: &str,
name: &str,
words: &[String],
description: Option<&str>,
candidates: &[(String, String)],
) -> Result<String> {
let span = tracing::info_span!(
"jev_icon",
candidates = candidates.len(),
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
paced_ms = tracing::field::Empty,
);
called(span, async {
let answer = ask(ctx, key, &icon_request(name, words, description, candidates)).await?;
let [input, output] = usage(&answer);
STATS.bill(who, "server", [0.0, 0.0, input, output]);
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
})
.await
}
/// Whether TypeSafe takes `key`, by one short question, before it is saved (#271): a key it
/// refuses would sit saved with nothing ever picked out, and nobody told why.
pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
let span = tracing::info_span!(
"jev_key_check",
"http.response.status_code" = tracing::field::Empty,
error = tracing::field::Empty,
"otel.status_code" = tracing::field::Empty,
input_tokens = tracing::field::Empty,
output_tokens = tracing::field::Empty,
paced_ms = tracing::field::Empty,
);
let body = serde_json::json!({
"model": "jev-latest",
"state": "A reader is saving their key.",
"questions": { "ok": { "type": "noul", "instructions": "Is this a key check?",
"criteria": { "true": "It is.", "false": "It is not." } } },
});
let answer = called(span, ask(ctx, key, &body)).await?;
let [input, output] = usage(&answer);
STATS.bill(who, "own", [0.0, 0.0, input, output]);
Ok(())
}
/// Puts `body` to Jev on `key` and gives its answer, or what TypeSafe refused it with. Carries
/// this trace's `traceparent`, as every call out of ipx does.
async fn ask(ctx: &Ctx, key: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
let mut req = ctx.client.post(url).bearer_auth(key).json(body).timeout(feed::FEED_TIMEOUT);
if let Some(tp) = ipx_directory::otel::traceparent() {
req = req.header("traceparent", tp);
}
pace(body).await;
let r = req.send().await?;
let status = r.status().as_u16();
tracing::Span::current().record("http.response.status_code", status);
if status == 429 {
back_off(r.headers().get("retry-after").and_then(|v| v.to_str().ok()?.parse().ok())).await;
}
if !r.status().is_success() {
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
return Err(Refused { status, body }.into());
}
let answer: serde_json::Value = r.json().await?;
// What a call cost, which TypeSafe says and nothing else would (#275); billed by the caller,
// who knows whose it was (#278).
for kind in ["input_tokens", "output_tokens"] {
if let Some(t) = answer["usage"][kind].as_u64() {
tracing::Span::current().record(kind, t);
}
}
Ok(answer)
}
/// Input and output tokens, as an answer counts them; 0 when it does not.
fn usage(answer: &serde_json::Value) -> [f64; 2] {
["input_tokens", "output_tokens"].map(|k| answer["usage"][k].as_f64().unwrap_or(0.0))
}
/// Runs a call to Jev in `span`, counting it in /metrics by how it ended, and marking the span
/// failed, which a field named `error` alone does not: Tempo's failed traces left Jev's out.
async fn called<T>(span: tracing::Span, call: impl std::future::Future<Output = Result<T>>) -> Result<T> {
let started = std::time::Instant::now();
let r = call.instrument(span.clone()).await;
STATS.jev_micros.fetch_add(started.elapsed().as_micros() as u64, Relaxed);
STATS.jev_calls[outcome(&r)].fetch_add(1, Relaxed);
if let Err(e) = &r {
span.record("error", format!("{e:#}").as_str());
span.record("otel.status_code", "ERROR");
}
r
}
/// Which of `metrics::JEV_RESULTS` a call's result is.
fn outcome<T>(r: &Result<T>) -> usize {
match r {
Ok(_) => 0,
Err(e) if refuses_key(e) => 1,
Err(e) if malformed(e) => 2,
Err(e) if rate_limited(e) => 3,
Err(_) => 4,
}
}
fn icon_request(
name: &str,
words: &[String],
description: Option<&str>,
candidates: &[(String, String)],
) -> serde_json::Value {
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
if let Some(d) = description {
state.push_str(&format!("\nIn their words: {d}"));
}
let criteria: serde_json::Map<String, serde_json::Value> =
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
serde_json::json!({
"model": "jev-latest",
"state": state,
"questions": { "icon": {
"type": "choice",
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
"criteria": criteria,
}},
})
}
/// Jev's choice, only if it is one it was offered.
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
let c = answer["answers"]["icon"]["choice"].as_str()?;
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
}
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut tag: Option<String> = None;
for c in html.chars() {
match (&mut tag, c) {
(None, '<') => tag = Some(String::new()),
(Some(t), '>') => {
let mut said: Vec<&str> = Vec::new();
for name in ["alt", "title"] {
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
said.push(v);
}
}
for v in said {
out.push(' ');
out.push_str(v);
}
out.push(' ');
tag = None;
}
(Some(t), c) => t.push(c),
(None, c) => out.push(c),
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
for q in ['"', '\''] {
let key = format!(" {name}={q}");
if let Some(at) = lower.find(&key) {
let rest = &tag[at + key.len()..];
return rest.find(q).map(|end| &rest[..end]);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_noul_of_a_half_or_more_is_a_fit() {
// As TypeSafe answered on 2026-10-16.
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
assert_eq!(hit(&answer, 3), Some(true));
assert_eq!(hit(&answer, 4), Some(false));
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
}
#[test]
fn an_icon_is_one_jev_was_offered() {
let offered = vec![
("solid/robot".to_string(), "Robot: ai, bot".to_string()),
("solid/laptop-code".to_string(), "Laptop Code".to_string()),
];
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
assert_eq!(r["questions"]["icon"]["type"], "choice");
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
// As TypeSafe answered on 2026-10-16.
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
}
#[test]
fn an_images_words_are_read_with_the_text() {
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
}
#[test]
fn a_call_is_counted_by_how_it_ended() {
let refused = |status| -> Result<()> { Err(Refused { status, body: String::new() }.into()) };
let named = |r: Result<()>| crate::metrics::JEV_RESULTS[outcome(&r)];
assert_eq!(named(Ok(())), "ok");
assert_eq!(named(refused(402)), "key_refused");
assert_eq!(named(refused(422)), "malformed");
assert_eq!(named(refused(503)), "error");
assert_eq!(named(refused(429)), "rate_limited");
assert_eq!(named(Err(anyhow::anyhow!("timed out"))), "error");
}
#[test]
fn one_persons_signals_never_share_a_call_with_anothers() {
let row = |user_id, guid: &str| Unjudged {
user_id,
user_name: format!("u{user_id}"),
key: None,
feed_id: "f".into(),
guid: guid.into(),
feed_title: None,
title: None,
description: None,
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
};
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)");
let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect();
let r = request(&items[0], &mine);
assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
}
#[test]
fn calls_are_paced_under_both_of_typesafes_limits() {
let t0 = tokio::time::Instant::now();
let ms = |a: tokio::time::Instant| (a - t0).as_secs_f64() * 1000.0;
// A small call holds the line for a 60th of a second, under 80 requests a second.
let (start, next) = slot(t0, t0, 1000.0);
assert_eq!((ms(start), ms(next).round()), (0.0, 17.0));
// One of 8,000 tokens, a tenth of a second, under 100,000 tokens a second.
let (start, next) = slot(next, t0, 8000.0);
assert_eq!((ms(start).round(), ms(next).round()), (17.0, 117.0));
// After a quiet spell, a call starts at once.
let later = t0 + std::time::Duration::from_secs(5);
assert_eq!(slot(next, later, 1000.0).0, later);
}
#[test]
fn a_batch_is_full_by_its_rows_not_its_items() {
let item = |n: usize| Unjudged {
user_id: 1,
user_name: "a".into(),
key: Some("k".into()),
feed_id: "f".into(),
guid: "g".into(),
feed_title: None,
title: None,
description: None,
signals: (0..n as i64)
.map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None })
.collect(),
};
assert!(
full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()),
"25 items of two Signals each is the 50 asked for"
);
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
}
#[test]
fn each_signal_is_its_own_question_on_the_items_words() {
let item = Unjudged {
user_id: 1,
user_name: "a".into(),
key: Some("k".into()),
feed_id: "f".into(),
guid: "g".into(),
feed_title: Some("Pit Lane".into()),
title: Some("Monaco".into()),
description: Some("<p>Qualifying <b>report</b></p>".into()),
signals: vec![
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
Ask {
id: 4,
name: "Food".into(),
keywords: vec!["cooking".into()],
description: Some("Recipes, not restaurants.".into()),
},
],
};
let r = request(&item, &item.signals.iter().collect::<Vec<_>>());
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
let q = &r["questions"]["signal_3"];
assert_eq!(q["type"], "noul");
let says = q["instructions"].as_str().unwrap();
assert!(
says.contains("\"F1\"") && says.contains("formula 1, f1"),
"one subject, by its name and its words: {says}"
);
assert!(!says.contains("own words"), "no sentence, none said");
assert!(
q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(),
"TypeSafe takes an object, not a sentence"
);
assert!(
r["questions"]["signal_4"]["instructions"]
.as_str()
.unwrap()
.contains("In their own words: Recipes, not restaurants.")
);
}
}

View File

@@ -26,10 +26,7 @@ impl Torrents {
let listen_addr: SocketAddr = format!("0.0.0.0:{lo}").parse()?;
let opts = SessionOptions {
listen: Some(librqbit::ListenerOptions {
listen_addr,
..Default::default()
}),
listen: Some(librqbit::ListenerOptions { listen_addr, ..Default::default() }),
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
..Default::default()
};
@@ -42,13 +39,15 @@ impl Torrents {
}
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`,
/// seeds it up to the configured limit, then stops.
/// seeds it up to the configured limit, then stops. `dest_dir` is the torrent's own
/// folder (`own_folder`), and it is the path returned: a file of several or one.
///
/// Downloading in place rather than into a staging dir is deliberate: seeding serves
/// the files it downloaded, so moving them first would break it.
pub async fn fetch(
&self,
cfg: &Config,
client: &reqwest::Client,
url: &str,
dest_dir: &Path,
mut on_progress: impl FnMut(u64, u64),
@@ -56,6 +55,15 @@ impl Torrents {
tokio::fs::create_dir_all(dest_dir).await?;
let limit = stall_limit(cfg);
let source = if url.starts_with("http://") || url.starts_with("https://") {
// Under the stall budget as well: a body sent a byte a minute stays inside the cap.
let file = tokio::time::timeout(limit, torrent_file(client, url))
.await
.map_err(|_| anyhow::anyhow!("no .torrent after {} minutes, gave up", limit.as_secs() / 60))??;
AddTorrent::from_bytes(file)
} else {
AddTorrent::from_url(url)
};
// Resolving a magnet's metadata happens inside add_torrent, and against a dead
// swarm it never returns. The stall budget has to cover this phase too, or a
@@ -63,7 +71,7 @@ impl Torrents {
let added = tokio::time::timeout(
limit,
self.session.add_torrent(
AddTorrent::from_url(url),
source,
Some(AddTorrentOptions {
output_folder: Some(dest_dir.to_string_lossy().into_owned()),
overwrite: true,
@@ -72,12 +80,7 @@ impl Torrents {
),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"no metadata after {} minutes, gave up",
limit.as_secs() / 60
)
})?
.map_err(|_| anyhow::anyhow!("no metadata after {} minutes, gave up", limit.as_secs() / 60))?
.context("adding the torrent")?;
let (id, handle) = match added {
@@ -111,15 +114,14 @@ impl Torrents {
}
}
let name = handle.name().unwrap_or_else(|| "torrent".to_owned());
let path = handle.output_folder().join(&name);
// The torrent's own name is the publisher's to choose, and librqbit checks it only when it
// picks the folder itself: joined on, "/config" or ".." pointed Delete, the reaper and
// /media at anything the daemon can reach (#318). The folder is ours, so it is the path.
let path = dest_dir.to_owned();
let size = handle.stats().total_bytes;
self.seed(cfg, &handle).await;
self.session
.delete(id.into(), false)
.await
.context("releasing the torrent")?;
self.session.delete(id.into(), false).await.context("releasing the torrent")?;
Ok((path, size))
}
@@ -145,6 +147,30 @@ impl Torrents {
}
}
/// A .torrent file is metadata: a few hundred KB even for a large torrent.
const MAX_TORRENT_FILE: usize = 10 << 20;
/// Fetches a .torrent file, up to `MAX_TORRENT_FILE`. Handed the URL, librqbit read the whole body
/// into memory with no limit and no timeout, so a feed could serve one without end (#330).
async fn torrent_file(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
let mut resp = client.get(url).send().await.context("fetching the .torrent")?.error_for_status()?;
let mut out = Vec::new();
while let Some(c) = resp.chunk().await.context("reading the .torrent")? {
out.extend_from_slice(&c);
if out.len() > MAX_TORRENT_FILE {
bail!("the .torrent is larger than {} MB, too large to be one", MAX_TORRENT_FILE >> 20);
}
}
Ok(out)
}
/// The folder an enclosure's torrent downloads into, one per enclosure inside the feed's folder.
/// With `overwrite` on, a torrent written straight into the feed's folder could replace another
/// enclosure's files of the same name, another feed's too when they share a folder (#331).
pub fn own_folder(feed_dir: &Path, enclosure: i64) -> PathBuf {
feed_dir.join(format!("torrent-{enclosure}"))
}
/// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
pub fn ratio(uploaded: u64, total: u64) -> f64 {
if total == 0 {
@@ -165,6 +191,32 @@ mod tests {
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever");
}
#[tokio::test]
async fn an_endless_torrent_file_stops_at_the_cap() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else { return };
let _ = sock.read(&mut [0u8; 2048]).await;
// No length, no end: a body that goes on until the reader stops.
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n").await;
let chunk = vec![b'd'; 64 << 10];
while sock.write_all(&chunk).await.is_ok() {}
});
let err = torrent_file(&reqwest::Client::new(), &format!("http://{addr}/ep.torrent")).await.unwrap_err();
assert!(err.to_string().contains("too large"), "{err:#}");
}
#[test]
fn own_folder_is_one_folder_inside_the_feeds() {
let feed = Path::new("/downloads/Show");
let dir = own_folder(feed, 42);
assert_eq!(dir.parent(), Some(feed));
assert!(matches!(dir.components().next_back(), Some(std::path::Component::Normal(_))));
assert_ne!(own_folder(feed, 43), dir, "two enclosures never share a folder");
}
#[test]
fn stall_limit_is_never_zero() {
let mut cfg = Config::default();

2871
crates/ipx/src/web.rs Normal file

File diff suppressed because it is too large Load Diff

34
deny.toml Normal file
View File

@@ -0,0 +1,34 @@
# cargo deny check: known vulnerabilities, licences and where crates come from. Run before a push
# (CLAUDE.md, Tests). It reads Cargo.lock and the RustSec database, and builds nothing.
[graph]
# Production runs on Linux; Windows and macOS-only crates are not ipx's concern.
targets = ["x86_64-unknown-linux-gnu"]
[advisories]
ignore = [
# ringbuf 0.4's double free needs an element whose Drop panics; librqbit-utp's buffer holds
# only u8, which has no Drop. Its fix is ringbuf 0.5, which librqbit 9.0.1 cannot take (#281).
{ id = "RUSTSEC-2026-0293", reason = "librqbit-utp's ring buffer holds u8: no Drop to panic" },
]
[licenses]
# Permissive licences only; anything else is a question for the owner before it comes in.
allow = [
"MIT", "MIT-0", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause",
"ISC", "Zlib", "Unlicense", "0BSD", "CC0-1.0", "BSL-1.0", "Unicode-3.0", "MPL-2.0",
"CDLA-Permissive-2.0",
]
# ipx's own crates have no licence field and are not published.
private = { ignore = true }
[bans]
# Two versions of one crate (syn 1 and 2, and 25 more) only cost build time, and a warning for
# each on every run would be read by nobody.
multiple-versions = "allow"
wildcards = "deny"
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"

View File

@@ -1,27 +1,44 @@
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both
# images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then
# `docker compose up -d`, and sign in at http://<host>:8099 as admin, with the password the first
# start writes to the config volume: `docker exec ipx cat /config/admin-password`.
# docs/configuration.md has the rest, Postgres included.
services:
ipodderx:
image: 192.168.1.130:5000/ipodderx:latest
container_name: iPodderX
ipx:
image: ipx
container_name: ipx
restart: unless-stopped
environment:
# Who owns the files it writes. 99:100 is Unraid's nobody:users.
PUID: "99"
PGID: "100"
TZ: "America/Toronto"
IPX_LOG: "ipx=info"
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
env_file:
- ipodderx.env # relative: Arcane resolves it inside its own container
TZ: "UTC"
# For Postgres instead of SQLite:
# IPX_DATABASE_URL: "postgres://ipx:password@db-host:5432/ipx"
# No web port of its own: ipx-web is the way in, and passes the API on to it.
ports:
- "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers
- "6881:6881/udp" # DHT
volumes:
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
- /mnt/user/ipodderx/:/data # state.db
- /mnt/user/ipodderx/downloads:/downloads
healthcheck:
test: ["CMD", "ipx", "status"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
- ./config:/config # config.toml, written on first start
- ./data:/data # state.db
- ./downloads:/downloads
ipx-web:
image: ipx-web
container_name: ipx-web
restart: unless-stopped
environment:
IPX_UPSTREAM: "ipx:8099"
# Behind Cloudflare Access only: where cloudflared's requests come from, the one address
# whose Cf-Access-* headers are passed on. docs/sso.md says how to find it.
# TUNNEL_PEER: "192.168.64.1"
# Traces to an OpenTelemetry collector, a span for each request, continued into ipx:
# WEB_TRACING: "on"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector:4318"
# OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
# OTEL_SERVICE_NAME: "ipx-web"
ports:
- "8099:8080" # the web UI
depends_on:
- ipx

View File

@@ -1,44 +1,77 @@
# How it works
One binary, `ipx`. `ipx daemon` runs three things in one process: a scheduler, a Unix-socket
control server, and the web UI. Everything else is a CLI that either does the work itself or hands
it to a running daemon.
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 |
|---|---|---|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
| `src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
| `src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `crates/ipx/src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
| `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` | Conditional GET, RSS/Atom/OPML parsing, `url_looks_private` | `FeedData.__getFeed/__getEntries` |
| `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 HTTP API, its token check, the refresher | — |
| `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.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
| `web/build.mjs` | 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.js` | 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 compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
build needs node and `npm ci` run once.
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).
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
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. A changed title or description flips the item back to unread.
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
@@ -51,8 +84,9 @@ build needs node and `npm ci` run once.
## Data model
```
feeds id, url, title, image, etag, last_modified, last_checked, ttl_mins,
last_error, orphaned, group_id, managed
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,
@@ -63,15 +97,25 @@ 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 `src/entity.rs`, and `Db::open` creates whatever
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`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
`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.
@@ -89,13 +133,15 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
```
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
(`enclosure`), `status`.
(`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`: the socket answers it
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
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
@@ -110,12 +156,13 @@ 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`.
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 /` | the app |
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
| `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) |
@@ -126,8 +173,12 @@ else a `401`.
| `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/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
| `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 |
@@ -141,7 +192,9 @@ before they reach the page.
```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
node tests/native-bridge.js # 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.js # 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

View File

@@ -13,12 +13,16 @@ processes must never download the same thing. `--local` forces the work to happe
| `ipx list` | Subscriptions and their state |
| `ipx status` | Counts: feeds, pending, downloaded |
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
| `ipx add <url> [--folder X] [--keywords a,b]` | Subscribe; the id comes from the feed title |
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` subscribes no one but keeps it in the catalogue, and so in what this server shares with the directory, when its last subscriber leaves; `--category` is the category the directory files it under when the feed names none. Run for a feed already in the catalogue, these list it or set its category |
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
| `ipx reap [--dry-run]` | Run retention now |
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) |
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
its next change, undoing them. If it does not answer they say so: restart it.
## Accounts
@@ -27,13 +31,14 @@ Passwords are read from **stdin**, so they miss the shell history and any `ps` l
```sh
echo -n 'a good password' | ipx user add ray # local account
ipx user add ray@example.com --no-password # signs in through the proxy only
echo -n 'a good password' | ipx user passwd admin # change a password
echo -n 'a good password' | ipx user passwd admin # change a password; signs the account out everywhere
ipx user list # who exists, and how each signs in
ipx user rm sam # account, subscriptions and read state
```
The first account created is an admin; later ones are ordinary users. A database with no accounts
at all gets **admin / ipodderx** on the next daemon start, announced in the log — change it.
at all gets **admin** on the next daemon start, with a random password in `admin-password` beside
config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it.
To avoid even the command line, read it interactively:
@@ -67,7 +72,7 @@ stops the next scan fetching it again.
## The daemon
```sh
ipx daemon # scheduler + socket + web UI
ipx daemon # scheduler + socket + HTTP API
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
```
@@ -80,7 +85,9 @@ To kill it, match the binary exactly:
pkill -x ipx
```
`pkill -f ipx` matches the shell running the command too, and kills your own session.
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
container's processes: stop the one you started by its PID instead (`kill <pid>`).
## Talking to it directly

View File

@@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database,
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
@@ -25,8 +25,13 @@ config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
ipx does not wait for the disk to confirm each write: SQLite syncs at checkpoints
(`synchronous=NORMAL`), and ipx's own Postgres sessions set `synchronous_commit=off`, which leaves
other databases on the server as they are. Waiting made a scan take most of a second a feed. A
crash of ipx loses nothing; a power cut, or a crash of the Postgres server, can lose the last moment
of changes, a read mark or a saved position, and never corrupts the database.
## `[general]`
@@ -40,25 +45,45 @@ max_total_gb = 50 # 0 = unlimited
max_age_days = 30 # 0 = keep forever
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"]
art_cache_mb = 500 # artwork kept on disk. 0 = none
fetch_internal = [] # internal addresses fetches may reach, e.g. ["192.168.1.0/24"]
```
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
database as described above; `download_dir`, `socket`, `organize` and `fetch_internal` stay in config.toml.
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
modified") is checked less often the longer it stays the same, up to every four hours, and is
back on schedule once it changes. A feed that keeps failing
waits as long as it has been failing before the next try, up to a day, and is back on schedule
after its first success. A moment's failure to look its host up does not count: the network, not
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
read it. `0` disables it entirely.
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
Kept ones stay. `0` disables it.
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
* **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
database, so the page loads it from iPX rather than from every publisher's server. Over this,
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
each time. 500 by default.
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
fetched 216 files and 22 GB in one scan.
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
without this the disk fills with artwork. Empty takes everything.
* **`fetch_internal`** — internal addresses and networks that feeds, episodes, artwork and
`.torrent` files may still be fetched from, as `["192.168.1.20", "10.0.0.0/8"]`. Anything off
the public internet (loopback, the LAN, Docker's networks, link-local) is refused otherwise,
redirects included, because a feed's publisher or anyone signed in chooses what is fetched. List
addresses, not names: a feed on `nas.lan` needs `nas.lan`'s address here. The directory is not
affected. Empty by default; read at start.
## `[torrent]`
@@ -83,9 +108,12 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
token = "" # generated and saved on first run
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"]
access_team = "" # e.g. "<team>.cloudflareaccess.com"
access_aud = "" # the Access application's AUD tag
auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30
metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no metrics
```
* **`token`** — the shared secret, which signs in as the **admin**. `?token=…` sets a cookie, so
@@ -94,11 +122,17 @@ session_days = 30
disables that path. See [sso.md](sso.md).
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
for it. Name the proxy, never a subnet.
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
the proxy signs them straight back in.
* **`session_days`** — sign a session out after this long without a request.
* **`metrics_bind`** — where to serve `/metrics` for Prometheus: database connections in use, the
worker's queue, the runtime's tasks, memory and open files. It has a thread and a port of its own
and no sign-in, so publish it to the LAN only. Empty, the default, serves none.
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
itself carry a credential. Put TLS in front of it if that matters.
@@ -116,6 +150,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
schedule = "every 6h" # overrides [general] for this feed
media_types = ["audio"] # overrides [general] for this feed
category = "Technology" # the Directory's, if the feed names none
listed = true # shared with the directory with no subscribers
username = "ray" # HTTP basic auth
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
```
@@ -125,17 +160,53 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md).
`username` and its password go to the feed's own host alone: not to episodes on another host, nor to
the feeds an OPML lists on other hosts, which would hand the password to whoever runs them.
**The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
up in Admin, Directory: its address, this server's token and whether to share. They are server
settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
With sharing on, this server sends it every feed in the catalogue that is not private, with how
many people here subscribe, and the `category` above for a feed that names none. To offer one with
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed
stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another
is dropped. A feed nobody
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So
does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.
## Environment
| Variable | Effect |
|---|---|
| `IPX_CONFIG` | Config file path |
| `IPX_ADMIN_PASSWORD` | The first admin's password, for a database with no accounts; random, in `admin-password` beside config.toml, without it |
| `IPX_DATA_DIR` | Directory holding `state.db` |
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
## ipx-web
The page's container reads only its environment (`web/Caddyfile`):
| Variable | Effect |
|---|---|
| `IPX_UPSTREAM` | Where ipx is, `host:port`; `ipx:8099` by default |
| `WEB_BIND` | Where to listen; `:8080` by default |
| `WEB_ROOT` | The built page; `/srv` in the image |
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
| `TRUSTED_HEADER` | ipx's `[web] trusted_header`, when it is not Cloudflare's: it is passed on from `TUNNEL_PEER` alone too, and dropped from anyone else. Set it to the same name, or anyone could sign in as anyone |
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
ipx-directory's settings are in [directory.md](directory.md).

169
docs/directory.md Normal file
View File

@@ -0,0 +1,169 @@
# The directory
`ipx-directory` is a catalogue of public podcasts and blogs that ipx servers share (#204). An ipx
server sends it the public feeds its people subscribe to, and how many of them do. The directory
fetches each feed's title, artwork, category, description and latest twenty items every twelve hours,
and any server holding a token can browse it. It never downloads a file, and it never stores
one's address: every ipx server still fetches its own feeds.
This server's is at `https://directory.ipxreader.app` (until 2026-10-12 `ipx-dir.sdf1.net`, #214), the `ipx-directory` service of the Arcane project
`ipx`, with its database `ipx_directory` on the `databases` project's Postgres.
## In ipx
An admin sets it up in **Admin, Directory**: the directory's address, the token its operator gave
this server, and whether to share. ipx asks the directory on its people's behalf, so the page never
talks to it and never sees the token or a feed's address. With no directory set up, the Directory
says so and feeds are added by their address.
ipx keeps the address and token with its other server settings, in its database (`settings`,
`directory_url` and `directory_token`), not in config.toml. The token is written, never shown
again: the tab says it is set and offers to clear it. To replace one, make a new token on the
directory, paste it over the old, then delete the old token's entry from the directory's config.
With sharing on, ipx sends its snapshot when what it would send has changed (checked after every
scan, and whenever the catalogue or these settings change), and every half hour regardless (#211): each feed checked at least once, with how many people here subscribe,
and the category an admin gave a feed that names none. It never sends an OPML or a Patreon creator
itself (their feeds go instead), or a private feed. A feed's settings have a **Private** switch for
a paid feed iPX does not recognise by its address; anyone subscribed may turn it on, and only an
admin off.
When this server's token is `rwa`, its admins administer the directory from iPX (#206). A feed's
page in the Directory has **Check now** and **Remove from the directory**, which blocks it so no
server's snapshot brings it back. Admin, Directory lists the **Servers** sharing, by token name,
with how many feeds and subscribers each gave, and **Blocked**, by title, with Unblock. Dropping a
server's snapshot takes its feeds and counts out until it next shares; to keep a server out,
revoke its token. An admin's own subscriptions open as their feeds, not as Directory pages, so
removing one of those is done by marking it private, or from another admin's account.
## In a web browser
Anyone may browse it at its address, no token and no sign-in (#215): the most subscribed, the
categories, every feed A to Z, a search by name, and a page for each feed with its description,
its latest items and its address, with a Copy button, to subscribe to in any podcast app or feed
reader. The pages only read: nothing in them changes the directory. They are rendered by the
directory itself from what the API serves, with the publisher's HTML shown as text, an item's link
only when it is http(s), and a Content-Security-Policy that allows the site's own script and
stylesheet and the covers, which load from their publishers. `/v1` is the API and still needs a
token for everything.
## Tokens
Everything but `/healthz` needs a token, sent as `Authorization: Bearer <token>`. Each one has a
name and one of three levels of access:
| | browse | send snapshots | remove, block, refresh feeds; drop a server's snapshot |
|---|---|---|---|
| `ro` | yes | | |
| `rw` | yes | yes | |
| `rwa` | yes | yes | yes |
Tokens are in the config file and nowhere else, and no level of access can make or revoke one
over the API. To make one:
```sh
docker exec ipx-directory ipx-directory token new <name> --access ro|rw|rwa
```
It prints the token, to give to whoever it is for, and the lines to add to the config file. The file
holds the token's SHA-256, never the token. The directory reads the file again within a minute of
a change, so adding a token and revoking one (deleting its entry) need no restart. A revoked token
also takes the counts its server sent. A file that does not load (a misspelled `access`, two tokens
with one name) leaves the tokens as they were, and the log says why.
## The config file
`/config/directory.toml` in the container (`DIRECTORY_CONFIG`, or `--config`), on Tower
`/mnt/fast/appdata/ipx-directory/directory.toml`:
```toml
bind = "0.0.0.0:8100"
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
# left out, serves none.
metrics_bind = "0.0.0.0:9188"
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
apple_country = "ca"
# Internal addresses and networks feeds may be fetched from, as ["10.0.0.5", "192.168.1.0/24"].
# Empty, or left out: nothing off the public internet is fetched (#329).
fetch_internal = []
# The environment variable holding the database URL: postgres://... or sqlite://...
database_url_env = "DIRECTORY_DATABASE_URL"
[[tokens]]
name = "tower"
access = "rwa"
token_sha256 = "…"
```
`DIRECTORY_LOG_FORMAT=json` logs one JSON object a line, for Loki, as ipx's `IPX_LOG_FORMAT` does.
Each request is a line with its `method`, `path`, `status`, `duration_ms` and the `token`'s name.
With `OTEL_EXPORTER_OTLP_ENDPOINT` set (`http://host:4318`, OTLP over HTTP), it sends traces: a
span a request, a child of the caller's when the request carries a `traceparent`, as ipx's do, and
a trace a refresher round with a span a feed (#213). `OTEL_RESOURCE_ATTRIBUTES` tags them, as
production does with `deployment.environment.name=production`. Its version is in
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
changelog is `crates/directory/CHANGELOG.md`.
## Apple Podcasts
With `apple_country` set, the directory takes Apple Podcasts' top charts for that country once a
week (#252): the top 100 overall and the top 50 of each of Apple's categories, about a thousand
shows. Apple's lookup API gives each one's RSS address, and they go in as a snapshot of their own,
`apple-podcasts`, with no subscribers: fetched, sorted into categories and listed like any other
feed, while the most subscribed stay what the servers sharing here subscribe to. A show Apple drops
from its charts leaves the directory at the next sync, unless a server shares it. An admin can drop
the snapshot as any server's (`DELETE /v1/instances/apple-podcasts`); the next sync brings it back.
No token may be named `apple-podcasts`.
The same sync compares Apple's list of categories with the one ipx keeps (`CATEGORIES` in
`crates/feed`) and logs a warning naming what differs, for whoever checks the log to update it.
It runs at start when the last sync is a week old, and a failed one tries again an hour later. Each
sync is a trace (`apple_sync`, a span for each of Apple's answers), and a line `Apple Podcasts
synced` says how many shows came and went in.
## Running one
```yaml
services:
ipx-directory:
image: ipx-directory # docker build -f Dockerfile.directory -t ipx-directory .
restart: unless-stopped
environment:
DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
ports:
- "8100:8100"
volumes:
- ./directory:/config:ro # directory.toml
```
## What it will not hold
A private feed is never meant to reach it. ipx leaves out a feed marked private, one with a
username or password, and one whose address `ipx_feed::url_looks_private` catches: a paid-feed
host, or a key in the query. The directory checks every address it is sent with the same function
and refuses what it catches, so one server's miss does not publish someone's paid feed to every
other. What both miss, an `rwa` token can remove: `DELETE /v1/feeds/{id}` removes the feed and
blocks it, so no snapshot brings it back.
## API
| Route | Access | |
|---|---|---|
| `GET /healthz` | none | `ok` when it can read its database |
| `GET /v1/me` | ro | the token's `name` and `access` |
| `GET /v1/feeds` | ro | every feed A to Z: `id`, `url`, `title`, `image`, `category`, `subcategory`, `podcast`, `subscribers` |
| `GET /v1/popular?limit=10` | ro | the most subscribed, at most 100 |
| `GET /v1/feeds/{id}` | ro | one feed, with `description`, `failing` (why its last check failed, in words) and its latest twenty `items` (`guid`, `title`, `link`, `published`, `duration`, `description`), never their files |
| `POST /v1/snapshot` | rw | `[{"url", "subscribers"}]`, at most 20,000: replaces everything this token sent before; answers what it `accepted` and how many it refused as private, blocked or invalid |
| `DELETE /v1/feeds/{id}` | rwa | remove and block |
| `GET /v1/blocked`, `DELETE /v1/blocked/{id}` | rwa | what is blocked; unblock |
| `GET /v1/instances` | rwa | each token holding a snapshot: its `name`, `feeds` and `subscribers` |
| `DELETE /v1/instances/{name}` | rwa | drop one token's snapshot, its counts with it |
| `POST /v1/feeds/{id}/refresh` | rwa | fetch the feed now, answering with its page |
A feed's `id` is the first 64 bits of the SHA-256 of its address, normalised (scheme and host in
lower case, no default port or fragment), so every server arrives at the same id. `subscribers`
is the sum over every token's latest snapshot. A feed is listed once the directory has read it as
a feed, so an OPML or an address that never worked is not, and goes when no snapshot has it any more. Descriptions
are the feed's own HTML, unsanitized: whoever shows them sanitizes them, as ipx does.

415
docs/signals.md Normal file
View File

@@ -0,0 +1,415 @@
# Signals
A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the
words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they
mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
own feed. The rest stays noise, where it was.
Whose TypeSafe key pays for Jev is the admin's choice (#271), under Admin, Server, **Signals for
everyone, on this server's TypeSafe key** (`signals_on_server`):
- **On**, with the server's key in `TYPESAFE_KEY`: everyone has Signals, with nothing to set up.
Every item is asked about on the server's key, and keys people saved are kept but not used.
- **Off**, the default, or on with no `TYPESAFE_KEY`: each person turns Signals on in Settings
with a key of their own. Nothing is judged for someone without one, and their Signals folder is
not shown.
Jev is asked once for each item, on that key, with one question for each of the person's Signals
that has not heard about the item yet.
## What someone sees
- **The sidebar.** Once Signals are on for someone, a Signals folder comes first in the feed list, under All Subscriptions (#255,
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
person's Signals, each with its items and unread counted under its name.
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
- **The folder's page** lists the Signals, with the + for a new one.
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
Its header says what it listens for (the sentence, or the words), and, on people's own keys,
that nothing is picked out without one. Edit changes the name, the words or the sentence; Delete removes the Signal
and leaves the items in their feeds.
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
round. The counts follow without a reload (#258).
- **Settings, Signals.** On the server's key, a line saying the server pays. On people's own, a
Signals switch: turning it on shows a password field for the key, which saves on leaving the
field once TypeSafe has taken it (one short question on that key); a key TypeSafe refuses is
not saved, and the page says so. Turning the switch off, or the bin, forgets the key and hides
the folder; the Signals are kept. The key is never sent back to the page, which only learns
whether one is set.
## How an item gets into a Signal
```mermaid
sequenceDiagram
autonumber
participant W as Scan worker
participant J as Signals judge<br/>(signals::run)
participant DB as Database
participant T as TypeSafe<br/>api.typesafe.ai
participant P as The page
W->>DB: new entries stored
W->>J: ctx.signals.notify_one()
Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
J->>DB: unjudged(skip refused keys, 50)
DB-->>J: items, each with the Signals<br/>that have not heard of it
loop each item
J->>T: POST /v1/systemone<br/>one noul question per Signal
T-->>J: answers: noul 0..1 per question
J->>DB: record_verdicts (hit = noul >= 0.5)
end
J-->>P: signal_hits event (SSE), once a pass per person
P->>DB: GET /api/signals (counts),<br/>and the open Signal's list
```
The judge is a task of its own beside DeadAir's (`crates/ipx/src/signals.rs`), not a job on the
scan worker, so a slow TypeSafe never holds up a scan.
### Which items are asked about
`Db::unjudged` finds, newest first, up to 50 items that are:
- in a feed the Signal's owner subscribes to,
- not hidden from them by their block lists,
- for someone with a key that TypeSafe has not refused since the daemon started,
- and either published (or, with no date, first seen) in the look-back window and not yet in
`signal_verdicts` for that Signal, or picked out by it before it was edited (`recheck`), however
old.
The window is the admin's, Admin, Server, **Signals look back (days)** (`signal_days`, 7 by
default, 1 to 365; #276): each item in it is a call, and a month of production's feeds was about
8,000. So a new Signal looks back that far, and after that sees each new item. A show subscribed
to today has its back catalogue skipped, apart from the window.
The recent half reads the index `entries_recent`, on each feed's items by date, the expression it
is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274).
Rows for one item and one person are grouped, so an item is one call whatever the number of
Signals. Two people's Signals never share a call, even on the server's key, where they did until
#343: one person's description was then in the question that decided the other's verdicts. Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms
each, a month of items took twenty minutes. Every call to Jev, Signals', icons' and key checks',
takes a turn (`pace`, #277): TypeSafe's default limits are 80 requests and 100,000 input tokens a
second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its
guessed tokens' share, whichever is longer. A 429 holds every call back for its `Retry-After`, or
a second, and ends the pass. Each `jev` span says how long it waited (`paced_ms`), and
`ipx_jev_paced_seconds_total` adds them up; the dashboard draws calls and tokens a second against
the limits. A full batch means there may be more, and the next pass
starts at once. Otherwise the judge sleeps until woken or for 5 minutes.
### Every answer is kept
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
an item with a row is never asked again. So the cost is one call per item per person, once.
Changing a Signal's name, words or sentence marks what it picked out to be asked again
(`recheck`), and those stay in its list until answered; what it passed over stays passed over, and
new items are asked the new question as they come (#276). The first start with sentences (#259) deleted every verdict, since they answered the
old question.
## TypeSafe
Jev is TypeSafe's System One model, already used to pick a feed's Directory category
(`feed::classify`). The address is `feed::JEV_URL`, `https://api.typesafe.ai/v1/systemone`.
Signals use the **noul** question type, which answers how likely a statement is true, from 0 to 1.
Questions in one call are judged independently and in parallel.
### A request
Sent with `Authorization: Bearer <the key>`, the server's or the person's, for an item and two of their Signals:
```json
{
"model": "jev-latest",
"state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
"questions": {
"signal_3": {
"type": "noul",
"instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
"criteria": {
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
"false": "It is about something else, or touches the subject only in passing."
}
},
"signal_4": {
"type": "noul",
"instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
"criteria": {
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
"false": "It is about something else, or touches the subject only in passing."
}
}
}
}
```
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
cut to 1,000 characters (`TEXT_CHARS`). It was 2,000 until #275: on 55 real items and six
Signals, 1,000 changed none of 330 verdicts and saved 12% of the input tokens (1,266 to 1,112 a
call); 500 changed one. Most of the input is the questions themselves, not the item.
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
(#257).
- **One subject, not any word.** The first question was "Is this item about any of these: ai,
development, programming?", so one broad word was enough: AI Development took Sega pulling a game
remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked
about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools,
vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in,
AI code review in the Linux kernel highest at 0.94 (#259).
### Its answer
What TypeSafe answered to a request of this shape, on 2026-10-16:
```json
{
"model": "jev-1.13.0",
"answers": {
"signal_3": { "type": "noul", "noul": 0.96 },
"signal_4": { "type": "noul", "noul": 0.02 }
},
"usage": { "input_tokens": 389, "output_tokens": 40 }
}
```
0.5 or more is a fit (`THRESHOLD`): the item goes into Signal 3 and not Signal 4. A question
missing from the answer is left unjudged and asked again next pass.
### When TypeSafe says no
| TypeSafe answers | What the judge does |
|---|---|
| 401, 402, 403, on the server's key | Everyone's key is refused. The pass ends and the queue waits for the next one, five minutes on. Logged: "TypeSafe refused the server's key for Signals". |
| 401, 402, 403, on a person's key | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
| 429 | Over TypeSafe's limits. Every call waits for `Retry-After` or a second; the pass ends, what was in flight kept, and the rest wait for the next. Logged once: "TypeSafe asked Signals to slow down". |
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
The error keeps TypeSafe's reply, cut to 500 characters, for example:
```
TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}
```
## The API
Every route is the signed-in person's own. A Signal of someone else's is not found, as one that
does not exist.
### `GET /api/signals`
```json
[
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
]
```
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
its list shows; `unread` those of them not read.
### `POST /api/signals`
```json
{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }
```
Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words
are allowed, each up to 200 characters, kept once whatever their case. `description` is optional,
up to 500 characters. Anything else is a `400` saying so.
### `PATCH /api/signals/{id}`
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
### `DELETE /api/signals/{id}`
Answers `204`. The Signal and its verdicts go; the items stay in their feeds.
### `GET /api/signals/{id}/entries`
The same query as a feed's list, `offset`, `limit`, `filter`, `q`, `sort`, `dir`:
```
GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
```
```json
{
"total": 1,
"entries": [
{
"guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
"link": "https://pitlane.example/monaco", "published": 1792170000,
"description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
"image": null, "duration": null, "episode": null, "season": null, "position": 0,
"enclosures": []
}
]
}
```
Reading or pinning one is the item's own route, `POST /api/entries/{feed_id}/{guid}/flags`, as
anywhere else.
### `PATCH /api/me`, the key
```json
{ "typesafe_key": "ts_…" }
```
A string is checked with TypeSafe, by one short question on that key, then saved, and the judge
tries it again. `null` or an empty string forgets it. A key is up to 500 printable characters.
A key TypeSafe refuses is a `400` ("TypeSafe did not accept that key") and not saved; TypeSafe not
answering is a `502`, and the key is not saved either. `GET /api/me` says `"typesafe_key_set":
true` and nothing more about the key, and `"signals": "server"` or `"own"`, whose key is used.
### `PATCH /api/settings`, who pays
An admin's `{ "signals_on_server": true }` judges everyone's Signals on `TYPESAFE_KEY`, and
`false` on each person's own. `GET /api/settings` has `signals_on_server` and
`typesafe_server_key`, whether the server has a key to use; without one the setting changes
nothing. A change wakes the judge and lets keys refused before be tried again.
### `POST /api/signals/icon`
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
```json
{
"name": "AI Development",
"keywords": ["ai", "development", "programming"],
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
}
```
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
```json
{
"model": "jev-latest",
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
"questions": { "icon": {
"type": "choice",
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
} }
}
```
TypeSafe answered, on 2026-10-16, for six candidates:
```json
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
```
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
### The event
Once a pass, for each person it found items for, the judge sends this on the event stream
(`/api/events`):
```json
{ "ev": "signal_hits", "user": 2, "hits": 3 }
```
A page with Signals fetches `/api/signals` again, and the open Signal's list.
## Data
```mermaid
erDiagram
users ||--o{ signals : owns
signals ||--o{ signal_verdicts : "has heard of"
entries ||--o{ signal_verdicts : "judged in"
users {
bigint id
text typesafe_key "NULL: no key"
}
signals {
bigint id
bigint user_id
text name
text keywords "JSON array"
text description "NULL: none"
text icon "solid/robot, NULL: initials"
bigint created
}
signal_verdicts {
bigint signal_id "PK"
text feed_id "PK"
text guid "PK"
bool hit
}
```
- `signals` and `signal_verdicts` are created from their entities like every table
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
were added to existing tables, so `create_missing` adds each column when it is missing.
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
- A Signal's list is `entries_where` with
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
work as for All Subscriptions.
- A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key
would refuse it, and SQLite would keep it for nothing.
## Watching it
| | |
|---|---|
| Traces | A `signals` span for each pass (`entries`), and a `jev` span for each call (`user`, `signal_count`, `http.response.status_code`, and TypeSafe's reply as `error` when it refuses). |
| Log, counted | `ev="signal"`, "an item fits a Signal", for each item with a fit: `user`, `feed`, `new` (how many of their Signals it fits). |
| Log, warnings | The three in the table above. |
| Events | `signal_hits` on the socket and `/api/events`. |
## Limits
- **The key is kept in plain text** in `users.typesafe_key`, because every call needs it whole. It
should be encrypted with a server key if the database is ever shared with anything else.
- **Refused keys are remembered in memory**, so a restart tries each once again.
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
words.
- **The meter is the server's**, `ipx_jev_tokens_total` and `ipx_jev_requests_total` on
`/metrics`, and the Signals row of the dashboard: tokens as TypeSafe's answers count them, by
person (`user`, the account's name) and whose key paid (`key`, `server` or `own`; #278), with
`ipx_signals_judged_total` and `ipx_signals_hits_total` the same way. A call shared by several
people on the server's key is split by each one's share of its questions. Icon picks bill the
person on the server's key, key checks the person on their own. The dashboard's **Who used what**
sums them over its time range. It is
one call per item in the person's feeds as they come, and per item in the window for a new Signal.
- **Only the window** is judged by publish date, apart from what an edit asks again.
## Testing
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
on SQLite and Postgres both.
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
`/signal.xml` is dated today, since only the window is judged. They spend the TypeSafe key, so
each runs only when the Signals code or the test has changed since it last passed (`signalsTest`;
`IPX_SIGNALS_TESTS=1` runs them anyway).

View File

@@ -18,7 +18,7 @@ real setup and pointed at the wrong address.
```
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
─► tunnel "rays-unraid" (the cloudflared container on Tower)
─► http://192.168.1.130:8099 ─► ipx
─► http://192.168.1.130:8099, ipx-web (Caddy) ─► ipx
```
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
@@ -31,21 +31,27 @@ request it forwards through the tunnel, and ipx signs that person in.
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
| ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
```toml
[web]
enabled = true
bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
trusted_proxies = ["127.0.0.1", "::1", "192.168.64.10"] # ipx-web; see "Behind ipx-web"
access_team = "rays-sdf1.cloudflareaccess.com"
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
auto_create_users = true
sign_out_url = "/cdn-cgi/access/logout"
session_days = 30
```
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`.
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml
restart ipx`.
### What was missing
@@ -59,22 +65,34 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
### The address to trust, and why it is 192.168.16.1
### The address to trust, and why it is 192.168.64.1
`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker
`cloudflared` runs in its own container and reaches ipx-web (before #203, ipx) through the host's published port. Docker
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
requests arrive from the **gateway of ipx's own network**, `content_default`:
requests arrive from the **gateway of ipx's own network**, `ipx_default`:
```sh
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
docker network inspect ipx_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
```
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
lists them in hex.)
If the `content` project's network is ever recreated, its gateway can change. Check it again, and
update `trusted_proxies` to match.
The `ipx` project's compose file pins that network's subnet to `192.168.64.0/20`, so its gateway
stays `192.168.64.1` when the network is recreated. It was `192.168.16.1`, `content_default`'s, while
ipx was part of the `content` project. If the subnet is ever changed, update `TUNNEL_PEER` in the
compose file to match (before #203, `trusted_proxies`).
### Behind ipx-web (#203)
With the page served by `ipx-web` (Caddy), cloudflared reaches Caddy instead, still through the
host's port 8099, so its requests still arrive from `192.168.64.1`, at Caddy now. Caddy passes the
`Cf-Access-*` headers on only from that address (`TUNNEL_PEER`) and strips them from everyone else,
the boundary `trusted_proxies` drew before. ipx then sees every request come from Caddy, so
`trusted_proxies` names Caddy's address on `ipx_default`, pinned in the compose file
(`192.168.64.10`), instead of the gateway. The token check is unchanged: a header Caddy passes on
still needs a valid `Cf-Access-Jwt-Assertion`.
### Names
@@ -87,7 +105,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
send:
```sh
docker exec iPodderX ipx user rename <old name> <email address>
docker exec iPX ipx user rename <old name> <email address>
```
### Signing out
@@ -103,11 +121,15 @@ feeds.
### The tile in Authentik's library
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
tile. Who actually gets in is still up to the Access policy.
Authentik's library lists Authentik's own applications, and ipx signs in through the one
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
Applications → Applications → `iPX` (slug `ipodderx`): no provider, launch URL
`https://ipodderx.sdf1.net`, and web/logo.svg at 512px as its icon, uploaded again when the logo
changes. The library keeps each person's list of tiles in a cache that an edit to the application
does not clear, so after one, clear it (System → Policies → Clear cache, or
`POST /api/v3/policies/all/cache_clear/`) or the old name and icon stay up. It has no policy
bindings, so everyone in Authentik sees the tile. Who actually gets in is still up to the Access
policy.
### Check it
@@ -163,18 +185,44 @@ itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-user
## How this is secured
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the
request falls through to a session cookie or the shared token. That is the whole security boundary.
**The header is only passed on from `TUNNEL_PEER`, and only believed from `trusted_proxies`.**
Caddy strips `Cf-Access-*` from every request that does not come from `TUNNEL_PEER`, and ipx
ignores the header from anyone but Caddy, its one trusted proxy; such a request falls through to a
session cookie or the shared token. That is the whole security boundary.
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
With the tunnel reaching ipx-web through the host's port, `192.168.64.1` means **any container on Tower
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
itself, arrive under their own addresses and cannot set the header; the checks above show both
sides. Never list a LAN address or range: anyone there could then send
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
boundary, and is the upgrade if that ever matters.
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
a container on Tower cannot make one.
### Verifying Cloudflare's token
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
request, and takes the name from its `email` claim.
```toml
[web]
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
```
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
most once a minute. It checks the signature (RS256 only), that the audience is this application's
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
the keys cannot be fetched; password and token sign-in still work then.
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
place.
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
in with them until they are given a password (`ipx user passwd <name>`).
@@ -192,12 +240,26 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account
```
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
that read a password.
Set `auto_create_users = false` once everyone who should have an account has one. After that the
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
the exact name the header will carry.
## API tokens for scripts and agents
Anyone can make API tokens for their own account in Settings. A token is sent as
`Authorization: Bearer ipx_...` and acts as the person who made it, admin only if they are. Only
its SHA-256 is kept; it is shown once, when made, and revoked there too.
Through the tunnel, Cloudflare Access turns a request with no Access sign-in towards Authentik
before ipx sees it, so a token alone does not get in that way. On the LAN, `http://192.168.1.130:8099`
takes it directly. From outside, make an Access service token, add a Service Auth policy for it to
the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client-Secret` beside the
`Authorization` header: Access lets the request through, vouches for no name, and ipx takes the
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
only thing between the internet and the API.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.

View File

@@ -51,8 +51,9 @@ Three ways, tried in order of how specific the claim is:
3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
and any scripts use.
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in
the log. Change it:
A database with no accounts creates **admin** on the next daemon start, with the password in
`IPX_ADMIN_PASSWORD` if it is set, or a random one written to `admin-password` beside config.toml,
readable by ipx's user alone. The log says where the password is, never what it is. Change it:
```sh
echo -n 'a good password' | ipx user passwd admin
@@ -70,19 +71,29 @@ list with their own read state. Unsubscribing removes it from their list alone;
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
**Directory** sits at the top of the feed list, above your own feeds. It shows the directory
service an admin has set up ([directory.md](directory.md)), which iPX servers share, and says so
when there is none. It opens on the ten feeds with the most subscribers on every server sharing
with it, you included, ranked, then the categories, each a tile
showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds,
then all of them as a grid of cover art, with its subcategories along the top (Tech News under
News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search
box finds one by name. A feed you do not subscribe to opens a page of its own, with what it says it
is and its latest episodes or posts, so you can see what it is first. Every page can be narrowed to Podcasts (anything with audio or video) or
Blogs (the rest). A show's category is the one it gives itself in iTunes, or for one that names
none, one an admin or iPX picked for it. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
want. Anything private is left out: a feed someone marked Private in its settings, a login configured for the feed, credentials in its URL,
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
they pay for.
**Currently Listening**, under the Directory, is every episode you started and have not finished,
across all your feeds, with how much is left. A click picks one up where you left off. The search
box looks through it, as it does a feed's items, and through the Directory by name.
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
@@ -91,7 +102,7 @@ removed there, so there is always someone who can manage the rest.
## Admin
The first account is an admin. An admin can change global settings (scanning interval, quota,
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
retention, media types, download folder, the directory), a feed's URL, folder and schedule, and who has an account
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
gets the Settings and Log buttons hidden and a `403` if they ask anyway.

417
grafana/dashboard.py Normal file
View File

@@ -0,0 +1,417 @@
"""The iPX dashboard in Grafana: the three containers of the Arcane project ipx (ipx, ipx-web,
ipx-directory) and Tower under them, from Prometheus, Loki (their logs, shipped by Alloy) and Tempo.
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
Grafana reads that file on its own within a minute; edits made in Grafana are refused. Rows go
top-down as a request does: an overview, Tower, then each container (ipx-web, ipx's API, its
worker and its process, the directory), then traces and logs. Prometheus scrapes ipx's /metrics
(job ipx, #194) and Tower's node exporter (job node); nothing scrapes the containers themselves,
so ipx-web and the directory are read from their logs. The log panels read fields: ipx's
(web.rs access_log, ipc.rs log_event), the directory's and Caddy's; renaming one has to be
matched here. `dashboard.py queries` prints each Loki query, to try against Loki.
"""
import json, sys
LOKI = {"type": "loki", "uid": "${loki}"}
TEMPO = {"type": "tempo", "uid": "${tempo}"}
PROM = {"type": "prometheus", "uid": "${prometheus}"}
SEL = '{container="iPX"}'
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
# and duration_ms. Events are logged under ipx::scan, the healthcheck's status under ipx::io, so
# the filter is on the ev field, not the target.
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
# Every production span: ipx-web's, ipx's and the directory's, one trace from the page to the
# directory where a request goes that far (#213). A daemon run by hand has no such tag.
TRACES = '{resource.deployment.environment.name="production"'
# The directory's JSON log (#204): a line a request (method, path, status, duration_ms, token), a
# line a snapshot (instance, accepted, refused_*), a line a refresher round (checked) and one a
# failed feed check (feed, error).
DIR = '{container="ipx-directory"} | json | __error__=""'
DIR_HTTP = '{container="ipx-directory"} |= "ipx_directory::http" | json | __error__=""'
# ipx-web's Caddy access log (#203, #210): request.uri, status, duration in seconds. The page and
# its files only: /api and /media are ipx's, and in its own log already.
WEB = ('{container="ipx-web"} |= "http.log.access" | json | __error__=""'
' | request_uri !~ "/(api|media)/.*" | request_remote_ip != "127.0.0.1"') # not its healthcheck
# Less what Caddy says at every start, by design: no admin endpoint, and no HTTPS or HTTP/3
# behind the tunnel; and its goodbye on a restart.
OTHERS_BAD = ('{container=~"ipx-web|ipx-directory"} | json | __error__="" | level =~ "(?i)warn|error"'
' | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"')
QUERIES = {}
panels, y = [], 0
pid = 0
def panel(kind, title, w, h, x, targets, **extra):
global pid
pid += 1
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
"datasource": ds, "targets": targets}
p.update(extra)
panels.append(p)
return p
def loki(expr, ref="A", legend=None, instant=False, kind=None):
QUERIES[expr] = instant
t = {"refId": ref, "datasource": LOKI, "expr": expr, "queryType": "instant" if instant else "range"}
if legend:
t["legendFormat"] = legend
return t
def prom(expr, ref="A", legend=None):
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
t = {"refId": ref, "datasource": PROM, "expr": expr}
if legend:
t["legendFormat"] = legend
return t
def row(title):
global y, pid
pid += 1
panels.append({"id": pid, "type": "row", "title": title, "collapsed": False,
"gridPos": {"x": 0, "y": y, "w": 24, "h": 1}, "panels": []})
y += 1
def stat(title, target, x, unit="short", color="blue", thresholds=None, desc=None):
"""target: loki(..., instant=True) or prom(...)."""
steps = thresholds or [{"color": color, "value": None}]
return panel("stat", title, 4, 4, x, [target], description=desc or "",
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
"colorMode": "value", "graphMode": "none", "textMode": "value"})
def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc=""):
custom = {"drawStyle": "bars" if bars else "line", "fillOpacity": 60 if bars else 10,
"lineWidth": 1, "showPoints": "never", "stacking": {"mode": "normal" if stack else "none"}}
return panel("timeseries", title, w, h, x, targets, description=desc,
fieldConfig={"defaults": {"unit": unit, "custom": custom}, "overrides": []},
options={"legend": {"displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "multi", "sort": "desc"}})
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
return panel("table", title, w, h, x, targets, description=desc,
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
{"id": "organize", "options": {
"excludeByName": {"Time": True, "container": True, "compose_project": True,
"service_name": True},
"renameByName": rename or {}}}],
options={"showHeader": True, "sortBy": sort or []},
fieldConfig={"defaults": {}, "overrides": []})
GOOD_BAD = [{"color": "green", "value": None}, {"color": "orange", "value": 1}]
# Caddy's line for each request, what kind it is by its address.
WEB_ALL = ('{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
' | label_format kind=`{{ if hasPrefix "/api/" .request_uri }}API{{ else if hasPrefix "/media/" .request_uri }}'
'media{{ else }}page and files{{ end }}`')
LOG_OPTS = {"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True}
def bar_overrides(*names):
return [{"matcher": {"id": "byName", "options": n},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}
for n in names]
# ---- Overview: is each part up, and is anything wrong
row("Overview")
stat("ipx", prom('max(up{job="ipx"})'), 0, thresholds=[{"color": "red", "value": None}, {"color": "green", "value": 1}],
desc="Prometheus reaching ipx's /metrics: 1 is up. Its healthcheck asks the daemon and the database too.")["fieldConfig"]["defaults"]["mappings"] = [
{"type": "value", "options": {"0": {"text": "down"}, "1": {"text": "up"}}}]
stat("Feeds", prom('max(ipx_feeds{job="ipx"})'), 4)
stat("Waiting to download", prom('max(ipx_downloads_pending{job="ipx"})'), 8)
stat("Since the last scan", prom('max(ipx_last_scan_age_seconds{job="ipx"})'), 12, unit="s",
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 600}, {"color": "red", "value": 1800}],
desc="Over ten minutes means the worker may be stuck; the healthcheck still passes.")
stat("Page and API errors", loki(f'sum(count_over_time({WEB_ALL} | status >= 500 [$__range])) or vector(0)', instant=True), 16,
thresholds=GOOD_BAD, desc="Answers of 500 and up from ipx-web, for the page or passed on from ipx: 502 is ipx not answering.")
stat("Warnings and errors", loki(f'sum(count_over_time({BAD} [$__range])) + sum(count_over_time({OTHERS_BAD} [$__range])) or vector(0)', instant=True), 20,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}],
desc="All three containers, in the time range.")
y += 4
stat("Feed failures", loki(f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', instant=True), 0,
thresholds=GOOD_BAD, desc="Failed feed checks in the time range.")
stat("Download failures", loki(f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', instant=True), 4,
thresholds=GOOD_BAD)
stat("Downloaded", prom('max(ipx_files_downloaded{job="ipx"})'), 8, color="green")
stat("ipx running for", prom('max(ipx_uptime_seconds{job="ipx"})'), 12, unit="s")
stat("Directory: feeds shared", loki('sum(max by (instance) (max_over_time(' + DIR + ' | message="snapshot" | unwrap accepted [6h])))', instant=True), 16,
desc="The feeds each server's latest snapshot gave, added up.")
stat("Directory: refused as private", loki(f'sum(sum_over_time({DIR} | message="snapshot" | unwrap refused_private [$__range])) or vector(0)', instant=True), 20,
thresholds=[{"color": "green", "value": None}, {"color": "red", "value": 1}],
desc="Feeds a server sent that the directory caught as private. Not 0 means a server's own check missed one.")
y += 4
# ---- Tower: the host all three run on, from the node exporter
row("Tower")
ts("Load", [prom('node_load1{job="node"}', legend="1 minute"), prom('node_load15{job="node"}', ref="B", legend="15 minutes")],
0, w=8, desc="Twelve threads. Load far above that with little CPU used is processes waiting on a disk or the user shares (shfs).")
ts("CPU", [prom('sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$__rate_interval]))', legend="{{mode}}")],
8, w=8, stack=True, desc="Cores busy, by kind. Much system time with little user time is the kernel, often FUSE (shfs).")
ts("Memory available", [prom('node_memory_MemAvailable_bytes{job="node"}', legend="available")], 16, w=8, unit="bytes")
y += 8
ts("Disk busy", [prom('rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$__rate_interval])', legend="{{device}}")],
0, w=12, unit="percentunit", desc="nvme1n1 is /mnt/fast (appdata, Postgres's data); md* are the array's disks behind /mnt/user (downloads).")
ts("Network", [prom('sum(rate(node_network_receive_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', legend="in"),
prom('sum(rate(node_network_transmit_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', ref="B", legend="out")],
12, w=12, unit="Bps")
y += 8
# ---- ipx-web: Caddy, the way in. Serves the page itself, passes /api and /media to ipx
row("ipx-web")
ts("Requests by kind", [loki(f'sum by (kind) (count_over_time({WEB_ALL} [$__interval]))', legend="{{kind}}")],
0, w=8, bars=True, stack=True, desc="Everything through Caddy, from the LAN and the tunnel. The API and media go on to ipx.")
ts("Page and files by status", [loki(f'sum by (status) (count_over_time({WEB} [$__interval]))', legend="{{status}}")],
8, w=8, bars=True, stack=True, desc="What Caddy served itself. A page answered 401 or 403 is ipx refusing it in /api/page-check.")
ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duration [$__interval]) by ()', legend="95th percentile"),
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
y += 8
# What the person waits for, from the page itself (#246): a click to the list drawn.
LIST = SEL + ' |= "\\"ev\\":\\"list_shown\\"" | json | __error__=""'
ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap ms [$__interval]) by ()', legend="95th percentile"),
loki(f'quantile_over_time(0.5, {LIST} | unwrap ms [$__interval]) by ()', ref="B", legend="median"),
loki(f'max_over_time({LIST} | unwrap ms [$__interval]) by ()', ref="C", legend="slowest")],
0, w=12, unit="ms", desc="Measured in the browser, network and drawing included. The API's part is in the click's trace.")
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
y += 8
# ---- ipx: the API it answers behind ipx-web
row("ipx: API")
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
loki(f'quantile_over_time(0.95, {HTTP} | unwrap duration_ms [$__interval]) by ()', ref="B", legend="95th percentile"),
loki(f'max_over_time({HTTP} | unwrap duration_ms [$__interval]) by ()', ref="C", legend="slowest")],
12, unit="ms")
y += 8
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
0, w=8, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
8, w=8, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
ts("Open connections", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="requests in flight"),
prom('max(ipx_event_listeners{job="ipx"})', ref="B", legend="live-update listeners"),
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
16, w=8, desc="Each open page holds one live-update listener. Missed updates mean a page fell behind and reloads its lists.")
y += 8
# ---- ipx: the worker, one job at a time: scans, downloads, DeadAir
row("ipx: worker")
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, bars=True, stack=True)
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
y += 8
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"),
prom('ipx_downloads_pending{job="ipx"}', ref="B", legend="downloads"),
prom('max(ipx_feed_retries{job="ipx"})', ref="C", legend="feeds to retry a lookup"),
prom('max(ipx_deadair_backlog{job="ipx"})', ref="D", legend="files for DeadAir")],
0, w=8, desc="A queue that only grows means the worker is stuck or behind.")
ts("Feeds checked and new items", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked"),
loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', ref="B", legend="new items")],
8, w=8, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed"),
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="at once"),
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
16, w=8, bars=True)
y += 8
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
0, w=8, unit="Bps")
ts("Feeds fetching at once", [prom('max(ipx_scan_feeds_fetching{job="ipx"})', legend="fetching")], 8, w=8)
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for"),
prom('max(ipx_deadair_playing{job="ipx"})', ref="B", legend="playing through DeadAir")],
16, w=8, unit="s", desc="How long DeadAir has been on the file it is analysing; a few seconds each.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "playing through DeadAir"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
y += 8
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
y += 8
# ---- ipx: Signals, items put to TypeSafe's Jev on each person's own key (#254)
row("ipx: Signals")
ts("Items judged", [prom('sum(increase(ipx_signals_judged_total{job="ipx"}[$__rate_interval]))', legend="judged"),
prom('sum(increase(ipx_signals_hits_total{job="ipx"}[$__rate_interval]))', ref="B", legend="fit a Signal")],
0, w=8, bars=True, desc="Each item is judged once against every Signal of its subscribers that has not had it; a burst follows a scan or a new Signal.")
ts("Calls to Jev", [prom('sum by (result) (increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="{{result}}"),
prom('(sum(increase(ipx_jev_seconds_total{job="ipx"}[$__rate_interval])) - sum(increase(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))) / '
'sum(increase(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="seconds a call")],
8, w=8, bars=True, stack=True,
desc="key_refused stops that person's Signals until they save another key; malformed stops the pass and is ipx's to fix; "
"rate_limited is TypeSafe saying slow down (#277), and every call waits; "
"error is TypeSafe failing or slow, and the item is asked again next pass. Seconds a call is TypeSafe's, without the wait for a turn.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "seconds a call"},
"properties": [{"id": "unit", "value": "s"}, {"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "line"}, {"id": "custom.stacking", "value": {"mode": "none"}}]}]
ts("Signals judge", [prom('max(ipx_signals_last_pass_age_seconds{job="ipx"})', legend="since the last pass"),
prom('max(ipx_signals_keys_refused{job="ipx"})', ref="B", legend="keys refused")],
16, w=8, unit="s", desc="It passes at least every five minutes; much longer means it is stuck on Jev. Keys refused, on the right, "
"are people whose Signals wait for a new key.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "keys refused"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
y += 8
ts("Tokens", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
0, w=8, bars=True, stack=True, desc="What TypeSafe counted for each call, as its answers say: what Signals, icons and key checks cost.")
ts("Tokens a call", [prom('sum by (kind) (increase(ipx_jev_tokens_total{job="ipx"}[$__rate_interval])) / ignoring(kind) group_left '
'sum(increase(ipx_jev_requests_total{job="ipx", result="ok"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, desc="Most of the input is the questions, one for each Signal, then the item's text, cut at 1,000 characters.")
# TypeSafe's default limits (#277): ipx paces itself to 60 calls and 80,000 input tokens a second.
LIMIT = lambda n: [{"color": "transparent", "value": None}, {"color": "red", "value": n}]
p = ts("Against TypeSafe's limits", [prom('sum(rate(ipx_jev_requests_total{job="ipx"}[$__rate_interval]))', legend="calls a second"),
prom('sum(rate(ipx_jev_tokens_total{job="ipx", kind="input"}[$__rate_interval])) / 1000', ref="B", legend="thousand input tokens a second"),
prom('sum(rate(ipx_jev_paced_seconds_total{job="ipx"}[$__rate_interval]))', ref="C", legend="seconds waited for a turn, a second")],
16, w=8, desc="TypeSafe allows 80 calls and 100,000 input tokens a second; the red line is 80 on both scales (calls, and thousands of tokens). "
"Waiting for a turn means ipx is holding itself under them, and is the time a new Signal's backfill takes longer for it.")
p["fieldConfig"]["defaults"]["custom"]["thresholdsStyle"] = {"mode": "line"}
p["fieldConfig"]["defaults"]["thresholds"] = {"mode": "absolute", "steps": LIMIT(80)}
y += 8
# By person and whose key paid (#278): who the server's TypeSafe bill is for. An item shared on the
# server's key is split by each person's share of its questions.
# Counts from before #278 have no person; user!="" leaves them out rather than drawing " ( key)".
ts("Input tokens by person", [prom('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__rate_interval]))',
legend="{{user}} ({{key}} key)")],
0, w=8, bars=True, stack=True, desc="server is the server's TypeSafe key, what the server pays for; own is the person's own key.")
ts("Items judged by person", [prom('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__rate_interval]))', legend="{{user}} ({{key}} key)"),
prom('sum by (user) (increase(ipx_signals_hits_total{job="ipx", user!=""}[$__rate_interval]))', ref="B", legend="{{user}}: fit a Signal")],
8, w=8, bars=True)
def ptab(expr, ref):
return {"refId": ref, "datasource": PROM, "expr": expr, "instant": True, "format": "table"}
panel("table", "Who used what", 8, 8, 16,
[ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="input", user!=""}[$__range]))', "A"),
ptab('sum by (user, key) (increase(ipx_jev_tokens_total{job="ipx", kind="output", user!=""}[$__range]))', "B"),
ptab('sum by (user, key) (increase(ipx_signals_judged_total{job="ipx", user!=""}[$__range]))', "C")],
description="Over the dashboard's time range: each person's input and output tokens and items judged, and whose key paid. "
"Icon picks are on the server's key, key checks on the person's own.",
transformations=[{"id": "merge", "options": {}},
{"id": "organize", "options": {"excludeByName": {"Time": True},
"renameByName": {"user": "Person", "key": "Key", "Value #A": "Input tokens",
"Value #B": "Output tokens", "Value #C": "Items judged"}}}],
options={"showHeader": True, "sortBy": [{"displayName": "Input tokens", "desc": True}]},
fieldConfig={"defaults": {"decimals": 0}, "overrides": []})
y += 8
# ---- ipx: the process, what it holds between requests and jobs (#194)
row("ipx: process")
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
# pressure; drawn as a line of its own it read as the pool maxing out (#199).
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
prom('min(min_over_time(ipx_db_connections{job="ipx", state="idle"}[$__interval]))', ref="B", legend="ready"),
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="the pool")],
0, w=8, desc="The daemon keeps the whole pool open (#241), so in use and ready add up to the pool. In use reaching "
"the pool, ready at 0, means requests and scans are waiting for a connection (#251).")
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 8, w=8, unit="bytes")
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
y += 8
ts("Runtime", [prom('ipx_runtime_tasks{job="ipx"}', legend="tasks"),
prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks waiting for a thread"),
prom('ipx_runtime_workers{job="ipx"}', ref="C", legend="threads")],
0, w=12, desc="Tasks waiting for a thread for long means something is blocking the runtime.")
ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"),
prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / '
'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"),
prom('sum(increase(ipx_art_evicted_total{job="ipx"}[$__rate_interval]))', ref="C", legend="images evicted")],
12, w=12, unit="percentunit",
desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: "
"the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the "
"limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = bar_overrides("images evicted")
y += 8
# ---- ipx-directory: public at directory.ipxreader.app, shared by ipx servers (#204)
row("ipx-directory")
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
desc="ipx sends one when what it shares changes, and every half hour (#211).")
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
desc="Each feed every twelve hours, five at a time.")
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
thresholds=GOOD_BAD, desc="Mostly the publishers': a feed gone or moved.")
stat("Requests", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)
stat("Requests refused", loki(f'sum(count_over_time({DIR_HTTP} | status =~ "401|403" [$__range])) or vector(0)', instant=True), 16,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 100}],
desc="Without a token, or with one that has not the access asked for. Now and then is someone trying a public address.")
stat("Slowest request", loki(f'max(max_over_time({DIR_HTTP} | unwrap duration_ms [$__range])) or vector(0)', instant=True), 20, unit="ms")
y += 4
ts("Requests by token", [loki(f'sum by (status, token) (count_over_time({DIR_HTTP} [$__interval]))', legend="{{status}} {{token}}")],
0, bars=True, stack=True)
ts("Feed checks over time", [loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__interval]))', legend="checked"),
loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__interval]))', ref="B", legend="failed")],
12, bars=True, desc="A burst follows a snapshot with new feeds.")
y += 8
table("Failing feeds in the directory", [loki(f'sum by (feed, error) (count_over_time({DIR} | message="feed check failed" [$__range]))', instant=True)],
0, rename={"feed": "Feed id", "error": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Snapshots by server", [loki(f'sum by (instance) (count_over_time({DIR} | message="snapshot" [$__range]))', instant=True)],
12, rename={"instance": "Token", "Value": "Snapshots"}, sort=[{"displayName": "Snapshots", "desc": True}])
y += 8
# The directory's own /metrics, on a port apart from the tunnel's (#224).
ts("Directory memory", [prom('directory_process_resident_bytes{job="ipx-directory"}', legend="resident")], 0, unit="bytes",
desc="mimalloc gives back what is freed, so this follows what is in use: a rise that never comes down is a leak.")
ts("Feed bytes fetched", [prom('rate(ipx_feed_bytes_total{job="ipx"}[$__rate_interval])', legend="ipx"),
prom('rate(directory_feed_bytes_total{job="ipx-directory"}[$__rate_interval])', ref="B", legend="directory")],
12, unit="Bps", desc="Feeds are read whole, so this is what scans and checks allocate; set it beside memory.")
y += 8
# ---- Traces: all three send them to Tempo (#213)
row("Traces")
for x, title, q in [(0, "Recent", TRACES + "}"),
(8, "Slow (over 2s)", TRACES + " && duration > 2s}"),
(16, "Failed", TRACES + " && status = error}")]:
panel("table", title, 8, 10, x,
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
"tableType": "traces"}],
fieldConfig={"defaults": {}, "overrides": []})
y += 10
# ---- Logs
row("Logs")
panel("logs", "ipx: warnings and errors", 12, 10, 0, [loki(BAD + TEXT)], options=LOG_OPTS)
panel("logs", "ipx-web and ipx-directory: warnings and errors", 12, 10, 12,
[loki(OTHERS_BAD + ' | line_format "{{.container}} {{.level}}: {{.message}}{{.msg}}"')], options=LOG_OPTS)
y += 10
panel("logs", "ipx log", 24, 12, 0,
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
description="Without the event stream's requests, not-due skips and healthcheck status calls.", options=LOG_OPTS)
y += 12
dash = {
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
"templating": {"list": [
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
{"name": "prometheus", "label": "Metrics", "type": "datasource", "query": "prometheus", "current": {}, "hide": 0},
]},
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
"panels": panels,
}
if sys.argv[1:] == ["queries"]:
for q, instant in QUERIES.items():
print(json.dumps([q, instant]))
else:
print(json.dumps(dash, indent=2))

21
package-lock.json generated
View File

@@ -1,19 +1,32 @@
{
"name": "ipx-web",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipx-web",
"version": "1.3.0",
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"typescript": "^7.0.2"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
@@ -871,6 +884,12 @@
"node": ">=16.20.0"
}
},
"node_modules/hls.js": {
"version": "1.6.19",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.19.tgz",
"integrity": "sha512-A//aZeaAgM5rgUCbTURD4gkfyw5Uag2/4Jky7CfDOukL/DN+P5DjbgK2BYteyP3/KnCkPfkGVkbLxQG2T0AbUQ==",
"license": "Apache-2.0"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",

View File

@@ -1,5 +1,6 @@
{
"name": "ipx-web",
"version": "1.3.0",
"private": true,
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
"scripts": {
@@ -7,14 +8,17 @@
"typecheck": "tsc -p .",
"smoke": "node tests/page-smoke.js",
"test": "playwright test",
"test:headed": "playwright test --headed"
"test:headed": "playwright test --headed",
"load": "node tests/load/run.js"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"typescript": "^7.0.2"
},
"dependencies": {
"@fortawesome/fontawesome-free": "7.3.1",
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
"@swc/html": "^1.16.2",
"hls.js": "1.6.19"
}
}

View File

@@ -26,10 +26,29 @@ module.exports = defineConfig({
reuseExistingServer: false,
stdout: 'ignore',
},
{
// The directory the Directory shows (#205), on SQLite in the scratch directory.
command: `cargo build -q -p ipx-directory && exec ./target/debug/ipx-directory --config ${setup.root}/directory.toml`,
url: `${setup.DIRECTORY}/healthz`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'ignore',
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` },
},
{
// The frontend as production serves it (#203): built from web/, served by Caddy on the
// suite's port, the API passed to the daemon behind it.
command: `node web/build.mjs ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`,
url: 'http://127.0.0.1:8791/favicon.png',
reuseExistingServer: false,
timeout: 60_000,
stdout: 'ignore',
env: { WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root },
},
{
// Build first so the tests always run against current source.
command: 'cargo build -q && exec ./target/debug/ipx daemon',
port: 8791,
port: 8796,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
@@ -37,6 +56,14 @@ module.exports = defineConfig({
IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`,
IPX_LOG: 'ipx=info',
// Blank, whatever the shell running the suite has: the test daemon sends no traces to
// production's Tempo and never opens production's database.
OTEL_EXPORTER_OTLP_ENDPOINT: '',
IPX_DATABASE_URL: '',
// The server's TypeSafe key is the shell's: /src/.envrc's is a development key, for this.
// Without one, the tests of the server paying for Signals see that it cannot.
TYPESAFE_KEY: process.env.TYPESAFE_KEY ?? '',
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
},
},
],

4
rustfmt.toml Normal file
View File

@@ -0,0 +1,4 @@
# Matches how the code was written by hand before rustfmt was adopted: long lines, and short
# expressions kept on one line.
max_width = 120
use_small_heuristics = "Max"

46
scripts/release.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/sh
# Publish a Gitea release for one service's tag, with a tarball taken from the image that was
# deployed, so what is attached is what runs on Tower. Run after the release commit is tagged,
# pushed and its image built and pushed: sh scripts/release.sh ipx|directory|web
set -eu
cd "$(dirname "$0")/.."
case "${1:-}" in
ipx) name=ipx; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ipx/Cargo.toml | head -1)
tag=v$ver; log=crates/ipx/CHANGELOG.md; from=/usr/local/bin/ipx ;;
directory) name=ipx-directory; ver=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/directory/Cargo.toml | head -1)
tag=directory-v$ver; log=crates/directory/CHANGELOG.md; from=/usr/local/bin/ipx-directory ;;
web) name=ipx-web; ver=$(node -p 'require("./package.json").version')
tag=web-v$ver; log=web/CHANGELOG.md; from=/srv ;;
*) echo "usage: $0 ipx|directory|web" >&2; exit 2 ;;
esac
case "$ver" in *-dev) echo "$name is at $ver: release it first" >&2; exit 1 ;; esac
git fetch -q --tags origin
git merge-base --is-ancestor "$tag" origin/main || { echo "$tag is not on origin/main" >&2; exit 1; }
out=$(mktemp -d); trap 'rm -rf "$out"' EXIT
dir=$out/$name-$ver; mkdir "$dir"
image=192.168.1.130:5000/$name:latest
c=$(docker create "$image"); trap 'docker rm -f "$c" >/dev/null; rm -rf "$out"' EXIT
if [ "$name" = ipx-web ]; then
docker cp -q "$c:$from/." "$dir/"
cp web/Caddyfile "$dir/"
# latest may be a later build than the tag: the page carries its version, so check it.
grep -qF "$ver" "$dir"/app.js || { echo "$image is not ipx-web $ver" >&2; exit 1; }
arch=any
else
docker cp -q "$c:$from" "$dir/"
"$dir/$name" --version | grep -qF " $ver" || { echo "$image is not $name $ver" >&2; exit 1; }
arch=linux-$(uname -m)
fi
cp LICENSE README.md "$dir/"; cp "$log" "$dir/CHANGELOG.md"
tarball=$out/$name-$ver-$arch.tar.gz
tar -C "$out" -czf "$tarball" "$name-$ver"
# The notes are this version's section of the changelog, without its heading.
awk -v h="## [$ver]" 'index($0, h) == 1 {p = 1; next} p && /^## \[/ {exit} p' "$log" > "$out/notes.md"
[ -s "$out/notes.md" ] || { echo "no [$ver] section in $log" >&2; exit 1; }
timeout 120 /src/tea releases create --login git.sdf1.net --repo rays/ipx --tag "$tag" \
--title "$name $ver" --note-file "$out/notes.md" --asset "$tarball" < /dev/null

7
site/_headers Normal file
View File

@@ -0,0 +1,7 @@
# Cloudflare Web Analytics, which Pages adds to the page, is the one script it runs (#216).
/*
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; script-src https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/fonts/*
Cache-Control: public, max-age=31536000, immutable

735
site/api.mjs Normal file
View File

@@ -0,0 +1,735 @@
// The API reference's content: each service's endpoints, grouped, and the types they take and
// give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A
// change to a route or a field there wants its line here; gen.mjs turns this into pages.
// Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links
// to its page.
const U = 'http://<your server>:8099';
const D = 'https://directory.ipxreader.app';
const ipxCurl = (m, p, body) =>
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`;
const dirCurl = (m, p, body) =>
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer <token>"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`;
const NOT_SUBSCRIBED = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.'];
const NOT_ADMIN = ['403 Forbidden', 'You are not an admin.'];
const NO_DIRECTORY = ['503 Service Unavailable', 'No directory is set up on this server.'];
export const ipx = {
name: 'ipx',
root: '/docs/api/ipx',
changelog: 'crates/ipx/CHANGELOG.md',
intro: `<p>The HTTP API of an iPX server: everything the page does, a script can do. Every request
and answer is JSON unless an item says otherwise. The page is served by ipx-web, which passes
<code>/api</code> and <code>/media</code> on to ipx at the same address, so a script talks to
the address people open in a browser.</p>
<h2 id="auth">Signing in</h2>
<p>Send an API token, made in Settings under API tokens or with <a href="${'/docs/api/ipx/auth/post-tokens'}"><code>POST /api/tokens</code></a>:</p>
<pre><code>Authorization: Bearer ipx_…</code></pre>
<p>A token acts as the person who made it, and is an admin only if they are. A browser signs in
with <a href="/docs/api/ipx/auth/post-login"><code>POST /api/login</code></a> instead and is
given a cookie. Behind Cloudflare Access, Access stops a request with no Access sign-in before
iPX sees it: send an Access service token's <code>CF-Access-Client-Id</code> and
<code>CF-Access-Client-Secret</code> beside the API token.</p>
<h2 id="errors">Errors</h2>
<p>A request that fails answers with its status and a sentence saying why, as plain text:
<code>401</code> not signed in, <code>403</code> an admin's to do, <code>404</code> not there or
not yours, <code>400</code> a request that cannot be done as asked, <code>409</code> a conflict
the answer explains.</p>
<h2 id="ids">Ids</h2>
<p>A feed's id is a short name made from its title, such as <code>99-invisible</code>, and
appears in the page's address when the feed is open. An episode is its feed's id and its
<code>guid</code>, the guid URL-encoded in a path. A file (an enclosure) has a number. Times are
Unix seconds.</p>`,
groups: [
{
id: 'auth', name: 'auth', title: 'Signing in and tokens',
summary: 'Who you are, signing in and out, and API tokens.',
items: [
{
method: 'POST', path: '/api/login', slug: 'post-login', access: 'none',
summary: 'Sign in with a name and password, for a session cookie.',
doc: '<p>Answers who signed in and sets the session cookie, which lasts <code>session_days</code> from the last request. A script is better off with an API token, which needs no password kept.</p>',
body: 'Credentials', returns: 'Me',
errors: [['401 Unauthorized', 'The name or password is wrong.']],
example: `curl -c cookies -H "Content-Type: application/json" \\\n -d '{"name":"sam","password":"…"}' "${U}/api/login"`,
},
{
method: 'POST', path: '/api/logout', slug: 'post-logout',
summary: 'Sign out: end the session and clear its cookies.',
returns: '204 No Content',
},
{
method: 'GET', path: '/api/me', slug: 'get-me',
summary: 'Who you are, your words that hide items, and the server’s version.',
returns: 'Me',
example: ipxCurl('GET', '/api/me'),
},
{
method: 'PATCH', path: '/api/me', slug: 'patch-me',
summary: 'Change the words that hide an item in every feed you read, or your TypeSafe key for Signals.',
doc: '<p>A key is checked with TypeSafe, by one short question on it, before it is saved.</p>',
body: 'MePatch', returns: '204 No Content',
errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters; or a key TypeSafe did not accept.'],
['502 Bad Gateway', 'TypeSafe could not be asked about the key just now. It is not saved.']],
example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'),
},
{
method: 'GET', path: '/api/tokens', slug: 'get-tokens',
summary: 'Your API tokens: names and when each was made and last used, never the token.',
returns: 'Vec<Token>',
},
{
method: 'POST', path: '/api/tokens', slug: 'post-tokens',
summary: 'Make an API token that acts as you.',
doc: '<p>The answer is the only time the token is shown: only its SHA-256 is kept.</p>',
body: 'NewToken', returns: 'NewTokenMade',
errors: [['400 Bad Request', 'No name.']],
example: ipxCurl('POST', '/api/tokens', '{"name":"my script"}'),
},
{
method: 'DELETE', path: '/api/tokens/{id}', slug: 'delete-token',
summary: 'Revoke one of your API tokens.',
pathParams: [['id', 'i64', 'The token’s id, from <code>GET /api/tokens</code>.']],
returns: '204 No Content',
errors: [['404 Not Found', 'No token of yours has that id.']],
},
],
},
{
id: 'feeds', name: 'feeds', title: 'Feeds',
summary: 'Your subscriptions: list, subscribe, change, unsubscribe, check, OPML.',
items: [
{
method: 'GET', path: '/api/feeds', slug: 'get-feeds',
summary: 'Your subscriptions, each with its counts and your settings for it.',
returns: 'Vec<FeedRow>',
example: ipxCurl('GET', '/api/feeds'),
},
{
method: 'POST', path: '/api/feeds', slug: 'post-feeds',
summary: 'Subscribe to a feed, by its address or a site that links one.',
doc: '<p>A feed someone else on the server already has costs nothing to add: it is fetched once and each file is kept once. An OPML address subscribes to the list, kept in step as a folder.</p>',
body: 'NewFeed', returns: 'Added',
errors: [['400 Bad Request', 'The address is not a feed and links none; the answer says what was found.']],
example: ipxCurl('POST', '/api/feeds', '{"url":"https://feeds.99percentinvisible.org/99percentinvisible"}'),
},
{
method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed',
summary: 'Change your settings for a feed, or (an admin) the feed’s own.',
doc: '<p>Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir, pinning and keeping a show from an OPML are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.</p>',
pathParams: [['id', 'String', 'The feed’s id.']],
body: 'FeedPatch', returns: '204 No Content',
errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED],
example: ipxCurl('PATCH', '/api/feeds/99-invisible', '{"keywords":["mini-stories"],"pinned":true}'),
},
{
method: 'DELETE', path: '/api/feeds/{id}', slug: 'delete-feed',
summary: 'Unsubscribe. The files stay for anyone else who reads the feed.',
pathParams: [['id', 'String', 'The feed’s id.']],
returns: '204 No Content', errors: [NOT_SUBSCRIBED],
},
{
method: 'POST', path: '/api/feeds/{id}/read-all', slug: 'post-read-all',
summary: 'Mark every episode of a feed read, and of every feed in it for an OPML list.',
pathParams: [['id', 'String', 'The feed’s id.']],
returns: 'Marked',
},
{
method: 'POST', path: '/api/feeds/{id}/download-latest', slug: 'post-download-latest',
summary: 'Download a feed’s newest episodes that are not downloaded yet.',
pathParams: [['id', 'String', 'The feed’s id.']],
body: 'HowMany', returns: 'Queued', errors: [NOT_SUBSCRIBED],
example: ipxCurl('POST', '/api/feeds/99-invisible/download-latest', '{"count":3}'),
},
{
method: 'POST', path: '/api/fetch', slug: 'post-fetch',
summary: 'Check one feed, or all of yours, for new episodes now.',
doc: '<p>The check runs in the background; <a href="/docs/api/ipx/server/get-events"><code>GET /api/events</code></a> shows it as it goes.</p>',
body: 'FetchBody', returns: '202 Accepted',
example: ipxCurl('POST', '/api/fetch', '{"feed":"99-invisible","force":true}'),
},
{
method: 'GET', path: '/api/opml', slug: 'get-opml',
summary: 'Your subscriptions as an OPML file, for another podcast app.',
returns: 'OPML (text/x-opml)',
},
{
method: 'POST', path: '/api/opml', slug: 'post-opml',
summary: 'Subscribe to every feed in an OPML file.',
body: 'OpmlBody', returns: 'Imported',
errors: [['400 Bad Request', 'Not OPML.']],
},
],
},
{
id: 'entries', name: 'entries', title: 'Episodes',
summary: 'Episodes and posts: list, search, mark read or kept, save where you are.',
items: [
{
method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries',
summary: 'A page of one feed’s episodes, newest first unless sorted. For an OPML list or a Patreon creator, those of the shows in it you subscribe to, together.',
pathParams: [['id', 'String', 'The feed’s id.']],
query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED],
example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'),
},
{
method: 'GET', path: '/api/entries', slug: 'get-entries',
summary: 'The same, across every feed you subscribe to.',
query: 'Page', returns: 'EntryPage',
example: ipxCurl('GET', '/api/entries?q=bridges&sort=published&dir=desc'),
},
{
method: 'GET', path: '/api/entries/{feed}/{guid}', slug: 'get-entry',
summary: 'One episode.',
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
returns: 'EntryRow', errors: [NOT_SUBSCRIBED],
},
{
method: 'POST', path: '/api/entries/{feed}/{guid}/flags', slug: 'post-flags',
summary: 'Mark an episode read or unread, kept or not.',
doc: '<p>Answers with the feed’s row, its counts changed, so a list can redraw one line.</p>',
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
body: 'Flags', returns: 'Option<FeedRow>', errors: [NOT_SUBSCRIBED],
example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/flags', '{"read":true}'),
},
{
method: 'POST', path: '/api/entries/{feed}/{guid}/position', slug: 'post-position',
summary: 'Save where you are in an episode, for every device you listen on.',
pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']],
body: 'Position', returns: '204 No Content', errors: [NOT_SUBSCRIBED],
example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/position', '{"secs":1234}'),
},
{
method: 'POST', path: '/api/read-all', slug: 'post-read-all-mine',
summary: 'Mark every episode of every feed you subscribe to read.',
returns: 'Marked',
},
],
},
{
id: 'signals', name: 'signals', title: 'Signals',
summary: 'Subjects picked out of every feed you subscribe to by TypeSafe.',
items: [
{
method: 'GET', path: '/api/signals', slug: 'get-signals',
summary: 'Your Signals, each with its items and unread counted.',
returns: 'Vec<Signal>',
example: ipxCurl('GET', '/api/signals'),
},
{
method: 'POST', path: '/api/signals', slug: 'post-signals',
summary: 'Make a Signal. It looks back over the last week (an admin’s setting), then at each new item.',
doc: '<p>Each item is one call to TypeSafe: on the server’s key when an admin has chosen that (<code>signals_on_server</code>), otherwise on yours, saved with <a href="/docs/api/ipx/auth/patch-me"><code>PATCH /api/me</code></a>. Without a key, nothing is picked out.</p>',
body: 'SignalBody', returns: '{ id: i64 }',
errors: [['400 Bad Request', 'No name or one over 100 characters, not 1 to 50 words, a description over 500 characters, or an icon that is not a style and a name.']],
example: ipxCurl('POST', '/api/signals', '{"name":"Formula 1","keywords":["formula 1","f1"],"description":"Grand prix racing, not road cars."}'),
},
{
method: 'PATCH', path: '/api/signals/{id}', slug: 'patch-signal',
summary: 'Change a Signal. A new name, words or description asks again about what it picked out, and about new items as they come; a new icon asks nothing.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
body: 'SignalBody', returns: '204 No Content',
errors: [['400 Bad Request', 'As for a new Signal.'], ['404 Not Found', 'No Signal of yours has that id.']],
example: ipxCurl('PATCH', '/api/signals/3', '{"keywords":["f1","grand prix"]}'),
},
{
method: 'DELETE', path: '/api/signals/{id}', slug: 'delete-signal',
summary: 'Delete a Signal. Its items stay in their feeds.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
returns: '204 No Content', errors: [['404 Not Found', 'No Signal of yours has that id.']],
},
{
method: 'GET', path: '/api/signals/{id}/entries', slug: 'get-signal-entries',
summary: 'A page of the items picked out for a Signal, as a feed’s list is.',
pathParams: [['id', 'i64', 'The Signal’s id.']],
query: 'Page', returns: 'EntryPage', errors: [['404 Not Found', 'No Signal of yours has that id.']],
example: ipxCurl('GET', '/api/signals/3/entries?filter=unread&limit=10'),
},
{
method: 'POST', path: '/api/signals/icon', slug: 'post-signal-icon',
summary: 'Have TypeSafe choose a Signal’s icon from the ones offered, on the server’s TypeSafe key.',
body: 'IconAsk', returns: '{ icon: String }',
errors: [['400 Bad Request', 'No icons, more than 60, or one that is not a style and a name.'],
['404 Not Found', 'The server has no TypeSafe key.'], ['502 Bad Gateway', 'TypeSafe could not choose just now.']],
},
],
},
{
id: 'files', name: 'files', title: 'Files',
summary: 'Downloading, deleting and playing an episode’s file.',
items: [
{
method: 'POST', path: '/api/enclosures/{id}/download', slug: 'post-download',
summary: 'Download a file now.',
pathParams: [['id', 'i64', 'The file’s id, an <code>EncRow</code>’s <code>id</code>.']],
returns: '202 Accepted, or 204 No Content when it is already here', errors: [NOT_SUBSCRIBED],
},
{
method: 'DELETE', path: '/api/enclosures/{id}', slug: 'delete-file',
summary: 'Delete a downloaded file. It is not downloaded again.',
doc: '<p>There is one copy of each file, so deleting it deletes it for everyone. If someone else has kept the episode or not played it yet, the first try is refused with who: send it again with <code>?force=true</code> to delete it anyway.</p>',
pathParams: [['id', 'i64', 'The file’s id.']],
query: 'Force', returns: '204 No Content',
errors: [['409 Conflict', 'Someone else still wants it; the answer says who. Send <code>force=true</code> to go ahead.'], NOT_SUBSCRIBED],
},
{
method: 'GET', path: '/media/{id}', slug: 'get-media',
summary: 'The file itself, with <code>Range</code> requests, so a player can seek.',
pathParams: [['id', 'i64', 'The file’s id.']],
returns: 'The file, with its own Content-Type', errors: [NOT_SUBSCRIBED],
},
{
method: 'GET', path: '/media/{id}/deadair.m3u8', slug: 'get-deadair',
summary: 'The episode with its long silences shortened by DeadAir, as an HLS playlist.',
doc: '<p>Only once DeadAir has looked at the file: an <code>EncRow</code> with <code>deadair</code> set says how many seconds it saves. <code>/media/{id}/deadair</code> gives the shortened episode as one file to save.</p>',
pathParams: [['id', 'i64', 'The file’s id.']],
returns: 'An HLS playlist (application/vnd.apple.mpegurl)', errors: [NOT_SUBSCRIBED],
},
],
},
{
id: 'directory', name: 'directory', title: 'The directory, through iPX',
summary: 'Browse the directory and subscribe from it; iPX asks it on your behalf.',
items: [
{
method: 'GET', path: '/api/popular', slug: 'get-popular',
summary: 'The directory’s ten most subscribed feeds.',
returns: 'Vec<PopularRow>', errors: [NO_DIRECTORY],
},
{
method: 'GET', path: '/api/directory', slug: 'get-directory',
summary: 'Every feed the directory lists, A to Z.',
returns: 'Vec<PopularRow>', errors: [NO_DIRECTORY],
},
{
method: 'GET', path: '/api/directory/{id}', slug: 'get-listed',
summary: 'A feed’s page in the directory: what it says it is and its latest items.',
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
returns: 'Listed', errors: [['404 Not Found', 'Not in the directory.'], NO_DIRECTORY],
},
{
method: 'POST', path: '/api/popular/{id}', slug: 'post-popular',
summary: 'Subscribe to a feed from the directory, by the directory’s id.',
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
returns: 'Subscribed', errors: [['400 Bad Request', 'Not in the directory.'], NO_DIRECTORY],
},
],
},
{
id: 'server', name: 'server', title: 'The server',
summary: 'Its counts and version, and live updates.',
items: [
{
method: 'GET', path: '/api/status', slug: 'get-status',
summary: 'How many feeds, files waiting and files downloaded, and the version.',
doc: '<p>The numbers <code>ipx status</code> prints, for a dashboard.</p>',
returns: 'Status', example: ipxCurl('GET', '/api/status'),
},
{
method: 'GET', path: '/api/events', slug: 'get-events',
summary: 'A live stream of checks and downloads as they happen (server-sent events).',
doc: '<p>Each message’s data is one JSON <a href="/docs/api/ipx/types/Event"><code>Event</code></a>. Every event of a check reaches everyone; a feed’s new row (<code>feed_row</code>) only those who subscribe to it.</p>',
returns: 'text/event-stream of Event',
example: `curl -N -H "Authorization: Bearer ipx_…" "${U}/api/events"\ndata: {"ev":"feed_start","feed":"99-invisible"}\ndata: {"ev":"feed_done","feed":"99-invisible","new":1,"downloaded":1,"failed":0,"torrents":0}`,
},
],
},
{
id: 'admin', name: 'admin', title: 'Admin',
summary: 'Server settings, accounts, the log and the directory’s administration. Admins only.',
items: [
{
method: 'GET', path: '/api/settings', slug: 'get-settings', access: 'signed in',
summary: 'The server’s settings. Anyone signed in may read them.',
returns: 'Settings',
},
{
method: 'PATCH', path: '/api/settings', slug: 'patch-settings', access: 'admin',
summary: 'Change the server’s settings. They take effect at once.',
body: 'SettingsPatch', returns: '204 No Content',
errors: [['400 Bad Request', 'A value that does not parse, such as a schedule.'], NOT_ADMIN],
example: ipxCurl('PATCH', '/api/settings', '{"schedule":"every 2h","max_total_gb":200}'),
},
{
method: 'GET', path: '/api/users', slug: 'get-users', access: 'admin',
summary: 'Every account.', returns: 'Vec<UserRow>', errors: [NOT_ADMIN],
},
{
method: 'POST', path: '/api/users', slug: 'post-users', access: 'admin',
summary: 'Add an account.',
doc: '<p>With no password, the account signs in only through single sign-on.</p>',
body: 'NewUser', returns: '201 Created',
errors: [['400 Bad Request', 'No name, or one that exists already.'], NOT_ADMIN],
example: ipxCurl('POST', '/api/users', '{"name":"sam","password":"…"}'),
},
{
method: 'PATCH', path: '/api/users/{id}', slug: 'patch-user', access: 'admin',
summary: 'Make an account an admin, or not.',
pathParams: [['id', 'i64', 'The account’s id.']],
body: 'UserPatch', returns: '204 No Content',
errors: [['400 Bad Request', 'No such account, or the only admin, who cannot stop being one.'], NOT_ADMIN],
},
{
method: 'DELETE', path: '/api/users/{id}', slug: 'delete-user', access: 'admin',
summary: 'Remove an account, with its subscriptions and read state. Files stay.',
pathParams: [['id', 'i64', 'The account’s id.']],
returns: '204 No Content',
errors: [['400 Bad Request', 'No such account, or the only admin, who cannot be removed.'], NOT_ADMIN],
},
{
method: 'GET', path: '/api/logs', slug: 'get-logs', access: 'admin',
summary: 'The newest lines of the server’s log, or those after a given one.',
query: 'LogQuery', returns: 'LogPage', errors: [NOT_ADMIN],
},
{
method: 'GET', path: '/api/directory-status', slug: 'get-directory-status', access: 'admin',
summary: 'Who the directory takes this server’s token to be, and how the last share went.',
returns: 'DirectoryStatus', errors: [NOT_ADMIN],
},
{
method: 'DELETE', path: '/api/directory/{id}', slug: 'delete-listed', access: 'admin',
summary: 'Remove a feed from the directory and keep it out.',
doc: '<p>Only when this server’s directory token is <code>rwa</code>; the directory refuses otherwise.</p>',
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
},
{
method: 'POST', path: '/api/directory/{id}/refresh', slug: 'post-listed-refresh', access: 'admin',
summary: 'Have the directory check a feed now. Needs an <code>rwa</code> token.',
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
},
{
method: 'GET', path: '/api/directory-admin', slug: 'get-directory-admin', access: 'admin',
summary: 'The servers sharing with the directory, and the feeds it keeps out. Needs <code>rwa</code>.',
returns: 'DirectoryAdmin', errors: [NOT_ADMIN, NO_DIRECTORY],
},
{
method: 'DELETE', path: '/api/directory-blocked/{id}', slug: 'delete-directory-blocked', access: 'admin',
summary: 'Let a feed the directory keeps out back in. Needs <code>rwa</code>.',
pathParams: [['id', 'String', 'The directory’s id for the feed.']],
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
},
{
method: 'DELETE', path: '/api/directory-instances/{name}', slug: 'delete-directory-instance', access: 'admin',
summary: 'Drop a server’s share from the directory until it next shares. Needs <code>rwa</code>.',
pathParams: [['name', 'String', 'The server’s token name.']],
returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY],
},
],
},
],
types: [
{ name: 'Credentials', doc: 'A name and password.', fields: [['name', 'String', ''], ['password', 'String', '']] },
{ name: 'Me', doc: 'Who is signed in.', fields: [
['name', 'String', 'The account’s name, often an email address.'],
['admin', 'bool', ''],
['blocked', 'Vec<String>', 'Words that hide an item in every feed you read. <code>GET /api/me</code> only, as are the fields below.'],
['sign_out', 'Option<String>', 'Where signing out should go, for someone single sign-on signed in.'],
['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'],
['version', 'String', 'ipx’s version.'],
['typesafe_key_set', 'bool', 'You have a TypeSafe key saved for Signals. The key itself is never sent back.'],
['signals', 'String', 'Whose key Signals are judged on: <code>server</code>, for everyone, or <code>own</code>, each person’s.'],
['signal_days', 'u64', 'How many days back a new Signal reads.'],
] },
{ name: 'MePatch', doc: 'What <code>PATCH /api/me</code> changes.', fields: [['blocked', 'Option<Vec<String>>', 'Replaces the list.'],
['typesafe_key', 'Option<Option<String>>', 'Your TypeSafe key for Signals, checked with TypeSafe and then saved; <code>null</code> or empty forgets it.']] },
{ name: 'Signal', doc: 'A Signal in your sidebar.', fields: [
['id', 'i64', ''], ['name', 'String', ''], ['keywords', 'Vec<String>', 'The words it listens for.'],
['description', 'Option<String>', 'What you mean, in your own words.'],
['icon', 'Option<String>', 'A Font Awesome icon, such as <code>solid/robot</code>; none shows its initials.'],
['items', 'i64', 'Items picked out that are still in a feed you subscribe to.'], ['unread', 'i64', 'Those of them not read.'],
] },
{ name: 'SignalBody', doc: 'A Signal to make, or what to change of one. Leave out what stays.', fields: [
['name', 'Option<String>', 'Up to 100 characters; needed for a new one.'],
['keywords', 'Option<Vec<String>>', '1 to 50 words, each up to 200 characters; needed for a new one.'],
['description', 'Option<Option<String>>', 'Up to 500 characters; <code>null</code> or empty clears it.'],
['icon', 'Option<Option<String>>', '<code>solid/</code>, <code>regular/</code> or <code>brands/</code> and a name; <code>null</code> or empty clears it.'],
] },
{ name: 'IconAsk', doc: 'The icons to choose a Signal’s from.', fields: [
['name', 'String', ''], ['keywords', 'Vec<String>', ''], ['description', 'Option<String>', ''],
['candidates', 'Vec<(String, String)>', '1 to 60 of <code>[icon, label]</code>, the label its name and search terms.'],
] },
{ name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option<i64>', '']] },
{ name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] },
{ name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins <code>ipx_</code>. Send it as <code>Authorization: Bearer</code>.']] },
{ name: 'FeedRow', doc: 'A feed as you see it: the feed, your settings for it and its counts for you.', fields: [
['id', 'String', ''], ['url', 'String', ''], ['title', 'Option<String>', ''], ['image', 'Option<String>', ''],
['folder', 'Option<String>', ''],
['category', 'Option<String>', 'The directory category an admin gave it.'],
['feed_category', 'Option<String>', 'The one the feed names itself, which wins.'],
['keywords', 'Vec<String>', 'Your words: with any, only episodes that mention one are downloaded.'],
['blocked', 'Vec<String>', 'Your words that hide an episode of this feed.'],
['deadair', 'bool', 'Played with its silences shortened.'],
['deadair_pause', 'i64', 'The pause DeadAir leaves where it cuts, in milliseconds.'],
['allow_explicit', 'bool', ''], ['auto_download', 'bool', ''],
['max_new_per_check', 'Option<usize>', 'Your own limit, or none for the server’s.'],
['group', 'Option<String>', 'The OPML list it came from.'],
['orphaned', 'bool', 'In a list that no longer has it, kept for its downloads.'],
['managed', 'bool', ''],
['schedule', 'Option<String>', 'Its own schedule, such as <code>every 2h</code>.'],
['schedule_mins', 'Option<u64>', 'The same in minutes.'],
['every_mins', 'u64', 'How often it is checked, all things considered.'],
['last_checked', 'Option<i64>', ''], ['next_check', 'Option<i64>', ''],
['last_error', 'Option<String>', ''],
['failing', 'Option<FailingRow>', 'Set once an error has held for a day.'],
['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''],
['subscribers', 'i64', 'People on this server, you included.'],
['pinned', 'bool', 'At the top of your list.'],
['kept', 'bool', 'From an OPML, and yours to keep as a feed of its own should the OPML drop it or you leave the OPML.'],
['private', 'bool', 'Kept out of the directory.'],
['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'],
] },
{ name: 'FailingRow', doc: 'Why a feed has been failing.', fields: [['reason', 'String', 'In words.'], ['new_url', 'Option<String>', 'Where it seems to have moved.']] },
{ name: 'NewFeed', doc: 'A feed to subscribe to.', fields: [
['url', 'String', 'A feed’s address, an OPML list’s, or a site’s page that links its feed.'],
['folder', 'Option<String>', ''],
['keywords', 'Vec<String>', 'Download only episodes that mention one. Empty for all.'],
['allow_explicit', 'bool', 'Download explicit episodes too. False if left out.'],
] },
{ name: 'Added', doc: 'A feed subscribed to.', fields: [['id', 'String', 'Its id here.'], ['existing', 'bool', 'You subscribed to it already.']] },
{ name: 'FeedPatch', doc: 'What <code>PATCH /api/feeds/{id}</code> changes. Leave out what stays; <code>null</code> clears a field that can be cleared.', fields: [
['keywords', 'Option<Vec<String>>', 'Yours.'], ['blocked', 'Option<Vec<String>>', 'Yours.'],
['allow_explicit', 'Option<bool>', 'Yours.'], ['auto_download', 'Option<bool>', 'Yours.'],
['max_new_per_check', 'Option<Option<usize>>', 'Yours; <code>null</code> for the server’s.'],
['pinned', 'Option<bool>', 'Yours.'],
['kept', 'Option<bool>', 'Yours: keep a show from an OPML should the OPML drop it.'],
['deadair', 'Option<Option<bool>>', 'Yours; <code>null</code> follows its OPML list’s.'],
['deadair_pause', 'Option<i64>', 'Yours: 200, 300, 400 or 500.'],
['private', 'Option<bool>', 'Anyone subscribed may mark it private; only an admin unmarks it.'],
['url', 'Option<String>', 'Admin.'], ['schedule', 'Option<Option<String>>', 'Admin.'],
['folder', 'Option<Option<String>>', 'Admin.'], ['category', 'Option<Option<String>>', 'Admin.'],
] },
{ name: 'Marked', doc: '', fields: [['marked', 'i64', 'How many episodes were marked.']] },
{ name: 'HowMany', doc: '', fields: [['count', 'i64', 'From 1 to 100; 5 if left out.']] },
{ name: 'Queued', doc: '', fields: [['queued', 'usize', 'How many files were queued.']] },
{ name: 'FetchBody', doc: 'What to check.', fields: [['feed', 'Option<String>', 'One feed; all of yours if left out.'], ['force', 'bool', 'Check even if it is not due.']] },
{ name: 'OpmlBody', doc: '', fields: [['xml', 'String', 'The OPML file’s text.']] },
{ name: 'Imported', doc: '', fields: [['added', 'usize', ''], ['already', 'usize', 'Feeds you had.']] },
{ name: 'Page', doc: 'Which episodes, as query parameters.', fields: [
['offset', 'i64', '0 if left out.'], ['limit', 'i64', '50 if left out.'],
['filter', 'Option<String>', '<code>unread</code>, <code>downloaded</code> or <code>flagged</code> (kept).'],
['q', 'Option<String>', 'Words to look for in titles and show notes.'],
['sort', 'Option<String>', '<code>published</code>, <code>title</code>, <code>feed</code>, <code>type</code>, <code>size</code> or <code>kept</code>. Newest first if left out.'],
['dir', 'Option<String>', '<code>asc</code> or <code>desc</code>.'],
] },
{ name: 'EntryPage', doc: 'A page of episodes.', fields: [['total', 'i64', 'How many match, over every page.'], ['entries', 'Vec<EntryRow>', '']] },
{ name: 'EntryRow', doc: 'An episode or post, with your state for it.', fields: [
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
['published', 'Option<i64>', ''],
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
['markdown', 'Option<String>', 'The item’s Markdown as written, when its feed sends Markdown beside its HTML (RSS’s source:markdown).'],
['markdown_html', 'Option<String>', 'That Markdown made HTML, sanitized as description is.'],
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
['position', 'i64', 'Where you are in it, in seconds.'],
['enclosures', 'Vec<EncRow>', 'Its files.'],
] },
{ name: 'EncRow', doc: 'An episode’s file.', fields: [
['id', 'i64', 'What <code>/media/{id}</code> and the file routes take.'],
['feed_id', 'String', ''], ['guid', 'String', ''], ['url', 'String', 'Where the publisher has it.'],
['mime', 'Option<String>', ''], ['length', 'Option<i64>', 'Bytes.'],
['path', 'Option<String>', 'Where it is on the server, once downloaded.'],
['state', 'String', '<code>pending</code> (a check will download it), <code>held</code> (back catalogue, downloaded on request), <code>downloading</code>, <code>done</code>, <code>skipped</code> (a filter or a failure) or <code>reaped</code> (deleted).'],
['last_error', 'Option<String>', ''],
['deadair', 'Option<i64>', 'Seconds DeadAir saves, when you play it shortened. Absent otherwise.'],
['deadair_pending', 'bool', 'DeadAir is still working on it. Absent when false.'],
] },
{ name: 'Flags', doc: '', fields: [['read', 'Option<bool>', ''], ['flagged', 'Option<bool>', 'Kept.']] },
{ name: 'Position', doc: '', fields: [['secs', 'i64', 'Seconds in.'], ['duration', 'Option<i64>', 'The length the player measured, for an episode whose feed gives none.']] },
{ name: 'Force', doc: 'A query parameter.', fields: [['force', 'bool', 'Delete it even though someone else wants it.']] },
{ name: 'PopularRow', doc: 'A feed in the directory. Never its address: subscribe by its id.', fields: [
['id', 'String', 'The directory’s id for it.'], ['title', 'Option<String>', ''], ['image', 'Option<String>', ''],
['subscribers', 'i64', 'On every server sharing with the directory.'],
['subscribed', 'bool', 'Yours already.'], ['feed', 'Option<String>', 'Its id here, when it is yours.'],
['category', 'Option<String>', ''], ['subcategory', 'Option<String>', ''],
['podcast', 'bool', 'Has audio or video.'],
] },
{ name: 'Listed', doc: 'A feed’s page in the directory.', fields: [
['description', 'Option<String>', 'What it says it is, sanitized.'],
['failing', 'Option<String>', 'Why its last check failed, when it did.'],
['items', 'Vec<ListedItem>', 'Its latest twenty.'],
['can_admin', 'bool', 'You may remove or refresh it.'],
] },
{ name: 'ListedItem', doc: '', fields: [['guid', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''], ['published', 'Option<i64>', ''], ['duration', 'Option<i64>', ''], ['description', 'Option<String>', 'Sanitized.']] },
{ name: 'Subscribed', doc: '', fields: [['id', 'String', 'The feed’s id here.']] },
{ name: 'Status', doc: '', fields: [['feeds', 'usize', ''], ['pending', 'usize', 'Files a check will download.'], ['downloaded', 'usize', ''], ['version', 'String', '']] },
{ name: 'Event', doc: 'One live update. <code>ev</code> says which, and the rest depends on it.', fields: [
['feed_start', '{ feed }', 'A feed is being checked.'],
['feed_skip', '{ feed }', 'Not due.'],
['feed_done', '{ feed, new, downloaded, failed, torrents }', ''],
['feed_error', '{ feed, msg }', ''],
['feed_row', '{ row: FeedRow }', 'A feed you subscribe to changed.'],
['progress', '{ feed, enclosure, file, done, total }', 'Bytes of a download, in whole percents.'],
['download_done', '{ feed, enclosure, path, bytes }', ''],
['download_error', '{ feed, enclosure, msg, url }', ''],
['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'],
['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'],
['signal_hits', '{ user, hits }', 'Items were picked out for someone’s Signals; theirs to fetch again.'],
] },
{ name: 'Settings', doc: 'The server’s settings.', fields: [
['schedule', 'String', 'How often feeds are checked, such as <code>every 60m</code>.'], ['every_mins', 'u64', 'The same in minutes.'],
['max_new_per_check', 'usize', 'The newest episodes a check downloads, a feed.'],
['media_types', 'Vec<String>', 'What is downloaded, such as <code>audio</code>, <code>video</code>.'],
['download_dir', 'String', ''], ['max_total_gb', 'f64', 'Disk to use; 0 for no limit.'],
['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'],
['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'],
['directory_share', 'bool', ''],
['signals_on_server', 'bool', 'Everyone’s Signals are judged on the server’s TypeSafe key, not each person’s.'],
['signal_days', 'u64', 'How many days back a new Signal reads; 7 unless an admin sets it.'],
['typesafe_server_key', 'bool', 'The server has a TypeSafe key (<code>TYPESAFE_KEY</code>); without one, <code>signals_on_server</code> changes nothing.'],
] },
{ name: 'SettingsPatch', doc: 'What <code>PATCH /api/settings</code> changes. Leave out what stays.', fields: [
['schedule', 'Option<String>', ''], ['max_new_per_check', 'Option<usize>', ''], ['media_types', 'Option<Vec<String>>', ''],
['max_total_gb', 'Option<f64>', ''], ['max_age_days', 'Option<u64>', ''], ['art_cache_mb', 'Option<u64>', ''],
['directory_url', 'Option<String>', 'Empty for no directory.'], ['directory_token', 'Option<String>', 'Replaces it; empty clears it.'],
['directory_share', 'Option<bool>', ''],
['signals_on_server', 'Option<bool>', 'Judge everyone’s Signals on the server’s TypeSafe key.'],
['signal_days', 'Option<u64>', '1 to 365.'],
] },
{ name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option<i64>', ''], ['last_login', 'Option<i64>', '']] },
{ name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] },
{ name: 'UserPatch', doc: '', fields: [['admin', 'bool', '']] },
{ name: 'LogQuery', doc: 'Query parameters.', fields: [['after', 'u64', 'The last <code>seq</code> you have; 0 for the newest lines.'], ['limit', 'usize', '200 if left out.']] },
{ name: 'LogPage', doc: '', fields: [['lines', 'Vec<LogLine>', ''], ['latest', 'u64', 'The newest <code>seq</code>, to ask after next time.']] },
{ name: 'LogLine', doc: '', fields: [['seq', 'u64', ''], ['ts', 'i64', ''], ['level', 'String', ''], ['target', 'String', ''], ['msg', 'String', '']] },
{ name: 'DirectoryStatus', doc: '', fields: [
['configured', 'bool', 'An address and token are set.'], ['name', 'Option<String>', 'The token’s name, as the directory knows it.'],
['access', 'Option<String>', '<code>ro</code>, <code>rw</code> or <code>rwa</code>.'], ['error', 'Option<String>', 'Why the directory would not say.'],
['shared', 'Option<SnapshotResult>', 'How the last share went.'],
] },
{ name: 'DirectoryAdmin', doc: '', fields: [['instances', 'Vec<Instance>', 'Servers sharing.'], ['blocked', 'Vec<BlockedRow>', '']] },
{ name: 'BlockedRow', doc: 'A feed the directory keeps out. Never its address.', fields: [['id', 'String', ''], ['title', 'Option<String>', ''], ['at', 'i64', 'When.']] },
{ name: 'Instance', doc: 'A server sharing with the directory.', fields: [['name', 'String', 'Its token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] },
{ name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', 'Addresses that look like someone’s paid access.'], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] },
],
};
export const directory = {
name: 'ipx_directory',
label: 'ipx-directory',
root: '/docs/api/directory',
changelog: 'crates/directory/CHANGELOG.md',
intro: `<p>The HTTP API of the directory of public podcasts that iPX servers share, at
<code>${D}/v1</code>. Its pages at <a href="${D}">${D.replace('https://', '')}</a> are anyone’s
to browse; the API needs a token for everything.</p>
<h2 id="auth">Tokens</h2>
<pre><code>Authorization: Bearer &lt;token&gt;</code></pre>
<p>A token has a name and one of three levels of access: <code>ro</code> reads,
<code>rw</code> also shares, and <code>rwa</code> also removes and blocks feeds and drops a
server’s share. Tokens are given out by whoever runs the directory, and none can be made or
revoked over the API.</p>
<h2 id="errors">Errors</h2>
<p>A request that fails answers with its status and <code>{"error": "…"}</code>:
<code>401</code> no token or an unknown one, <code>403</code> a token without the access,
<code>404</code> not listed.</p>
<h2 id="ids">Ids</h2>
<p>A feed’s <code>id</code> is the first 16 hex digits of the SHA-256 of its address with the
scheme and host in lower case and no default port or fragment, so every server arrives at the
same one. Descriptions are the publisher’s own HTML, unsanitized: sanitize them before you
show them.</p>`,
groups: [
{
id: 'feeds', name: 'feeds', title: 'Feeds',
summary: 'What the directory lists.',
items: [
{
method: 'GET', path: '/v1/me', slug: 'get-me', access: 'ro',
summary: 'The token’s name and access.', returns: 'Me', example: dirCurl('GET', '/v1/me'),
},
{
method: 'GET', path: '/v1/feeds', slug: 'get-feeds', access: 'ro',
summary: 'Every feed listed, A to Z.', returns: 'Vec<Listing>', example: dirCurl('GET', '/v1/feeds'),
},
{
method: 'GET', path: '/v1/popular', slug: 'get-popular', access: 'ro',
summary: 'The most subscribed feeds.', query: 'Limit', returns: 'Vec<Listing>',
example: dirCurl('GET', '/v1/popular?limit=5'),
},
{
method: 'GET', path: '/v1/feeds/{id}', slug: 'get-feed', access: 'ro',
summary: 'One feed, with its description and latest twenty items.',
pathParams: [['id', 'String', 'The feed’s id.']],
returns: 'FeedPage', errors: [['404 Not Found', 'Not listed.']],
},
{
method: 'GET', path: '/healthz', slug: 'get-healthz', access: 'none',
summary: '<code>ok</code> when the directory can read its database.', returns: 'text/plain',
},
],
},
{
id: 'share', name: 'share', title: 'Sharing',
summary: 'A server telling the directory what its people follow.',
items: [
{
method: 'POST', path: '/v1/snapshot', slug: 'post-snapshot', access: 'rw',
summary: 'Share: every public feed this server’s people follow, and how many follow each.',
doc: '<p>Replaces everything this token shared before, so send the whole list each time. At most 20,000 feeds. Addresses that look private (a login, a key in the query, a paid-feed host) are refused and counted, never stored. A feed is listed once the directory has read it as one.</p>',
body: 'Vec<SnapshotEntry>', returns: 'SnapshotResult',
errors: [['413 Payload Too Large', 'More than 20,000 feeds.']],
example: dirCurl('POST', '/v1/snapshot', '[{"url":"https://feeds.99percentinvisible.org/99percentinvisible","subscribers":3}]'),
},
],
},
{
id: 'admin', name: 'admin', title: 'Administering it',
summary: 'Removing and blocking feeds, and the servers sharing. <code>rwa</code> tokens only.',
items: [
{
method: 'DELETE', path: '/v1/feeds/{id}', slug: 'delete-feed', access: 'rwa',
summary: 'Remove a feed and block it, so no server’s share brings it back.',
pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content',
},
{
method: 'POST', path: '/v1/feeds/{id}/refresh', slug: 'post-refresh', access: 'rwa',
summary: 'Check a feed now, answering with its page.',
pathParams: [['id', 'String', 'The feed’s id.']], returns: 'FeedPage',
},
{
method: 'GET', path: '/v1/blocked', slug: 'get-blocked', access: 'rwa',
summary: 'The feeds kept out.', returns: 'Vec<Blocked>',
},
{
method: 'DELETE', path: '/v1/blocked/{id}', slug: 'delete-blocked', access: 'rwa',
summary: 'Let a blocked feed back in.',
pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content',
},
{
method: 'GET', path: '/v1/instances', slug: 'get-instances', access: 'rwa',
summary: 'Each token holding a share, with how many feeds and subscribers it gave.',
returns: 'Vec<Instance>',
},
{
method: 'DELETE', path: '/v1/instances/{name}', slug: 'delete-instance', access: 'rwa',
summary: 'Drop one token’s share, its counts with it, until it next shares.',
doc: '<p>To keep a server out for good, revoke its token instead.</p>',
pathParams: [['name', 'String', 'The token’s name.']], returns: '204 No Content',
},
],
},
],
types: [
{ name: 'Me', doc: 'A token, as the directory knows it.', fields: [['name', 'String', ''], ['access', 'Access', '']] },
{ name: 'Access', doc: 'A token’s access: <code>"ro"</code>, <code>"rw"</code> or <code>"rwa"</code>.', fields: [] },
{ name: 'Limit', doc: 'A query parameter.', fields: [['limit', 'Option<u64>', 'From 1 to 100; 10 if left out.']] },
{ name: 'Listing', doc: 'A feed listed.', fields: [
['id', 'String', ''], ['url', 'String', 'Its address, to subscribe to anywhere.'], ['title', 'Option<String>', ''], ['image', 'Option<String>', ''],
['category', 'Option<String>', 'Apple’s category, from the feed’s own.'], ['subcategory', 'Option<String>', ''],
['podcast', 'bool', 'Audio or video in its latest items.'],
['subscribers', 'i64', 'Summed over every server’s latest share.'],
] },
{ name: 'FeedPage', doc: 'A feed’s page: every field of <a href="/docs/api/directory/types/Listing"><code>Listing</code></a>, and these.', fields: [
['description', 'Option<String>', 'The feed’s own HTML, unsanitized.'],
['failing', 'Option<String>', 'Why its last check failed, in words.'],
['items', 'Vec<Item>', 'Its latest twenty.'],
] },
{ name: 'Item', doc: 'An episode or post. Never its file.', fields: [['guid', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''], ['published', 'Option<i64>', 'Unix seconds.'], ['duration', 'Option<i64>', 'Seconds.'], ['description', 'Option<String>', 'Unsanitized.']] },
{ name: 'SnapshotEntry', doc: 'One feed in a share.', fields: [['url', 'String', ''], ['subscribers', 'i64', ''], ['category', 'Option<String>', 'A category for a feed that names none itself.']] },
{ name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', ''], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] },
{ name: 'Blocked', doc: '', fields: [['id', 'String', ''], ['url', 'Option<String>', ''], ['title', 'Option<String>', ''], ['at', 'i64', 'When.']] },
{ name: 'Instance', doc: '', fields: [['name', 'String', 'The token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] },
],
};

14
site/build.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# ipxreader.app: the page and its stylesheet, and the typeface, logo and icon from web/, into
# site/dist, which Cloudflare Pages serves.
set -e
here=$(dirname "$0")
rm -rf "$here/dist"
mkdir -p "$here/dist/fonts"
cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/"
# The reader's own typeface, the same file ipx-web serves.
cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
cp -r "$here/shots" "$here/dist/"
# The docs: the guides and the API references, generated (#223).
node "$here/gen.mjs"

24
site/docs.mjs Normal file
View File

@@ -0,0 +1,24 @@
// The guides: a page each, from site/guides, with the docs' front page written here (#223).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const read = name => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
const front = `<h1>Docs</h1>
<p class="lede">How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.</p>
<dl class="cards">
<div><dt><a href="/docs/install">Install ipx and ipx-web</a></dt><dd>Build the two containers, start them, sign in and add people.</dd></div>
<div><dt><a href="/docs/cli">The command line</a></dt><dd>The <code>ipx</code> commands, and the control socket a program on the same machine can use.</dd></div>
<div><dt><a href="/docs/directory">The directory</a></dt><dd>Browsing it, connecting your iPX to it, and running one of your own.</dd></div>
<div><dt><a href="/docs/api/ipx/">ipx API reference</a></dt><dd>Every endpoint of an iPX server, what it takes and what it gives.</dd></div>
<div><dt><a href="/docs/api/directory/">ipx-directory API reference</a></dt><dd>The directory's API, for reading what it lists and sharing with it.</dd></div>
</dl>`;
export const guides = [
{ href: '/docs/', file: 'docs/index.html', name: 'Overview', title: 'iPX docs', description: 'How to run iPX on your own server, and how to use its API and the directory’s.', body: front },
{ href: '/docs/install', file: 'docs/install.html', name: 'Install', title: 'Install ipx and ipx-web - iPX docs', description: 'Run iPX and its page on your own server with Docker.', body: read('install.html') },
{ href: '/docs/cli', file: 'docs/cli.html', name: 'Command line', title: 'The command line - iPX docs', description: 'The ipx commands, and its control socket.', body: read('cli.html') },
{ href: '/docs/directory', file: 'docs/directory.html', name: 'Directory', title: 'The directory - iPX docs', description: 'Browse the directory, connect your iPX to it, or run your own.', body: read('directory.html') },
];

212
site/gen.mjs Normal file
View File

@@ -0,0 +1,212 @@
// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each
// service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its
// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type.
// No script: the site's Content-Security-Policy allows none of its own.
//
// node site/gen.mjs (site/build.sh runs it)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ipx, directory } from './api.mjs';
import { guides } from './docs.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(here, 'dist');
const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main';
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/// The version a reference documents: its service's last release, from its changelog, not the
/// -dev version between releases.
function released(changelog) {
const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m);
return m ? m[1] : '';
}
function write(rel, html) {
const file = path.join(dist, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, html);
}
function shell({ title, description, sidebar, body, current }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<meta name="description" content="${esc(description)}">
<meta name="color-scheme" content="light">
<link rel="icon" href="/favicon.png">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<header class="bar">
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
<nav aria-label="Elsewhere">
<a href="/docs/" aria-current="true">Docs</a>
<a href="https://directory.ipxreader.app">Directory</a>
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
</nav>
</header>
<div class="docs">
<aside class="side">${sidebar}</aside>
<main class="doc">${body}</main>
</div>
<footer>
<p>iPX is free and open source under the MIT licence.</p>
</footer>
</body>
</html>
`;
}
/* ---------------- the guides ---------------- */
const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']];
function guideSidebar(cur) {
const li = (href, name, on) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
return `<nav aria-label="Docs">
<h2 class="side-h">Guides</h2>
<ul>${guides.map(g => li(g.href, g.name, g.href === cur)).join('')}</ul>
<h2 class="side-h">API reference</h2>
<ul>${REFS.map(([r, label]) => li(r.root + '/', label, false)).join('')}</ul>
</nav>`;
}
for (const g of guides) {
write(g.file, shell({ title: g.title, description: g.description, sidebar: guideSidebar(g.href), body: g.body, current: 'docs' }));
}
/* ---------------- the references ---------------- */
const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
function reference(api) {
const label = api.label || api.name;
const version = released(api.changelog);
const typeNames = new Set(api.types.map(t => t.name));
const typeHref = n => `${api.root}/types/${n}`;
const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`;
const fnName = it => it.slug.replace(/-/g, '_');
// A Rust type, its names that are types of this API linked.
const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
const access = it => it.access || 'signed in';
// Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up.
const usedBy = new Map();
for (const g of api.groups) for (const it of g.items) {
for (const s of [it.body, it.query, it.returns].filter(Boolean)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) {
if (!usedBy.has(n)) usedBy.set(n, []);
if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]);
}
}
}
// Types inside types: a type used only as a field is reached from the type that holds it.
for (const t of api.types) for (const [, ft] of t.fields) {
for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) {
if (!usedBy.has(n)) usedBy.set(n, []);
if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]);
}
}
const crumbs = (...parts) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
const top = `<a class="crate" href="${api.root}/"><img src="/logo.svg" alt="" width="40" height="40"><span><b>${esc(label)}</b>${version ? `<span class="ver">${version}</span>` : ''}</span></a>`;
const back = `<p class="side-back"><a href="/docs/">All docs</a></p>`;
function sidebar({ group, item, type } = {}) {
let s = top;
if (group) {
s += `<h2 class="side-h">Module <a href="${api.root}/${group.id}/">${group.name}</a></h2>
<ul class="items">${group.items.map(it => `<li><a class="${METHOD_CLASS[it.method]}" href="${itemHref(group, it)}" title="${it.method} ${esc(it.path)}"${it === item ? ' aria-current="page"' : ''}><span class="verb">${it.method}</span> ${fnName(it)}</a></li>`).join('')}</ul>`;
}
s += `<h2 class="side-h">Modules</h2><ul>${api.groups.map(g => `<li><a href="${api.root}/${g.id}/"${g === group && !item ? ' aria-current="page"' : ''}>${g.name}</a></li>`).join('')}</ul>`;
s += `<h2 class="side-h">Types</h2><ul class="types">${api.types.map(t => `<li><a class="t" href="${typeHref(t.name)}"${t === type ? ' aria-current="page"' : ''}>${t.name}</a></li>`).join('')}</ul>`;
return `<nav aria-label="${esc(label)} reference">${s}</nav>${back}`;
}
const page = (rel, title, description, side, body) =>
write(rel, shell({ title, description, sidebar: side, body, current: 'docs' }));
// The root: what the crate page shows on docs.rs, its modules and types.
page(`${api.root.slice(1)}/index.html`, `${label} API`, `The HTTP API of ${label}, endpoint by endpoint.`, sidebar(),
`<h1 class="item"><span class="kind">API</span> <span class="name">${esc(label)}</span>${version ? ` <span class="ver">${version}</span>` : ''}</h1>
${api.intro}
<h2 id="modules">Modules</h2>
<dl class="list">${api.groups.map(g => `<dt><a href="${api.root}/${g.id}/">${g.name}</a></dt><dd>${g.summary}</dd>`).join('')}</dl>
<h2 id="types">Types</h2>
<dl class="list">${api.types.map(t => `<dt><a class="t" href="${typeHref(t.name)}">${t.name}</a></dt><dd>${t.doc || ''}</dd>`).join('')}</dl>`);
for (const g of api.groups) {
page(`${api.root.slice(1)}/${g.id}/index.html`, `${api.name}::${g.name}`, g.title, sidebar({ group: g }),
`${crumbs()}<h1 class="item"><span class="kind">Module</span> ${esc(api.name)}::<span class="name">${g.name}</span></h1>
<p>${g.summary}</p>
<h2 id="endpoints">Endpoints</h2>
<dl class="list">${g.items.map(it => `<dt><a class="${METHOD_CLASS[it.method]}" href="${itemHref(g, it)}"><span class="verb">${it.method}</span> ${esc(it.path)}</a></dt><dd>${it.summary}</dd>`).join('')}</dl>`);
for (const it of g.items) {
const decl = [
`<span class="verb ${METHOD_CLASS[it.method]}">${it.method}</span> ${esc(it.path)}`,
it.query && ` query ${ty(it.query)}`,
it.body && ` body ${ty(it.body)}`,
` -&gt; ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`,
].filter(Boolean).join('\n');
const fieldsOf = n => api.types.find(t => t.name === n)?.fields || [];
const rows = list => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}
<h1 class="item"><span class="kind">Endpoint</span> ${esc(api.name)}::${g.name}::<span class="name fn">${fnName(it)}</span><a class="srclink" href="${SRC}/${api.name === 'ipx' ? 'crates/ipx/src/web.rs' : 'crates/directory/src/lib.rs'}">Source</a></h1>
<pre class="decl"><code>${decl}</code></pre>
<p class="summary">${it.summary}</p>
${it.doc || ''}
<p class="access">Access: <b>${esc(access(it))}</b></p>`;
if (it.pathParams) body += `<h2>Path parameters</h2>${rows(it.pathParams)}`;
if (it.query) body += `<h2>Query</h2><p>${ty(it.query)}, as query parameters:</p>${rows(fieldsOf(it.query))}`;
if (it.body) body += `<h2>Body</h2><p>${ty(it.body)}, as JSON.</p>`;
body += `<h2>Returns</h2><p>${isType(it.returns) ? `${ty(it.returns)}, as JSON.` : esc(it.returns)}</p>`;
if (it.errors?.length) body += `<h2>Errors</h2><dl class="fields">${it.errors.map(([c, d]) => `<dt><code>${esc(c)}</code></dt><dd>${d}</dd>`).join('')}</dl>`;
if (it.example) body += `<h2>Example</h2><pre><code>${esc(it.example)}</code></pre>`;
page(`${api.root.slice(1)}/${g.id}/${it.slug}.html`, `${it.method} ${it.path} - ${label}`, it.summary.replace(/<[^>]+>/g, ''), sidebar({ group: g, item: it }), body);
}
}
page(`${api.root.slice(1)}/types/index.html`, `${api.name}::types`, `The types ${label}'s API takes and gives.`, sidebar(),
`${crumbs()}<h1 class="item"><span class="kind">Module</span> ${esc(api.name)}::<span class="name">types</span></h1>
<dl class="list">${api.types.map(t => `<dt><a class="t" href="${typeHref(t.name)}">${t.name}</a></dt><dd>${t.doc || ''}</dd>`).join('')}</dl>`);
for (const t of api.types) {
const decl = t.name === 'Event'
? `enum Event {\n${t.fields.map(([n, f]) => ` ${n} ${ty(f)},`).join('\n')}\n}`
: t.fields.length
? `struct ${t.name} {\n${t.fields.map(([n, f]) => ` ${n}: ${ty(f)},`).join('\n')}\n}`
: `enum ${t.name}`;
const uses = usedBy.get(t.name) || [];
let body = `${crumbs([`${api.root}/types/`, 'types'])}
<h1 class="item"><span class="kind">${t.name === 'Event' || !t.fields.length ? 'Enum' : 'Struct'}</span> ${esc(api.name)}::types::<span class="name t">${t.name}</span></h1>
<pre class="decl"><code>${decl}</code></pre>
${t.doc ? `<p class="summary">${t.doc}</p>` : ''}`;
if (t.fields.length) {
body += `<h2>${t.name === 'Event' ? 'Variants' : 'Fields'}</h2><dl class="fields">${t.fields.map(([n, f, d]) =>
`<dt id="${n}"><a class="anchor" href="#${n}" aria-label="Link to ${esc(n)}">§</a><code>${esc(n)}${t.name === 'Event' ? ' ' : ': '}${ty(f)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
}
if (uses.length) {
body += `<h2>Used by</h2><ul class="uses">${uses.map(([g, x]) => g
? `<li><a class="${METHOD_CLASS[x.method]}" href="${itemHref(g, x)}"><span class="verb">${x.method}</span> ${esc(x.path)}</a></li>`
: `<li><a class="t" href="${typeHref(x.name)}">${x.name}</a></li>`).join('')}</ul>`;
}
page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body);
}
// Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a
// name in plain text, which looks right and leads nowhere.
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) {
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
}
}
reference(ipx);
reference(directory);

1
site/gitea.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" fill="#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" fill="#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" fill="#609926"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

22
site/guides/cli.html Normal file
View File

@@ -0,0 +1,22 @@
<h1>The command line</h1>
<p>The same <code>ipx</code> program that runs the server is its command line. Run it in the container with <code>docker exec ipx ipx …</code>; when the server is running, it does the work and the command shows what happened.</p>
<div class="scroll"><table>
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>ipx list</code></td><td>Every podcast and its state</td></tr>
<tr><td><code>ipx status</code></td><td>How many podcasts, how many files waiting and downloaded</td></tr>
<tr><td><code>ipx fetch [id] [--force]</code></td><td>Check everything that is due, or one podcast; <code>--force</code> checks it even if it is not due</td></tr>
<tr><td><code>ipx add &lt;url&gt;</code>, <code>ipx rm &lt;id&gt;</code></td><td>Add a podcast, or take it out</td></tr>
<tr><td><code>ipx import &lt;file&gt;</code>, <code>ipx export &lt;file&gt;</code></td><td>OPML in or out</td></tr>
<tr><td><code>ipx reap [--dry-run]</code></td><td>Clear old files now, to stay within the space you set</td></tr>
<tr><td><code>ipx user add|list|passwd|rm</code></td><td>Accounts</td></tr>
</tbody>
</table></div>
<h2>The control socket</h2>
<p>A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:</p>
<pre><code>printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
{"ev":"feed_start","feed":"atp"}
{"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}</code></pre>
<p>The commands are <code>fetch</code> (optional <code>feed</code>, <code>force</code>), <code>download</code> (<code>enclosure</code>), <code>reap</code> (optional <code>dry_run</code>) and <code>status</code>.</p>

View File

@@ -0,0 +1,32 @@
<h1>The directory</h1>
<p>The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.</p>
<h2>Browsing it</h2>
<p>Anyone can browse <a href="https://directory.ipxreader.app">directory.ipxreader.app</a> without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.</p>
<h2>Connecting your iPX</h2>
<p>In iPX, an admin opens Admin, Directory and enters the directory's address, <code>https://directory.ipxreader.app</code>, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.</p>
<p>Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.</p>
<h2>Its API</h2>
<p>Anything the pages show, a program can read through the directory's API with a token: see the <a href="/docs/api/directory/">ipx-directory API reference</a>.</p>
<h2>Running your own</h2>
<p>The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:</p>
<pre><code>docker build -f Dockerfile.directory -t ipx-directory .</code></pre>
<pre><code>services:
ipx-directory:
image: ipx-directory
container_name: ipx-directory
restart: unless-stopped
environment:
DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
ports:
- "8100:8100"
volumes:
- ./directory:/config:ro # directory.toml</code></pre>
<pre><code># directory/directory.toml
bind = "0.0.0.0:8100"
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every twelve hours.</p>

68
site/guides/install.html Normal file
View File

@@ -0,0 +1,68 @@
<h1>Install ipx and ipx-web</h1>
<p class="lede">iPX is two containers: <strong>ipx</strong>, which checks your podcasts, downloads them and answers the API, and <strong>ipx-web</strong>, which serves the page and passes the API on to ipx.</p>
<p>You need Docker with Compose. There are no published images yet, so you build both from the source:</p>
<pre><code>git clone https://git.sdf1.net/rays/ipx.git
cd ipx
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .</code></pre>
<p>The repository has a sample <code>docker-compose.yml</code> that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:</p>
<pre><code>services:
ipx:
image: ipx
container_name: ipx
restart: unless-stopped
environment:
PUID: "99" # who owns the files it writes
PGID: "100"
TZ: "UTC"
ports:
- "6881:6881/tcp" # BitTorrent
- "6881:6881/udp"
volumes:
- ./config:/config # config.toml, written on first start
- ./data:/data # the database
- ./downloads:/downloads
ipx-web:
image: ipx-web
container_name: ipx-web
restart: unless-stopped
environment:
IPX_UPSTREAM: "ipx:8099"
ports:
- "8099:8080" # the page
depends_on:
- ipx</code></pre>
<pre><code>docker compose up -d</code></pre>
<p>ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open <code>http://&lt;your server&gt;:8099</code> and sign in as <strong>admin</strong> with the password <strong>ipodderx</strong>, then change it:</p>
<pre><code>echo -n 'a good password' | docker exec -i ipx ipx user passwd admin</code></pre>
<p>The password is read from what you pipe in, so it stays out of your shell's history.</p>
<h2>Adding people</h2>
<p>An admin adds people in Admin, Accounts, or from the command line:</p>
<pre><code>echo -n 'their password' | docker exec -i ipx ipx user add sam</code></pre>
<p>Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.</p>
<h2>Settings</h2>
<p>How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. <code>/config/config.toml</code> holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.</p>
<div class="scroll"><table>
<thead><tr><th>Environment variable</th><th>What it does</th></tr></thead>
<tbody>
<tr><td><code>IPX_DATABASE_URL</code></td><td>A <code>postgres://user:password@host:5432/database</code> address, to keep everything in Postgres instead of SQLite</td></tr>
<tr><td><code>PUID</code>, <code>PGID</code></td><td>The user and group that own the files ipx writes</td></tr>
<tr><td><code>TYPESAFE_KEY</code></td><td>The server's own <a href="https://typesafe.ai">typesafe.ai</a> API key: it picks a podcast's Directory category and a Signal's icon, and, when an admin turns it on, pays for everyone's Signals</td></tr>
<tr><td><code>IPX_LOG_FORMAT</code></td><td><code>json</code> logs one JSON object a line, for Loki and the like</td></tr>
<tr><td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td><td>Sends traces of requests, checks and downloads to an OpenTelemetry collector; set <code>WEB_TRACING=on</code> on ipx-web too</td></tr>
<tr><td><code>IPX_UPSTREAM</code></td><td>On ipx-web: where ipx is, <code>ipx:8099</code> by default</td></tr>
</tbody>
</table></div>
<h2>Reaching it from outside</h2>
<p>The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; <a href="https://git.sdf1.net/rays/ipx/src/branch/main/docs/sso.md">single sign-on</a> says how.</p>
<h2>Updating</h2>
<pre><code>git pull
docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
docker compose up -d</code></pre>
<p>Your podcasts, accounts and downloads are in the three folders and are kept.</p>

138
site/index.html Normal file
View File

@@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>iPX</title>
<meta name="description" content="iPX checks your podcasts, downloads new episodes and gives everyone at home an account of their own, on your own server. The successor to iPodderX, free and open source.">
<meta name="color-scheme" content="light">
<link rel="icon" href="/favicon.png">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<header class="bar">
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
<nav aria-label="Elsewhere">
<a href="/docs/">Docs</a>
<a href="https://directory.ipxreader.app">Directory</a>
<a class="src" href="https://git.sdf1.net/rays/ipx" title="Source code on git.sdf1.net"><img src="/gitea.svg" alt="Source code" width="28" height="28"></a>
</nav>
</header>
<main>
<section class="hero">
<h1>iPX</h1>
<p class="intro">iPX checks your podcasts, downloads new episodes, and gives everyone at home their own account to listen from in the browser. It runs on your own server.</p>
<p class="acts">
<a class="btn primary" href="https://directory.ipxreader.app">Browse the directory</a>
</p>
</section>
<!-- The iPodderX logo is a radio's tuning scale and needle. Here the scale is full size and its
stations are shows from the directory; the needle sweeps once and settles. -->
<figure class="dial" aria-label="A radio dial tuned across shows in the iPX directory">
<div class="scale"><div class="track">
<ol class="freq" aria-hidden="true">
<li>88</li><li>92</li><li>96</li><li>100</li><li>104</li><li>108</li>
</ol>
<ul class="stations">
<li>99% Invisible</li>
<li>Hackaday</li>
<li>Clarkesworld</li>
<li>Accidental Tech Podcast</li>
<li>Daring Fireball</li>
<li class="tuned">Ologies</li>
<li>Oxide and Friends</li>
<li>xkcd</li>
<li>The Sporkful</li>
<li>Acquired</li>
<li>Construction Physics</li>
<li>Dwarkesh Podcast</li>
</ul>
<span class="needle" aria-hidden="true"></span>
</div></div>
</figure>
<!-- Each feature beside the part of the app that shows it, cut from a scratch iPX on public
podcasts, in a navy frame like the dial's. -->
<section class="does" aria-label="What it does">
<div class="feature">
<div class="say">
<h3>New episodes, ready to play</h3>
<p>iPX checks your podcasts through the day and downloads new episodes as they come out. Skip the ones you don't want by keyword, or leave out explicit ones. Old episodes are cleared to stay within the space you give it, but never one someone has pinned.</p>
</div>
<figure class="frame"><img class="cut" src="/shots/episodes.webp" width="904" height="283" loading="lazy"
alt="99% Invisible's episodes in a table with each one's size and date, and the downloaded one's file beside it, ready to play, save, share or delete."></figure>
</div>
<div class="feature">
<div class="say">
<h3>Pick up where you left off</h3>
<p>The player keeps your place in every episode, on every device.</p>
</div>
<figure class="frame"><img class="cut player" src="/shots/player.webp" width="950" height="80" loading="lazy"
alt="The player along the bottom of the page: the episode playing, back and forward, and how far in it is."></figure>
</div>
<!-- DeadAir: talk keeps its length, the quiet between it shrinks to a short pause, and the
episode ends sooner. CSS only; the page runs no script of its own. -->
<div class="feature">
<div class="say">
<h3>All talk, no dead air</h3>
<p>Radio has a name for silence on air: dead air. Turn on DeadAir for a show and its long silences shrink to a pause as short as you like. Everyone still talks at their own pace; the episode just ends sooner.</p>
</div>
<figure class="frame deadair" role="img" aria-label="A waveform whose silent gaps shrink to short pauses, so the episode ends sooner.">
<div class="wave" aria-hidden="true">
<span class="talk t1"></span><span class="hush"></span>
<span class="talk t2"></span><span class="hush"></span>
<span class="talk t3"></span><span class="hush"></span>
<span class="talk t4"></span><span class="hush"></span>
<span class="talk t5"></span><span class="hush"></span>
<span class="talk t6"></span>
</div>
</figure>
</div>
<div class="feature">
<div class="say">
<h3>Tune out the noise</h3>
<p>Your feeds bring in more than anyone has time for. A Signal listens across all of them for one subject: give it a name, the words to listen for, and a sentence saying what you mean if the words are not enough. What fits lands in a list of its own, starting with the last week, and it keeps listening. To use Signals you will need an API key from <a href="https://typesafe.ai">typesafe.ai</a>, unless your server's admin provides one.</p>
</div>
<figure class="frame"><img class="cut" src="/shots/signals.webp" width="874" height="398" loading="lazy"
alt="A Signal called Space, picked out of the noise of eight feeds: episodes and posts about galaxies, Artemis II, Mars and a rocket launch from Hackaday, Universe Today, NASA's Houston We Have a Podcast and Ars Technica."></figure>
</div>
<div class="feature">
<div class="say">
<h3>See what others listen to</h3>
<p>iPX servers can share which podcasts their listeners follow, and the directory gathers them in one place. <a href="https://directory.ipxreader.app">Browse the directory</a> to find something new and subscribe in one click.</p>
</div>
<figure class="frame"><img class="cut half" src="/shots/directory.webp" width="700" height="133" loading="lazy"
alt="The directory's most subscribed shows, each with its cover and how many people subscribe."></figure>
</div>
<dl class="also">
<div><dt>BitTorrent too</dt>
<dd>Some shows publish their episodes as torrents. iPX downloads them like any other, shares them back for as long as you choose, and a slow one never holds up the rest.</dd></div>
<div><dt>Subscribe to a whole list</dt>
<dd>Some people publish lists of podcasts they recommend, as OPML. Give iPX a list's address and it keeps a folder of those shows up to date as the list changes. Your own subscriptions move in and out the same way.</dd></div>
<div><dt>Sign in your way</dt>
<dd>Everyone signs in with a password, or with the single sign-on you already use, Cloudflare Access or Authentik. An admin adds people and decides who else is one.</dd></div>
<div><dt>Blogs too</dt>
<dd>Any RSS or Atom feed works, so the blogs you read sit beside the shows you listen to, and iPX keeps track of what you have read.</dd></div>
</dl>
</section>
<section class="then" aria-labelledby="then">
<h2 id="then">Since 2004</h2>
<p>iPodderX was one of the first podcatchers, a Mac app by Ray Slakinski and August Trometer, from the year podcasts got their name. iPX is that app rebuilt to run on your own server.</p>
</section>
</main>
<footer>
<p>iPX is free and open source under the MIT licence.</p>
</footer>
</body>
</html>

BIN
site/shots/directory.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
site/shots/episodes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
site/shots/player.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
site/shots/signals.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

196
site/site.css Normal file
View File

@@ -0,0 +1,196 @@
/* ipxreader.app. The palette is the iPodderX logo's: the radio's navy body, the sky of its
screen, the blue of its tuning scale, and the orange needle, which is the needle alone. The
typeface is the reader's own, Inter, the same file (web/InterVariable.woff2). */
@font-face{font-family:Inter;src:url(/fonts/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
:root{
--navy:#0c2238; /* the radio */
--ink:#16304a; /* body text, a step off the navy */
--muted:#46607a;
--sky:#dff0fb; /* its screen */
--page:#f1f8fd;
--scale:#2f6aa0; /* the tuning scale, and links */
--tick:#8fc2ea; /* the scale on the dark dial */
--needle:#ff5500;
--rule:#c6d9ea;
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
body{margin:0;background:var(--page);color:var(--ink);font:400 1.0625rem/1.6 Inter,system-ui,sans-serif}
a{color:var(--scale);text-underline-offset:.18em}
a:focus-visible,.btn:focus-visible{outline:3px solid var(--scale);outline-offset:3px;border-radius:6px}
main,.bar,footer{max-width:72rem;margin:0 auto;padding-inline:1.5rem}
.bar{display:flex;align-items:center;justify-content:space-between;padding-block:1.25rem}
.home{display:flex;align-items:center;gap:.6rem;color:var(--navy);text-decoration:none;font-weight:800;font-size:1.25rem}
.home img{border-radius:9px;display:block}
.bar nav{display:flex;gap:1.5rem}
.bar nav a{color:var(--navy);text-decoration:none;font-weight:600}
.bar nav a:hover{text-decoration:underline}
.bar nav{align-items:center}
.src img{display:block}
.hero{padding-block:3rem 2.5rem;max-width:46rem}
h1{margin:0;color:var(--navy);font-weight:900;font-size:clamp(6.5rem,24vw,15rem);line-height:.82;letter-spacing:-.065em}
.intro{margin:1.75rem 0 0;max-width:38rem;color:var(--muted);font-size:1.125rem}
.acts{display:flex;flex-wrap:wrap;gap:.75rem;margin:2rem 0 0}
.btn{display:inline-block;padding:.8rem 1.35rem;border:2px solid var(--navy);border-radius:999px;color:var(--navy);text-decoration:none;font-weight:700}
.btn:hover{background:var(--sky)}
.btn.primary{background:var(--navy);color:#fff}
.btn.primary:hover{background:#1a3a5c}
/* The dial: a frequency row, minor and major ticks, stations on two staggered rows as a printed
dial has them, and the needle, which points down at the scale and lights the station it stops
on rather than crossing its name. --at is where it stops: the tuned station's middle. */
.dial{margin:1rem 0 0}
.scale{background:var(--navy);border-radius:1.25rem;overflow:hidden}
.track{position:relative;--at:45.8%}
.freq{position:relative;list-style:none;margin:0;padding:1.1rem 4% 0;display:flex;justify-content:space-between;color:var(--tick);font-weight:600;font-size:.95rem;font-variant-numeric:tabular-nums}
.freq li{width:0;display:flex;justify-content:center} /* each number centred on its tick */
.freq::after{content:"";position:absolute;left:4%;right:4%;top:2.6rem;height:1.6rem;
background:
repeating-linear-gradient(90deg,var(--tick) 0 2px,transparent 2px 20%) 0 0/100% 100%,
repeating-linear-gradient(90deg,rgba(143,194,234,.55) 0 1px,transparent 1px 2%) 0 100%/100% 45%;
background-repeat:no-repeat}
.stations{list-style:none;margin:2.6rem 0 0;padding:0 1% 1.6rem;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));align-items:start;
color:#cfe3f4;font-weight:550;font-size:.84rem;line-height:1.2;letter-spacing:-.01em;text-align:center}
.stations li{padding-inline:.15rem}
.stations li:nth-child(even){margin-top:1.7rem}
.needle{position:absolute;top:2.35rem;height:2.7rem;left:var(--at);width:3px;margin-left:-1.5px;background:var(--needle);border-radius:2px;
box-shadow:0 0 0 1px rgba(12,34,56,.6);animation:tune 2.4s cubic-bezier(.3,.7,.3,1) .4s both}
.stations .tuned{text-decoration:underline 2px transparent;text-underline-offset:.3em;animation:lit .5s ease-out 2.6s both}
@keyframes tune{0%{left:3%}68%{left:calc(var(--at) + 7%)}86%{left:calc(var(--at) - 1.4%)}100%{left:var(--at)}}
@keyframes lit{from{color:#cfe3f4}to{color:#fff;font-weight:800;text-decoration-color:var(--needle)}}
@media (prefers-reduced-motion:reduce){.needle,.stations .tuned{animation-duration:0s;animation-delay:0s}}
h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4vw,2.4rem);line-height:1.1;letter-spacing:-.02em}
.does,.then{padding-block:4rem 0}
/* Each feature on one row: what it does beside the part of the app that shows it,
framed in the dial's navy at no more than its own size. */
.feature{display:grid;grid-template-columns:minmax(0,22rem) minmax(0,1fr);gap:clamp(1.5rem,5vw,4rem);align-items:center;padding-block:2.5rem}
.feature h3{margin:0 0 .5rem;color:var(--navy);font-weight:750;font-size:1.35rem;line-height:1.25}
.feature p{margin:0;max-width:34rem}
/* Every other row the picture comes first, so the page zigzags rather than running down one side. */
.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr) minmax(0,22rem)}
.feature:nth-of-type(even) .say{order:2}
.frame{margin:0;justify-self:center;max-width:100%;padding:clamp(.6rem,1.6vw,1.1rem);background:var(--navy);border-radius:1.1rem}
.frame img{display:block;max-width:100%;height:auto;border-radius:.55rem}
/* DeadAir: the track is the episode's full length; talk keeps its width, each silence lights
in the needle's orange and shrinks to a pause, and the end comes in from the right. */
.frame.deadair{justify-self:stretch;width:100%}
.wave{display:flex;align-items:center;height:4.5rem;border-radius:.55rem;background:rgba(143,194,234,.08)}
.talk{height:100%;background:url(/wave.svg) 0 50%/auto 100% repeat-x}
.talk:first-child{border-radius:.55rem 0 0 .55rem}
.t1{flex:0 0 14%}.t2{flex:0 0 19%}.t3{flex:0 0 9%}.t4{flex:0 0 16%}.t5{flex:0 0 12%}.t6{flex:0 0 10%}
.hush{flex:0 0 4%;align-self:stretch;background:linear-gradient(var(--tick),var(--tick)) 50%/100% 2px no-repeat;border-radius:.3rem;animation:hush 7s ease-in-out infinite}
@keyframes hush{
0%,14%{flex-basis:4%;background-color:transparent}
24%,30%{flex-basis:4%;background-color:rgba(255,85,0,.35)}
48%,86%{flex-basis:.8%;background-color:rgba(255,85,0,.35)}
100%{flex-basis:4%;background-color:transparent}}
@media (prefers-reduced-motion:reduce){.hush{animation:none;flex-basis:.8%;background-color:rgba(255,85,0,.35)}}
.also{margin:2rem 0 0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2.25rem 3.5rem}
.also dt{color:var(--navy);font-weight:750;font-size:1.2rem;line-height:1.25}
.also dd{margin:.45rem 0 0;max-width:34rem}
/* Docs (#223): a sidebar and a column, as docs.rs lays out a crate. The sidebar is the dial's
sky; an endpoint's name is the scale's blue, a type's green, and each method has its own
colour, every one AA on the page. Code to read is dark, as on the home page; a declaration is
light, as docs.rs shows one. On a phone the sidebar follows the page instead of leading it. */
.docs{max-width:72rem;margin:0 auto;padding-inline:1.5rem;display:grid;grid-template-columns:15rem minmax(0,1fr);gap:3rem;align-items:start}
.docs main.doc{max-width:50rem;margin:0;padding:0}
.side{position:sticky;top:1rem;max-height:calc(100vh - 2rem);overflow:auto;padding:1rem .75rem;background:var(--sky);border-radius:1rem;font-size:.92rem}
.side ul{list-style:none;margin:0;padding:0}
.side li a{display:block;padding:.2rem .5rem;border-radius:.4rem;color:var(--ink);text-decoration:none;overflow-wrap:anywhere}
.side li a:hover{background:#fff}
.side a[aria-current=page]{background:#fff;font-weight:700}
.side-h{margin:1.1rem .5rem .35rem;color:var(--muted);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
.side-h a{color:inherit}
.side-back{margin:1rem .5rem 0}
.crate{display:flex;align-items:center;gap:.6rem;padding:.25rem .5rem;color:var(--navy);text-decoration:none}
.crate img{border-radius:9px}
.crate b{display:block;font-size:1.1rem}
.ver{color:var(--muted);font-size:.85rem;font-weight:500}
.mono,.items a,.uses a,.list dt,.path,.decl,.fields dt,.item .name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.items a,.uses a{font-size:.82rem}
.items .verb{display:inline-block;min-width:3.6em}
.verb{font-weight:700}
.m-get .verb,.verb.m-get{color:#2f6aa0}
.m-post .verb,.verb.m-post{color:#1d6b44}
.m-patch .verb,.verb.m-patch{color:#875400}
.m-delete .verb,.verb.m-delete{color:#b3261e}
a.t,.item .name.t{color:#1d6b44}
.doc h1{margin:2rem 0 0;color:var(--navy);font-weight:900;font-size:clamp(2.6rem,8vw,4rem);line-height:1;letter-spacing:-.04em}
.doc h1.item{margin-top:.25rem;font-size:clamp(1.35rem,4vw,1.85rem);font-weight:500;letter-spacing:-.01em;line-height:1.3;overflow-wrap:anywhere}
.item .kind{font-family:Inter,system-ui,sans-serif;color:var(--muted)}
.item .name{font-weight:700;color:var(--scale)}
.srclink{float:right;margin-left:1rem;font:500 .9rem/2.4 Inter,system-ui,sans-serif}
.path{margin:2rem 0 0;font-size:.9rem;color:var(--muted)}
.path a{color:inherit}
.lede{margin:1.5rem 0 0;color:var(--muted);font-size:1.125rem}
.doc h2{margin:2.5rem 0 .75rem;padding-bottom:.35rem;border-bottom:1px solid var(--rule);font-size:1.35rem;letter-spacing:-.01em}
.doc p{margin:.75rem 0}
.summary{font-size:1.06rem}
.access{color:var(--muted)}
.access b{color:var(--ink)}
.doc code{font:500 .9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.doc :not(pre)>code{padding:.08em .3em;background:var(--sky);border-radius:.3em;overflow-wrap:anywhere}
.doc dt>code,.doc a>code{background:none;padding:0}
.doc pre{margin:1rem 0;padding:1rem 1.15rem;background:var(--navy);color:#e6f1fa;border-radius:.8rem;overflow-x:auto}
.doc pre code{font-size:.85rem}
.doc pre.decl{background:#fff;color:var(--ink);border:1px solid var(--rule)}
.decl a{text-decoration:none}
.scroll{margin:1rem 0;overflow-x:auto}
.doc table{width:100%;border-collapse:collapse;font-size:.95rem}
.doc th,.doc td{padding:.55rem .75rem .55rem 0;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
.doc th{color:var(--navy);font-weight:700}
.doc td:first-child code{white-space:nowrap}
/* An item table: the name, and the first line of what it is. */
.list{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:.4rem 1.5rem;margin:0}
.list dt{font-size:.88rem;overflow-wrap:anywhere}
.list dt a{text-decoration:none}
.list dt a:hover{text-decoration:underline}
.list dd{margin:0}
.fields dt{position:relative;margin-top:1rem;font-size:.92rem;overflow-wrap:anywhere}
.fields dd{margin:.25rem 0 0 1.5rem}
.anchor{position:absolute;left:-1.1rem;color:var(--muted);text-decoration:none;opacity:0}
.fields dt:hover .anchor,.anchor:focus-visible{opacity:1}
.uses{padding-left:1.25rem}
.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin:2rem 0 0}
.cards div{padding:1.1rem 1.25rem;background:#fff;border:1px solid var(--rule);border-radius:1rem}
.cards dt{font-weight:750;font-size:1.1rem}
.cards dd{margin:.35rem 0 0;color:var(--muted)}
.bar nav a[aria-current]{text-decoration:underline;text-decoration-color:var(--needle);text-decoration-thickness:2px}
@media (max-width:800px){
.docs{grid-template-columns:minmax(0,1fr);gap:2rem;padding-inline:1rem}
.side{position:static;max-height:none;order:2}
.srclink{float:none;display:block;margin:0;line-height:1.8}
.list{grid-template-columns:minmax(0,1fr);gap:.15rem}
.list dd{margin-bottom:.6rem}
.cards{grid-template-columns:minmax(0,1fr)}
}
footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem}
footer p{margin:0}
@media (max-width:640px){
main,.bar,footer{padding-inline:1rem}
.bar nav{gap:1rem}
.hero{padding-block:1.5rem 2rem}
.feature,.feature:nth-of-type(even){grid-template-columns:minmax(0,1fr);gap:1.25rem;padding-block:1.75rem}
.feature:nth-of-type(even) .say{order:0}
/* A wide crop at this width is too small to read: its left part, larger. */
.frame img.cut{width:100%;aspect-ratio:4/3;object-fit:cover;object-position:0 0}
.frame img.cut.player{aspect-ratio:2.4/1}
.frame img.cut.half{aspect-ratio:2.6/1} /* the directory's left column */
.also{grid-template-columns:minmax(0,1fr);gap:1.75rem}
.dial{margin-inline:-1rem}
.scale{border-radius:0}
/* Six stations, the tuned one third, rather than twelve too narrow to read. */
.track{--at:41.7%}
.stations{grid-template-columns:repeat(6,minmax(0,1fr));padding-inline:2%}
.stations li:nth-child(-n+3),.stations li:nth-child(n+10){display:none}
.stations li:nth-child(even){margin-top:0}
.stations li:nth-child(odd){margin-top:1.7rem}
}

1
site/wave.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="64" viewBox="0 0 48 64" fill="#8fc2ea"><rect x="1" y="22" width="3" height="20" rx="1.5"/><rect x="7" y="10" width="3" height="44" rx="1.5"/><rect x="13" y="17" width="3" height="30" rx="1.5"/><rect x="19" y="3" width="3" height="58" rx="1.5"/><rect x="25" y="14" width="3" height="36" rx="1.5"/><rect x="31" y="7" width="3" height="50" rx="1.5"/><rect x="37" y="20" width="3" height="24" rx="1.5"/><rect x="43" y="12" width="3" height="40" rx="1.5"/></svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -1,92 +0,0 @@
//! Who is asking. Sign-in is either a local password or a header set by whatever fronts
//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated
//! address in `Cf-Access-Authenticated-User-Email`.
use anyhow::{Result, bail};
use argon2::Argon2;
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The
/// salt is generated per password by the hasher itself.
pub fn hash_password(password: &str) -> Result<String> {
if password.len() < 8 {
bail!("password must be at least 8 characters");
}
Argon2::default()
.hash_password(password.as_bytes())
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("could not hash the password: {e}"))
}
/// False for a wrong password *and* for a stored hash this build cannot parse; either way
/// the answer is no.
pub fn verify_password(password: &str, stored: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored) else {
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
pub fn new_session_token() -> String {
let mut bytes = [0u8; 32];
if getrandom(&mut bytes).is_err() {
// Falling back to the clock would be a predictable session id. Better to fail.
panic!("no source of randomness for a session token");
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read;
std::fs::File::open("/dev/urandom")?.read_exact(buf)
}
/// A username taken from a proxy header. Cloudflare sends an email address; the local part
/// is what a person recognises, and the whole thing stays unique enough for one household.
pub fn name_from_header(raw: &str) -> Option<String> {
let name = raw.trim();
if name.is_empty() || name.len() > 190 {
return None;
}
// Anything that could confuse a lookup or a log line is not a name.
if name.chars().any(|c| c.is_control() || c == ',' || c == ';') {
return None;
}
Some(name.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_verifies_only_against_itself() {
let h = hash_password("correct horse battery").unwrap();
assert!(verify_password("correct horse battery", &h));
assert!(!verify_password("Correct horse battery", &h));
assert!(!verify_password("", &h));
// A hash from a different scheme, or a truncated one, must not authenticate.
assert!(!verify_password("correct horse battery", "not-a-hash"));
assert!(hash_password("short").is_err());
}
#[test]
fn session_tokens_are_long_and_distinct() {
let a = new_session_token();
let b = new_session_token();
assert_eq!(a.len(), 64);
assert_ne!(a, b);
}
#[test]
fn a_header_name_is_cleaned_or_refused() {
assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into()));
assert_eq!(name_from_header(""), None);
assert_eq!(name_from_header("ray\nadmin"), None);
assert_eq!(name_from_header("ray;admin"), None);
}
}

2226
src/db.rs

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,391 +0,0 @@
//! Unix-socket control and event stream. Replaces printMSG's `;;1;;1;;100.00;;42.31`.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc};
/// One JSON object per line, `ev` naming the variant.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "ev", rename_all = "snake_case")]
pub enum Event {
FeedStart { feed: String },
FeedSkip { feed: String, reason: String },
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
FeedError { feed: String, msg: String },
Progress {
feed: String,
/// Which enclosure this is about. Without it a UI cannot tell one download's
/// progress from another's and ends up animating every pending row.
enclosure: i64,
url: String,
file: String,
done: u64,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<u64>,
},
DownloadDone { feed: String, enclosure: i64, url: String, path: String, bytes: u64 },
DownloadError { feed: String, enclosure: i64, url: String, msg: String },
TorrentDeferred { feed: String, url: String },
Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize },
ReapDone { files: usize, bytes: u64 },
Status { feeds: usize, pending: i64, downloaded: i64 },
Error { msg: String },
}
impl Event {
pub fn is_terminal(&self) -> bool {
matches!(self, Event::ScanDone { .. } | Event::ReapDone { .. } | Event::Status { .. })
}
/// The human rendering, for a terminal rather than a UI.
pub fn human(&self) -> Option<String> {
Some(match self {
Event::FeedSkip { feed, reason } => format!("{feed}: {reason}"),
Event::FeedDone { feed, new, downloaded, failed, torrents } => format!(
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
),
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
Event::Progress { file, done, total, .. } => match total {
Some(t) if *t > 0 => format!(
" {file}: {:.1}% ({:.1}/{:.1} MB)",
*done as f64 / *t as f64 * 100.0,
*done as f64 / 1_048_576.0,
*t as f64 / 1_048_576.0
),
_ => format!(" {file}: {:.1} MB", *done as f64 / 1_048_576.0),
},
Event::DownloadDone { path, .. } => format!(" saved {path}"),
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
Event::Reaped { path, bytes } => {
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
}
Event::ReapDone { files, bytes } => format!(
"deleted {files} old file(s), {:.1} MB",
*bytes as f64 / 1_048_576.0
),
Event::Status { feeds, pending, downloaded } => {
format!("{feeds} feeds, {pending} pending, {downloaded} downloaded")
}
Event::Error { msg } => format!("error: {msg}"),
// Noise in a terminal; a UI still gets them on the socket.
Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
Fetch {
#[serde(default)]
feed: Option<String>,
#[serde(default)]
force: bool,
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
/// Empty is every feed, as the schedule and the CLI mean it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
feeds: Vec<String>,
},
Reap {
#[serde(default)]
dry_run: bool,
},
/// Fetch one specific enclosure now, ignoring max_new_per_check and the queue order.
/// A scan cannot express "this one, now": it takes the lowest-id pending rows up to
/// the per-scan cap, so an explicit request has to bypass both.
Download {
enclosure: i64,
},
Status,
}
/// Where events go: the socket, the terminal, or both.
#[derive(Clone)]
pub struct Emitter {
tx: Option<broadcast::Sender<Event>>,
print: bool,
}
impl Emitter {
pub fn terminal() -> Self {
Self { tx: None, print: true }
}
pub fn socket(tx: broadcast::Sender<Event>, print: bool) -> Self {
Self { tx: Some(tx), print }
}
pub fn emit(&self, e: Event) {
// Also log it. Scans and downloads travel as events, not tracing calls, so
// without this the log view shows only startup and HTTP lines and none of the
// work the daemon is actually doing. Progress goes to debug: it fires on every
// whole percent and would otherwise crowd everything else out of the buffer.
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
// line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes.
let routine = match &e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
let bad = matches!(
&e,
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
);
if let Some(line) = e.human() {
let line = line.trim();
if bad {
tracing::warn!(target: "ipx::scan", "{line}");
} else if routine {
tracing::debug!(target: "ipx::scan", "{line}");
} else {
tracing::info!(target: "ipx::scan", "{line}");
}
}
if let Some(tx) = &self.tx {
// The outbound half of the protocol, as it goes on the wire. Progress is the
// high-volume one, so it sits at debug.
if let Ok(json) = serde_json::to_string(&e) {
if matches!(e, Event::Progress { .. }) {
tracing::debug!(target: "ipx::io", "<- {json}");
} else {
tracing::info!(target: "ipx::io", "<- {json}");
}
}
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}
if self.print && let Some(line) = e.human() {
println!("{line}");
}
}
}
/// True when something is already listening -- i.e. a daemon owns this socket.
pub async fn daemon_is_live(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok()
}
/// Answers `status` for the socket, without the worker. The worker runs one job at a time, and a
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
/// A future, since reading the counts is a database query.
pub type StatusFn =
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve(
path: PathBuf,
events: broadcast::Sender<Event>,
cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> {
// A socket file left by a crashed daemon would block the bind; a live one was already
// rejected by the caller's daemon_is_live() check.
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("removing stale socket {}", path.display()))?;
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = UnixListener::bind(&path)
.with_context(|| format!("binding {}", path.display()))?;
tracing::info!(socket = %path.display(), "listening");
loop {
let (stream, _) = listener.accept().await?;
let rx = events.subscribe();
let cmds = cmds.clone();
let status = status.clone();
tokio::spawn(async move {
if let Err(e) = handle(stream, rx, cmds, status).await {
tracing::debug!(error = %e, "client gone");
}
});
}
}
async fn handle(
stream: UnixStream,
mut rx: broadcast::Receiver<Event>,
cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> {
let (read, mut write) = stream.into_split();
// Events out: everything broadcast, and the answers meant for this client alone.
let (reply, mut replies) = mpsc::channel::<Event>(4);
let writer = tokio::spawn(async move {
loop {
let ev = tokio::select! {
Some(ev) = replies.recv() => ev,
got = rx.recv() => match got {
Ok(ev) => ev,
Err(_) => break,
},
};
let mut line = serde_json::to_string(&ev).unwrap_or_default();
line.push('\n');
if write.write_all(line.as_bytes()).await.is_err() {
break;
}
}
});
// Commands in.
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await? {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<Command>(line) {
// Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(Command::Status) => {
tracing::info!(target: "ipx::io", "-> {line}");
let ev = status().await;
if let Ok(json) = serde_json::to_string(&ev) {
tracing::info!(target: "ipx::io", "<- {json}");
}
let _ = reply.send(ev).await;
}
Ok(cmd) => {
if cmds.send(cmd).await.is_err() {
break; // Worker is gone; so are we.
}
}
Err(e) => tracing::warn!(error = %e, line, "bad command"),
}
}
writer.abort();
Ok(())
}
/// Sends one command to a running daemon and prints the events it produces.
pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
let stream = UnixStream::connect(path).await?;
let (read, mut write) = stream.into_split();
let mut line = serde_json::to_string(cmd)?;
line.push('\n');
write.write_all(line.as_bytes()).await?;
// ponytail: the event stream is a broadcast, so a busy daemon's other work shows up
// here too. Fine for a CLI; a UI that cares would want per-request ids.
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await? {
let Ok(ev) = serde_json::from_str::<Event>(&line) else {
continue;
};
if let Some(text) = ev.human() {
println!("{text}");
}
if ev.is_terminal() {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true }));
// "Download this one now" is its own command precisely because a scan cannot
// express it: a scan takes the lowest-id pending rows up to max_new_per_check.
let got: Command = serde_json::from_str(r#"{"cmd":"download","enclosure":11}"#).unwrap();
assert!(matches!(got, Command::Download { enclosure: 11 }));
assert!(serde_json::from_str::<Command>(r#"{"cmd":"nope"}"#).is_err());
}
#[test]
fn events_serialise_to_the_documented_shape() {
let ev = Event::Progress {
feed: "atp".into(),
enclosure: 42,
url: "https://x/ep.mp3".into(),
file: "ep.mp3".into(),
done: 10_485_760,
total: Some(52_428_800),
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.starts_with(r#"{"ev":"progress""#), "got {json}");
assert!(json.contains(r#""done":10485760"#));
assert!(json.contains(r#""enclosure":42"#), "a UI needs this to target one row");
// total is omitted rather than null when the server sent no length.
let ev = Event::Progress {
feed: "a".into(),
enclosure: 1,
url: "u".into(),
file: "f".into(),
done: 1,
total: None,
};
assert!(!serde_json::to_string(&ev).unwrap().contains("total"));
}
#[test]
fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1 }.is_terminal());
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
assert!(!Event::FeedDone {
feed: "a".into(),
new: 0,
downloaded: 0,
failed: 0,
torrents: 0
}
.is_terminal());
}
#[tokio::test]
async fn status_is_answered_while_the_worker_is_busy() {
// The queue is full and nobody drains it, as when the worker is deep in a long download:
// anything sent to it would wait for ever.
let (cmds, _worker) = mpsc::channel::<Command>(1);
cmds.send(Command::Reap { dry_run: true }).await.unwrap();
let (events, _) = broadcast::channel::<Event>(8);
// Another client, watching a scan: it must not be handed someone else's answer, which
// would end its session.
let mut watcher = events.subscribe();
let status: StatusFn =
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status));
let (read, mut write) = client.into_split();
write.write_all(b"{\"cmd\":\"status\"}\n").await.unwrap();
let line = tokio::time::timeout(std::time::Duration::from_secs(2), BufReader::new(read).lines().next_line())
.await
.expect("status waited behind the worker")
.unwrap()
.unwrap();
assert!(line.contains(r#""ev":"status""#), "{line}");
assert!(watcher.try_recv().is_err(), "the answer went to every client, not just the one asking");
}
}

File diff suppressed because it is too large Load Diff

1847
src/web.rs

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ const blocks = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars = {};
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100;
if (Object.keys(vars).length) blocks[m[1]] = vars;
}
@@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) {
}
themes['modern'] = base;
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
const oklab = h => {
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
};
const fromOklab = ([L, a, b]) => {
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
};
for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); }
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
let bad = 0;
for (const [name, p] of Object.entries(themes)) {
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
@@ -58,5 +74,43 @@ for (const [name, p] of Object.entries(themes)) {
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
}
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
const rgba = (sel, n) => {
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
return [[+m[1], +m[2], +m[3]], +m[4]];
};
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
const p = themes[name];
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
const worst = {};
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
let g = rgb(p.bg);
for (const [w, [c, a]] of tint) {
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
g = over(c, a * Math.max(0, 1 - d), g);
}
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
// and a panel's top-left corner under its sheen at full strength.
const panel = over(rgb(p.panel), .7, g);
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
if (!(k in worst) || r < worst[k]) worst[k] = r;
}
}
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
for (const [k, r] of Object.entries(worst))
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
}
if (bad) process.exit(1);
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);

Binary file not shown.

BIN
tests/data/access-test.der Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
{
"keys": [
{
"kid": "k1",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"e": "AQAB",
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
}
]
}

132
tests/dom-stub.js Normal file
View File

@@ -0,0 +1,132 @@
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
// to find what it reaches for, and no more.
//
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
// native.ts replaces that surface on the element and a proxy that answers everything would prove
// nothing about whether it worked.
const vm = require('vm');
function makeContext(html, script, { media = false } = {}) {
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){},
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
const body = el('body');
const audio = media ? makeMediaElement() : null;
if (media) {
// native.ts reads has-video off the body to decide whether the host takes the file, so this
// one has to be a real set rather than something that always says no.
const classes = new Set();
body.classList = {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
contains: c => classes.has(c),
};
}
const document = {
querySelector(sel) {
if (sel === '#audio' && audio) return audio;
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body,
documentElement: { dataset: {} },
};
const ctx = {
document, console, crypto: require("crypto").webcrypto, performance,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
getComputedStyle: () => ({ backgroundColor: '' }),
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
if (media) {
ctx.HTMLMediaElement = MediaElement;
ctx.Event = Event;
ctx.isFinite = isFinite;
ctx.CSS = { escape: s => String(s) };
}
ctx.globalThis = ctx;
ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
return { ctx, missing, audio, body, ids };
}
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
function Event(type) { this.type = type; }
function MediaElement() {
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
this._ready = 0; this._vol = 1; this._rate = 1;
this._listeners = {};
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
// Every call that reached the real element, so a test can say the host took over rather than
// the element quietly playing as well.
this.calls = [];
}
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
MediaElement.prototype.load = function(){ this.calls.push('load'); };
MediaElement.prototype.addEventListener = function(name, fn, opts){
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
};
MediaElement.prototype.dispatchEvent = function(ev){
for (const l of (this._listeners[ev.type] || []).slice()) {
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
l.fn(ev);
}
return true;
};
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
MediaElement.prototype.setAttribute = function(){};
MediaElement.prototype.getAttribute = function(){ return null; };
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
configurable: true,
get(){ return this[field]; },
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
});
accessor('src', '_src', true);
accessor('currentTime', '_t', true);
accessor('volume', '_vol');
accessor('playbackRate', '_rate');
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
function makeMediaElement(){ return new MediaElement(); }
module.exports = { makeContext, vm };

48
tests/load/browse.js Normal file
View File

@@ -0,0 +1,48 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { BASE, FEEDS, ITEMS, as, me, pick, feedId, p95 } from './lib.js';
// An evening's browsing: twenty-five people at once opening their feed list, All Subscriptions,
// a feed, a search, the Directory and a feed's page in it. Each answer's budget is well above what
// it takes now, so it fails on a query that has started asking once per feed -- the Directory
// asked the database three questions a feed until 0.10.0 -- not on a slow minute. Fifty measured
// the queue for what was SQLite's one connection (#136) more than any query.
export const options = {
scenarios: {
evening: {
executor: 'ramping-vus',
stages: [{ duration: '15s', target: 25 }, { duration: '45s', target: 25 }, { duration: '10s', target: 0 }],
},
},
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About twice each one's p95 on 2026-10-05 on Tower, and no less than 50ms, where a few ms
// either way is noise. SQLite: 89, 17, 10, 22, 112, 126ms; Postgres: 54, 34, 24, 44, 71, 88ms.
'http_req_duration{name:feeds}': p95(200, 150),
'http_req_duration{name:all-entries}': p95(50, 100),
'http_req_duration{name:feed-entries}': p95(50, 100),
'http_req_duration{name:search}': p95(60, 100),
},
};
export default function () {
const u = me();
const feeds = http.get(`${BASE}/api/feeds`, as(u, 'feeds'));
check(feeds, { 'your thirty feeds': r => r.status === 200 && r.json().length === 30 });
const all = http.get(`${BASE}/api/entries?limit=50&filter=all&sort=published&dir=desc`, as(u, 'all-entries'));
check(all, { 'All Subscriptions, a page of it': r => r.status === 200 && r.json().entries.length === 50 });
const f = pick(feeds.json());
const one = http.get(`${BASE}/api/feeds/${f.id}/entries?limit=50&sort=title&dir=asc`, as(u, 'feed-entries'));
check(one, { 'a feed of yours, every item': r => r.status === 200 && r.json().entries.length === ITEMS });
// Every generated item mentions the weather, in its text, not its title.
const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search'));
check(found, { 'a search of everything you read': r => r.status === 200 && r.json().total === 30 * ITEMS });
// Not the Directory: since #204 it is ipx-directory's, which this harness does not start (#245).
sleep(1 + Math.random() * 2);
}

19
tests/load/lib.js Normal file
View File

@@ -0,0 +1,19 @@
// What the load tests share. Each virtual user is a listener of its own, signed in by name with
// the header the scratch daemon trusts from 127.0.0.1, as production trusts Cloudflare Access's.
export const BASE = __ENV.BASE;
export const USERS = Number(__ENV.USERS || 100);
export const FEEDS = Number(__ENV.FEEDS || 1500);
// Each generated feed has this many items; see run.js.
export const ITEMS = 20;
export const as = (name, tag) => ({
headers: { 'X-Load-User': name, 'Content-Type': 'application/json' },
tags: { name: tag },
});
/// The listener this virtual user is, one of the hundred run.js seeded, thirty feeds each.
export const me = () => `load-${(__VU - 1) % USERS}`;
export const pick = a => a[Math.floor(Math.random() * a.length)];
/// A p95 budget, in ms, for whichever database the daemon is on (run.js --postgres): SQLite reads
/// a file beside the daemon, Postgres answers over the network, and each is quicker at something.
export const p95 = (sqlite, postgres) => [`p(95)<${__ENV.DB === 'postgres' ? postgres : sqlite}`];
export const feedId = n => `gen-${String(n).padStart(4, '0')}`;

57
tests/load/listening.js Normal file
View File

@@ -0,0 +1,57 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { BASE, as, me, pick, p95 } from './lib.js';
// Players saving where people are while scans write. A hundred listeners each save a position
// every second -- the player saves every ten, so this is a thousand people listening -- mark an
// item read now and then, and read their position back to see it is theirs and as they left it,
// while one of them forces a scan of all their feeds every five seconds. On SQLite every one of
// these is a write waiting its turn for the one writer.
export const options = {
scenarios: {
listeners: { executor: 'constant-vus', vus: 100, duration: '60s', exec: 'listen' },
scans: { executor: 'constant-vus', vus: 1, duration: '60s', exec: 'scan' },
},
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About twice each one's p95 on 2026-10-05 on Tower. SQLite: 21, 277 and 134ms; Postgres: 9,
// 225 and 153ms. Marking read answers with the feed's row, counts and all, hence its budget.
'http_req_duration{name:position}': p95(100, 100),
'http_req_duration{name:flags}': p95(600, 500),
'http_req_duration{name:read-back}': p95(300, 400),
},
};
// Each virtual user's own episode, and how far into it it is.
let ep = null, secs = 0, n = 0;
export function listen() {
const u = me();
if (!ep) {
const r = http.get(`${BASE}/api/entries?limit=50`, as(u, 'pick'));
ep = pick(r.json().entries.filter(e => e.duration));
}
secs += 1;
const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`,
JSON.stringify({ secs, duration: ep.duration }), as(u, 'position'));
check(saved, { 'position saved': r => r.status === 204 });
if (++n % 10 === 0) {
const flags = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/flags`,
JSON.stringify({ read: n % 20 === 0 }), as(u, 'flags'));
check(flags, { 'marked read or unread': r => r.status === 200 });
const back = http.get(`${BASE}/api/feeds/${encodeURIComponent(ep.feed_id)}/entries?limit=50`, as(u, 'read-back'));
const mine = back.status === 200 && back.json().entries.find(e => e.guid === ep.guid);
check(mine, {
'your position, as you left it': e => e && e.position === secs,
'read as you marked it': e => e && e.read === (n % 20 === 0),
});
}
sleep(1);
}
export function scan() {
const r = http.post(`${BASE}/api/fetch`, JSON.stringify({ force: true }), as('load-0', 'fetch'));
check(r, { 'scan queued': r => r.status === 202 || r.status === 200 });
sleep(5);
}

48
tests/load/media.js Normal file
View File

@@ -0,0 +1,48 @@
import http from 'k6/http';
import { check } from 'k6';
import { BASE, as, pick } from './lib.js';
// Listeners seeking: fifty at once asking for ranges of the same few episodes, as a player does
// on every seek and every few seconds of playback. Every range is checked against what the feed
// served, byte by byte at a sample of offsets, so a wrong offset fails, not only a wrong status.
export const options = {
scenarios: { seeking: { executor: 'constant-vus', vus: 50, duration: '45s' } },
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About four times its p95 on 2026-10-05, 48ms on SQLite and 45 on Postgres: a range is a
// file read, little to vary.
'http_req_duration{name:range}': ['p(95)<200'],
},
};
// The generated file: ID3's ten bytes, then a byte its offset gives (run.js, mediaByte).
const SIZE = 2 * 1024 * 1024;
const byte = k => (k * 31 + 7) & 255;
export function setup() {
const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', 'setup'));
const files = r.json().entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id);
if (!files.length) throw new Error('the listener has no downloaded files to seek through');
return { files };
}
export default function ({ files }) {
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
const r = http.get(`${BASE}/media/${pick(files)}`, {
headers: { 'X-Load-User': 'listener', Range: `bytes=${start}-${end}` },
responseType: 'binary',
tags: { name: 'range' },
});
const body = r.status === 206 ? new Uint8Array(r.body) : new Uint8Array(0);
check(r, {
'part of the file': r => r.status === 206,
'the range asked for': r => r.headers['Content-Range'] === `bytes ${start}-${end}/${SIZE}`,
'its bytes': () => {
if (body.length !== end - start + 1) return false;
for (const at of [0, body.length - 1, ...Array.from({ length: 30 }, () => Math.floor(Math.random() * body.length))])
if (body[at] !== byte(start + at)) return false;
return true;
},
});
}

282
tests/load/run.js Normal file
View File

@@ -0,0 +1,282 @@
// Load tests: k6 against a scratch daemon with a catalogue the size of production's, for what
// the browser tests cannot show -- many people at once, and what that does to latency, to the
// database and to the healthcheck. The browser suite drives one person against a handful of
// feeds, one request at a time.
//
// node tests/load/run.js [--postgres] [browse|listening|media|signin ...] default: all four
//
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
// While each runs, `ipx status` is run every second, and the run fails if any answer takes 5s.
//
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
// /src/.envrc), in a schema of its own, ipx_load, made afresh each run (#139). Postgres's numbers
// are what production would see.
// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync, execFile, execFileSync } = require('child_process');
const repo = path.resolve(__dirname, '../..');
const root = '/tmp/ipx-load';
const WEB = 8793, GEN = 8794;
const FEEDS = Number(process.env.IPX_LOAD_FEEDS || 1500);
const USERS = Number(process.env.IPX_LOAD_USERS || 100);
// The first few feeds carry real files, downloaded for the media test.
const MEDIA = 4;
const ITEMS = 20;
const BASE = `http://127.0.0.1:${WEB}`;
const bin = path.join(repo, 'target/release/ipx');
const SCRIPTS = ['browse', 'listening', 'media', 'signin'];
const POSTGRES = process.argv.includes('--postgres');
const PG_URL = process.env.IPX_TEST_DATABASE_URL || '';
const PG_SCHEMA = 'ipx_load';
const env = {
...process.env,
IPX_CONFIG: `${root}/config/config.toml`,
IPX_DATA_DIR: `${root}/data`,
IPX_LOG: 'ipx=info',
IPX_LOG_FORMAT: 'json',
// Nothing of the test reaches production's database or traces, or a paid API.
// In its own schema, as Db::memory puts each Rust test, and with notices off, as url_for does.
IPX_DATABASE_URL: POSTGRES
? `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}%20-c%20client_min_messages%3Dwarning`
: '',
OTEL_EXPORTER_OTLP_ENDPOINT: '',
TYPESAFE_KEY: '',
};
// ---- the feeds ------------------------------------------------------------------------------
// Apple's categories, some with a subcategory, so the Directory has tiles and pages to draw.
const CATS = [['Technology'], ['News', 'Tech News'], ['Comedy'], ['Leisure', 'Video Games'],
['Society & Culture', 'Documentary'], ['Sports', 'Soccer'], ['Arts', 'Books'],
['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'],
['Education', 'Self-Improvement'], ['Health & Fitness', 'Mental Health'], ['Music']];
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const now = Date.now();
// Every third feed is a blog: no files, as most of production's are.
const isBlog = n => n % 3 === 2 && n >= MEDIA;
function feedXml(n) {
const [cat, sub] = CATS[n % CATS.length];
const category = sub
? `<itunes:category text="${esc(cat)}"><itunes:category text="${esc(sub)}"/></itunes:category>`
: `<itunes:category text="${esc(cat)}"/>`;
const items = Array.from({ length: ITEMS }, (_, i) => `<item>
<title>Episode ${ITEMS - i} of Show ${n}</title><guid>gen-${n}-${i}</guid>
<pubDate>${new Date(now - (i * 3 + n % 3) * 86400e3).toUTCString()}</pubDate>
<description>&lt;p&gt;Show ${n}, episode ${ITEMS - i}: an hour on the news, the weather and whatever came up.&lt;/p&gt;</description>
${isBlog(n) ? '' : `<itunes:duration>${1800 + i * 60}</itunes:duration>
<enclosure url="http://127.0.0.1:${GEN}/media/${n}/${i}.mp3" length="${MEDIA_BYTES.length}" type="audio/mpeg"/>`}
</item>`).join('');
return `<?xml version="1.0"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"><channel>
<title>Generated Show ${n}</title><link>http://127.0.0.1:${GEN}/site/${n}</link>
<description>Generated show number ${n}, for the load tests.</description>
<itunes:image href="http://127.0.0.1:${GEN}/art/${n}.jpg"/>${category}${items}</channel></rss>`;
}
// A file whose every byte is known from its offset, so the media test can check that a range
// it asked for is the range it got, not merely that it got something that long. ID3 first, so
// ipx takes it for audio.
const mediaByte = k => (k * 31 + 7) & 255;
const MEDIA_BYTES = Buffer.from(Uint8Array.from({ length: 2 * 1024 * 1024 }, (_, k) => mediaByte(k)));
Buffer.from('ID3\x03\x00\x00\x00\x00\x00\x00', 'latin1').copy(MEDIA_BYTES);
const art = fs.readFileSync(path.join(repo, 'tests/ui/fixtures/art.jpg'));
function serveFeeds() {
return http.createServer((req, res) => {
const m = req.url.match(/^\/(feed|media|art)\/(\d+)/);
if (m?.[1] === 'feed') { res.writeHead(200, { 'content-type': 'application/rss+xml' }); return res.end(feedXml(Number(m[2]))); }
if (m?.[1] === 'media') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': MEDIA_BYTES.length }); return res.end(MEDIA_BYTES); }
if (m?.[1] === 'art') { res.writeHead(200, { 'content-type': 'image/jpeg' }); return res.end(art); }
res.writeHead(404).end();
}).listen(GEN, '127.0.0.1');
}
const feedId = n => `gen-${String(n).padStart(4, '0')}`;
const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
// ---- the daemon -----------------------------------------------------------------------------
/// The load tests' schema dropped and made again, as /tmp/ipx-load is wiped. Only in a database
/// made for tests: the URL is production's server and role, and one slip of a name would
/// otherwise point this at production's database.
function freshPostgres() {
const u = PG_URL && new URL(PG_URL);
if (!u || !/_test$/.test(u.pathname)) {
throw new Error('--postgres needs IPX_TEST_DATABASE_URL, a database whose name ends in _test (. /src/.envrc)');
}
psql(`DROP SCHEMA IF EXISTS ${PG_SCHEMA} CASCADE`, `CREATE SCHEMA ${PG_SCHEMA}`);
}
/// Statements run in the test database, given by its URL in psql's environment, not its
/// arguments, where every process on Tower could read the password.
function psql(...sql) {
const u = new URL(PG_URL);
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
PGPASSWORD: decodeURIComponent(u.password), PGDATABASE: u.pathname.slice(1), PGOPTIONS: `-c search_path=${PG_SCHEMA}` };
execFileSync('psql', ['-q', '-v', 'ON_ERROR_STOP=1', ...sql.flatMap(s => ['-c', s])], { env, stdio: ['ignore', 'ignore', 'inherit'] });
}
function writeConfig() {
fs.rmSync(root, { recursive: true, force: true });
for (const d of ['config', 'data', 'downloads']) fs.mkdirSync(path.join(root, d), { recursive: true });
// Read into the database's catalogue on the first start, as an existing config.toml is. Only
// the media feeds download: a forced scan of a listener's 30 feeds would otherwise fetch a
// 2 MB episode of each, every time.
const feeds = Array.from({ length: FEEDS }, (_, n) =>
`[feeds.${feedId(n)}]\nurl = "${feedUrl(n)}"\nauto_download = ${n < MEDIA}\n`).join('\n');
fs.writeFileSync(env.IPX_CONFIG, `
[general]
download_dir = "${root}/downloads"
socket = "${root}/ipx.sock"
schedule = "every 60m"
max_new_per_check = 1
# The generated feeds are on loopback, which ipx refuses otherwise (#320).
fetch_internal = ["127.0.0.1"]
[torrent]
enabled = false
[web]
enabled = true
bind = "127.0.0.1:${WEB}"
token = "loadtokenloadtokenloadtoken12345"
# Each k6 user signs in by name, as Cloudflare Access does in production: no password to hash
# for every one of a hundred listeners. Only the sign-in test uses a password.
trusted_header = "X-Load-User"
trusted_proxies = ["127.0.0.1"]
auto_create_users = true
${feeds}`);
}
/// Resolves with the first log line matching `test`, read from the daemon's JSON log, which it
/// writes to stderr.
function waitForLog(daemon, test, ms) {
return new Promise((resolve, reject) => {
let buf = '';
const timer = setTimeout(() => { daemon.stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms);
const on = chunk => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
let ev; try { ev = JSON.parse(line); } catch { continue; }
if (test(ev)) { clearTimeout(timer); daemon.stderr.off('data', on); return resolve(ev); }
}
};
daemon.stderr.on('data', on);
});
}
const as = name => ({ 'X-Load-User': name, 'Content-Type': 'application/json' });
async function api(name, url, opts = {}) {
const r = await fetch(BASE + url, { ...opts, headers: { ...as(name), ...opts.headers } });
if (!r.ok) throw new Error(`${opts.method || 'GET'} ${url} as ${name}: ${r.status} ${await r.text()}`);
return r.status === 204 ? null : r.json().catch(() => null);
}
const opml = ns => `<?xml version="1.0"?><opml version="2.0"><head><title>load</title></head><body>${
ns.map(n => `<outline type="rss" text="${feedId(n)}" xmlUrl="${feedUrl(n)}"/>`).join('')}</body></opml>`;
async function seed() {
// The first account made is the admin.
await api('admin', '/api/me');
// Every listener subscribes to 30 feeds, overlapping as people's do; one OPML each, one scan.
for (let u = 0; u < USERS; u++) {
const mine = Array.from({ length: 30 }, (_, k) => (u * 7 + k * 41) % FEEDS);
await api(`load-${u}`, '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(mine) }) });
}
// The media test's listener takes the feeds with files. They may be downloaded already: the
// first start subscribes the admin to the whole catalogue, so the first scan fetched them.
// By OPML as the others: /api/popular asks the directory, which this harness does not start (#245).
await api('listener', '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(Array.from({ length: MEDIA }, (_, n) => n)) }) });
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
if (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`);
}
// A password, for the sign-in test; the CLI hashes it as `ipx user add` always does.
execFileSync(bin, ['user', 'add', 'piper'], { input: 'piperpassword', env });
}
// ---- the run --------------------------------------------------------------------------------
/// `ipx status` once a second until stopped: the slowest answer, and any that failed. Not
/// spawnSync: blocked in it, this process stops draining the daemon's log, the pipe fills, and
/// the daemon stalls writing its next line, which is the test measuring itself.
function watchHealth() {
const seen = { slowest: 0, failed: 0 };
let on = true;
(async () => {
while (on) {
const t = Date.now();
const ok = await new Promise(res => execFile(bin, ['status'], { env, timeout: 5000 }, err => res(!err)));
seen.slowest = Math.max(seen.slowest, Date.now() - t);
if (!ok) seen.failed++;
await new Promise(res => setTimeout(res, 1000));
}
})();
return () => { on = false; return seen; };
}
function k6(script) {
return new Promise(resolve => {
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
path.join(__dirname, `${script}.js`)], { stdio: 'inherit' });
run.on('exit', code => resolve(code));
});
}
(async () => {
const only = process.argv.slice(2).filter(a => a !== '--postgres');
for (const s of only) if (!SCRIPTS.includes(s)) { console.error(`no load test called ${s}: ${SCRIPTS.join(', ')}`); process.exit(2); }
if (spawnSync('k6', ['version']).error) { console.error('k6 is not installed: see install.sh'); process.exit(2); }
console.log('building the release binary...');
execFileSync('cargo', ['build', '--release', '-q'], { cwd: repo, stdio: 'inherit' });
const feeds = serveFeeds();
writeConfig();
if (POSTGRES) freshPostgres();
console.log(`on ${POSTGRES ? 'Postgres' : 'SQLite'}`);
// Its log kept out of this run's output, which is k6's; IPX_LOAD_LOG=1 shows it.
const daemon = spawn(bin, ['daemon'], { env, stdio: ['ignore', process.env.IPX_LOAD_LOG ? 'inherit' : 'ignore', 'pipe'] });
daemon.stderr.setEncoding('utf8');
if (process.env.IPX_LOAD_LOG) daemon.stderr.on('data', d => process.stderr.write(d));
// Stopped by hand, the daemon goes too, by its own PID, or it holds the ports for the next run.
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { daemon.kill('SIGTERM'); process.exit(130); });
let failed = 0;
try {
const t = Date.now();
const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && ev.feeds > 0, 600_000);
console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`);
await seed();
// The planner's statistics, as production's long-standing tables have them. A schema filled
// seconds ago has none until autovacuum gets to it, which it did partway through a test, and
// the search's p95 swung from 40ms to 166ms between runs with the plan it changed.
if (POSTGRES) psql('ANALYZE');
// The log is drained from here on, or the pipe fills and the daemon blocks writing to it.
daemon.stderr.resume();
for (const script of only.length ? only : SCRIPTS) {
console.log(`\n=== ${script}`);
const stop = watchHealth();
const code = await k6(script);
const health = stop();
console.log(`ipx status: slowest ${health.slowest}ms, ${health.failed} failed`);
if (code !== 0) { failed++; console.log(`${script}: thresholds failed (k6 exit ${code})`); }
if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
}
} catch (e) {
console.error(e.message);
failed++;
} finally {
// Its own PID, never a pkill: Tower sees production's ipx too (#38).
daemon.kill('SIGTERM');
feeds.close();
}
console.log(failed ? `\n${failed} failure(s)` : '\nall load tests passed');
process.exit(failed ? 1 : 0);
})();

44
tests/load/signin.js Normal file
View File

@@ -0,0 +1,44 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
import { BASE, as, me } from './lib.js';
// A flood of sign-in attempts with wrong passwords while everyone else goes on using the site.
// Two things only many requests at once show: whether checking passwords, tens of milliseconds
// of CPU each, starves the rest of the server, and whether a wrong password for a name that is
// an account takes longer to refuse than one for a name that is not -- the answers read the
// same, and the time would tell anyone which names are accounts here.
const gap = new Trend('signin_name_gap', true);
export const options = {
scenarios: {
attempts: { executor: 'constant-vus', vus: 40, duration: '40s', exec: 'attempt' },
everyone: { executor: 'constant-vus', vus: 5, duration: '40s', exec: 'browse' },
},
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// 57ms on 2026-10-05 on SQLite, 68 on Postgres; 4.3s while password checks ran on the async
// workers (#137).
'http_req_duration{name:meanwhile}': ['p(95)<300'],
// Known name against unknown, one straight after the other: no gap but noise. 0.2ms on
// 2026-10-05; 31ms while an unknown name was refused without a check (#138).
signin_name_gap: ['med<10'],
},
};
const refused = { headers: { 'Content-Type': 'application/json' }, responseCallback: http.expectedStatuses(401) };
export function attempt() {
const known = http.post(`${BASE}/api/login`, JSON.stringify({ name: 'piper', password: 'not-the-password' }),
{ ...refused, tags: { name: 'signin-known' } });
const unknown = http.post(`${BASE}/api/login`, JSON.stringify({ name: `nobody-${__VU}-${__ITER}`, password: 'not-the-password' }),
{ ...refused, tags: { name: 'signin-unknown' } });
check(known, { 'a wrong password refused': r => r.status === 401 });
check(unknown, { 'an unknown name refused': r => r.status === 401 });
gap.add(known.timings.duration - unknown.timings.duration);
}
export function browse() {
const r = http.get(`${BASE}/api/feeds`, as(me(), 'meanwhile'));
check(r, { 'the site still answers': r => r.status === 200 });
sleep(0.5);
}

117
tests/native-bridge.js Normal file
View File

@@ -0,0 +1,117 @@
// The page inside a native shell: web/src/native.ts should take playback off the element and
// hand it to the host, while everything in player.ts carries on talking to the element.
//
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
// road, with nothing to look at.
//
// node tests/native-bridge.js
const { makeContext, vm } = require('./dom-stub.js');
const { buildPage } = require('../web/build.mjs');
const { html, js: script } = buildPage('index.html');
let failed = 0;
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
/* ---- a browser: nothing installs ---- */
{
const { ctx } = makeContext(html, script, { media: true });
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
const audio = ctx.document.querySelector('#audio');
audio.src = '/media/1';
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
}
/* ---- inside the shell ---- */
const posted = [];
const { ctx, audio, body } = makeContext(html, script, { media: true });
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
const last = t => [...posted].reverse().find(m => m.t === t);
const since = () => posted.splice(0, posted.length);
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
ok(last('ready'), 'the host was never told the bridge is in');
since();
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
body.classList.toggle('has-video', false);
audio.calls.length = 0;
audio.src = '/media/42';
const load = last('load');
ok(load, 'setting the src told the host nothing');
if (load) {
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
}
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
// player.ts sets currentTime=0 straight after the src.
since();
audio.currentTime = 0;
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
since();
audio.play();
ok(last('play'), 'play did not reach the host');
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
// The host answers, and the page must move as it would have on its own.
let played = 0, timed = 0;
audio.addEventListener('play', () => played++);
audio.addEventListener('timeupdate', () => timed++);
ctx.window.ipxNative.on({ t: 'state', playing: true });
ok(played === 1, 'the page never saw the host start playing');
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
ok(audio.currentTime === 30, "the host's clock did not reach the page");
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
// The 15-second key: a read and a write through the shim.
since();
audio.currentTime -= 15;
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
since();
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
// Closing the player has to stop the host, not just blank the element.
since();
audio.removeAttribute('src');
ok(last('stop'), 'closing the player left the host playing');
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
// buys nothing.
since();
audio.calls.length = 0;
body.classList.toggle('has-video', true);
audio.src = '/media/99';
ok(!last('load'), 'a video was handed to the host');
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
console.log('OK: native-bridge: the host takes playback and the page follows it');
process.exit(0);

View File

@@ -7,7 +7,7 @@
// and every server-side test passed too, because the server was fine.
//
// node tests/page-smoke.js
const vm = require('vm');
const { makeContext, vm } = require('./dom-stub.js');
const PAGE = process.argv[2] || 'index.html';
const { buildPage } = require('../web/build.mjs');
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
const document = {
querySelector(sel) {
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body: el('body'),
documentElement: { dataset: {} },
};
const ctx = {
document, console,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
ctx.globalThis = ctx;
ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
const { ctx, missing } = makeContext(html, script);
try {
vm.createContext(ctx);
@@ -99,13 +49,12 @@ const drive = PAGE === 'admin.html' ? [
['prefsModal', () => ctx.prefsModal()],
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
['groupHead', () => ctx.groupHead(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
];
for (const [name, fn] of drive) {
try {
@@ -119,6 +68,18 @@ for (const [name, fn] of drive) {
}
}
// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet
// minute, so a refresh hours later is not filed under the last tap.
if (PAGE === 'index.html') {
const id = () => ctx.traceparent().split('-')[1];
vm.runInContext('Date.now = (n => () => n)(1e12)', ctx);
const first = id();
vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx);
if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); }
vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx);
if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); }
}
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);

Some files were not shown because too many files have changed in this diff Show More