Commit Graph

126 Commits

Author SHA1 Message Date
980fbc3f66 Tests for Signals' judging, pacing, icons and API (#398)
signals.rs's pass, judge, pace, back_off, check_key and pick_icon ran
only against a live Jev. In cargo tests ask now goes to a stand-in for
it, served from a thread of its own so every test shares it, answering
by key as TypeSafe would: a pass judges each new item once, keeps its
verdicts and bills its owner's tokens on their own key; a refused key
stops its owner's judging, not anyone else's, and is not sent again; a
429 stops the pass and the next call to Jev waits out Retry-After; a
422 stops the pass rather than repeating on every item; a key check
costs tokens; an icon is one Jev was offered, on the server's key.

The API: a Signal made, its words trimmed and deduplicated, bad names,
words, icons and descriptions refused, renamed and re-worded, its icon
cleared, its picked items listed, deleted with its verdicts; someone
else's is 404 every way. In the browser, a Signal renamed and deleted
from its own page. The coverage run runs the browser Signals tests too
(IPX_SIGNALS_TESTS=1): Jev there is the fixture server's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:15:49 +00:00
3bb59c215a Tests for proxy sign-in, passwords, account settings and ipx user (#399)
Who gets in had no test above the token check: the router now runs in
tests with a peer address, as the listener gives it. A name in the
proxy's header signs in only from the trusted proxy (not from
cloudflared's gateway or anyone else) and makes the first account an
admin; with Access set, the name is the signed token's, and a token for
another application, an expired one, garbage, a header without a token
or a token without the header are refused, as is a good token not via
Caddy. Wrong passwords and unknown names are 401 and the right one
after them signs in with a session cookie; a forged one is refused.
PATCH /api/me refuses an over-long block list and a malformed TypeSafe
key before asking TypeSafe. The signing keys are read from a local
stand-in for the team's certs, and a 503 or 404 is an error.

ipx user add, list, passwd, rename and rm run against a scratch
database: user_cmd takes its stdin as an argument, so a password can be
given without a terminal, and access's fetch takes the certs address.

There is no 429 for a flood of wrong passwords to test: that is #409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:10:29 +00:00
b62182e38e Tests for the log's fields and the metrics Grafana reads (#403)
Three tests for what the dashboard and the alert rules read and nothing
checked: every ipx_ metric named in grafana/dashboard.py and alerts.py
(read out of them at compile time) is served by render, with the ones
written only once known (counts, the last scan, a job under way) set
first; ipx's JSON log lines for a scan's events carry ev, feed, new,
bytes, msg and error.type, and inside a span trace_id and span_id; the
directory's snapshot line carries instance, accepted and the refused_
counts.

The JSON layer was built the same way in both services' setup; it is
otel::json_layer now, so the tests run the layer production does.

The log tests set the global subscriber, as production does: under a
scoped one WithTrace finds no current span (#408).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:02:15 +00:00
a92e81aba2 Tests for the reaper deleting files (#396)
retention::run's age and quota passes and remove ran in no test: only
the pure choosers did. Three tests now run them against a scratch
downloads folder and a database: an old file deleted, marked reaped,
its bytes counted and its entry pruned, a dry run deleting and pruning
nothing; over quota the oldest going first with the age pass's take
not counted again; a file already gone reaped without an error, and
one procfs will not unlink left claiming its file with nothing freed.
Held items, which have no path, are never candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:55:52 +00:00
f8408a3d26 Tests, tooling and the served scripts are TypeScript (#392)
The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).

Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.

The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.

boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.

Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:23:22 +00:00
af69b699a8 iPX opens and reads without a connection (#389)
With no network the home-screen app showed nothing: no service worker,
and the lists the page had fetched, full text included, lived only in
its memory.

A service worker (web/sw/sw.ts, TypeScript checked against a worker's
globals by tsconfig.sw.json, built by swc to /sw.js) goes to the network
first for the page and every API read and keeps the answers, so online
nothing is an old copy and #388's version check works as before;
offline the kept copy answers. Scripts named by hash come from what is
kept. Reads, flags and places in an episode that cannot reach the
server wait in an outbox, sent in order when the connection is back
(and new ones queue behind them meanwhile, so none lands out of turn);
a kept list served offline shows the reads waiting there.

Pictures: a worker cannot keep another site's pictures sensibly, since
browsers count each opaque answer as megabytes. ipx now remembers the
picture addresses in item text it sends (bounded, in memory) and
/api/art fetches those as it does artwork, through the guarded client;
the worker keeps them, for unread items, under a 200 MB budget, and
serves them offline in place of the publisher's. Online, pictures still
come from their publisher.

Sign-out, or another account signing in on the device, clears what was
kept. The browser suite blocks the worker except in the offline tests,
since it answers before page.route can.

The fixture JPEGs are placeholders a browser cannot draw; the offline
test uses a real PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:57:31 +00:00
e5388b443f YouTube feeds are read one at a time, two seconds apart (#386)
A scan fetches six feeds ahead of its turn, so channels next to each
other alphabetically (android-authority-2, android-developers, apple,
architectural-digest-2) went to YouTube together and failed together,
while one asked alone a minute later read. feed::fetch now takes a turn
before each request for a YouTube feed: one at a time per process, two
seconds after the last, its wait a `youtube_turn` span. The retries
take their turn too, in place of their one-second sleep. The directory
builds the same fetch and paces its own checks the same way.

39 channels due together add up to 80 s to that scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:54:32 +00:00
bbcf69062e A failing YouTube feed is asked again hourly for a day (#386)
#382's retries run (each failing fetch takes the two seconds) and still
end 404: YouTube fails about 70% of feed reads from Tower, in streaks
longer than seconds, for ?user= and ?channel_id= addresses alike (142
and 68 failures in 7 days across 26 and 13 feeds). The 39 YouTube
channels are catalogue feeds nobody subscribes to, read once a day, so
each missed about 5 days in 7.

due_at now waits at most an hour for a YouTube feed in the first day of
a run of failures. A channel gone for good costs 24 requests, then backs
off as any feed does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
5c1970e8ee Swipes and a reader that will not scroll are reported (#384)
A swipe that only bounces sends no request and throws nothing, so
#383's stop at the 50th item left nothing in the log or the traces, and
the reader that would not scroll in the same report was never found.

The page now reports each swipe in the reader (next, prev, list, end,
short; a tap is not one) with where the reader stood, under the tap's
trace, and scroll_stuck when a drag up or down with room to move the
reader left it where it was. That one is watched by where the finger
was, not by what it landed on, so a layer lying over the reader is
caught, and its state names what is under the finger, what is inert,
the dialog, the reader's transform, a #dpeek left behind, and how long
since the app came back. ipx logs swipe as info and scroll_stuck as a
warning, in a new free-text state field, and the dashboard counts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:46:31 +00:00
245fa79fbf A YouTube feed's spurious 404 or 500 is asked again (#382)
YouTube answers some requests for a channel's feed with a 404 or 500
in tens of milliseconds, while the next request for the same feed, or
another channel's 200 ms later, reads. Checked once a day, the 39
YouTube channels in the catalogue missed most days: 178 failures in
the week to 2026-11-04, against 74 in the ten days before, and all 39
read from Tower when asked by hand.

feed::fetch now asks a youtube.com/feeds/videos.xml address again, up
to twice, a second apart, when it answers 404 or 5xx. Other hosts'
404s are real and are not retried. tokio moves from ipx-feed's dev
dependencies to its own, for the sleep; both services build it already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:46:14 +00:00
90bbcfb40e Changelog: the blank line after Added stays first (#380)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:15:25 +00:00
3f27dfdfdb The directory has a robots.txt and a sitemap (#380)
Crawlers asked for both about 70 times a day and got 404. robots.txt
keeps them off /search, which is any query string at all, and the token-only
API, and names the sitemap; the sitemap lists /, /all, each category and each
feed. Both need whole addresses, so they are written for the host asked for:
directory.ipxreader.app through the tunnel, whose cloudflared says https, or
the LAN address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:15:05 +00:00
dd91602a49 Metrics and traces for what had none, and alerts on them (#365-#377)
Reviewing what production's traces and metrics leave out turned up
twelve gaps; each is an issue.

Metrics. A Prometheus histogram for ipx and the directory both
(ipx_directory::metrics::Histograms), which ipx's request histogram
(#364) now uses too.
- ipx: each statement's time once it has a connection, by its first
  word, from SeaORM's metric callback; and the wait for one, for
  hand-written SQL in db.rs, measured as the call less the statement
  (the callback runs in the poll that returns the result, so the time
  read after the await is that statement's) (#367). SeaORM's own queries
  are timed but their wait is not seen: a shortcut, noted.
- ipx: counters of what the events say, counted in emit, which every
  event passes once: feed failures by error.type, new items, downloads
  saved and failed, DeadAir's seconds found (#368).
- ipx: the page's list_shown, play_start and stall timings (#369).
- directory: requests by method, route (a route layer puts the matched
  route on the response, or each feed's page was a row) and status;
  feeds checked and failed; snapshots taken (#370).
- ipx-web: Caddy's own metrics on METRICS_BIND (:9180), served apart from
  the port the tunnel reaches. `servers { metrics }`, which Caddy 2.11
  calls deprecated, because the browser tests' Caddy is 2.6 (#371).

Traces.
- A list's sync is list_sync with steps, an OPML upload opml_import
  (#372). Doing so showed sync_group reading every account and its
  subscription to the list once per listed feed; read once now (#377).
- Each command the worker runs, from the socket, the CLI or the page,
  is a command span with its job inside (#373).
- The daemon's start is a trace, from opening the database through the
  catalogue's repairs to "daemon started" (#374).
- The directory's lines carry trace_id and span_id: WithTrace moves to
  ipx_directory::otel and both use it (#375).
- A tap or key is its trace's root span (#376). The page names a root
  span id in each request's traceparent, where the parent was random and
  pointed at nothing, and reports the tap (ids, start, its last answer)
  when the next begins or the page is hidden. opentelemetry 0.33 gives a
  span builder no way to take ids, so the shared tracer provider has an
  id generator that takes given ids inside with_ids, on one thread.

Alerts. grafana/alerts.py, Grafana alerting provisioning beside the
dashboard (#365): ipx, the directory or ipx-web down, scans stopped, a
job over 30 minutes, the pool full, 5xx, 502s, Jev refused, Signals
stopped, the directory's refresher stopped, Tower's load and memory, the
monitoring down, Tempo not reading its blocks, iPX's lines not reaching
Loki. No contact point yet: firing shows in Grafana. Prometheus now
scrapes Tempo, Loki, Alloy and itself (#366), in the monitoring project's
config, not this repository's.

The dashboard has database and page-timing panels; the prod check knows
the new metrics, spans and alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:18:06 +00:00
c788112190 Requests are counted in /metrics, and only a failed one is logged (#364)
ipx logged a line for every request: 2,306 a day, over half its log by
size, repeating Caddy's access line and the request's span. It stayed
because ipx had no request metric and the dashboard's API row was built
on the line. /metrics now has ipx_http_request_duration_seconds, a
histogram by method, route and status (5 ms to 10 s), the event stream
left out as the panels left it out. The route is the pattern, "" for an
unrouted path, and an unusual method is "other", so a scanner cannot make
a row a request. A request that went well is logged at debug, which the
admin page's log view keeps; a failed one keeps its warning in Loki.

The dashboard's API row reads the histogram (the slowest-request series
becomes the 99th percentile, a bucket's bound; the slowest themselves are
in Tempo). prom() takes instant queries and table() a unit for it.

The prod-check skill, reviewed whole: check 3 reads the histogram; the
directory has metrics, which it said it had not; "all gauges" was half
counters; the healthcheck removed in #218 was still named three times;
Caddy's /api/events aborts (600 a week, a page closing its stream) are
filtered as by design; the monitoring section checks Tempo's block poll,
which failed on 2026-10-25 on one empty block and left every search with
only the last 15 minutes; and the lines to expect from each container
are counted over a day, now that iPX can be quiet for an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:11:20 +00:00
6352324bcb The logs stop repeating what metrics and their own fields say (#363)
iPX logged scan_done after every scan: 921 lines a day, a fifth of the
log, nearly all "scan complete, 1 feed(s)" since scans became one or two
feeds at a time (#114). ipx_jobs_total, ipx_feed_fetches_total and
ipx_last_scan_age_seconds hold all of it. It is routine now, at debug, so
the page's log view keeps it. The dashboard's "feeds checked" series and
the prod check's check 4 read the metrics instead.

ipx-directory wrote each line's span twice, as `span` and in `spans`, and
neither added anything: a request's span repeated its method, path and
status, refresh.feeds repeated `checked`, a feed span was empty.

ipx::http stays: it repeats Caddy's access line and the span, but holds
the route pattern and ipx's own time, which the dashboard groups by.

The prod check's `status` line was the healthcheck's every 30 s; since
#218 it is only `ipx status` run by hand, and the skill says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:42 +00:00
1d54f5141d The same feed over http and https is one feed (#362)
rays was subscribed to oldvcr's blog twice, once at
https://oldvcr.blogspot.com/feeds/posts/default and once at the same
address over http, and saw every post twice. same_feed compared
addresses exactly but for Patreon's, so add_to_catalogue found nothing
there already; check_url compared exactly too and now asks same_feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:00:20 +00:00
c6ca4d1a91 A show made a feed of its own leaves its list's group (#358)
Unsung's unread counted 2 in the feed list while All Subscriptions'
unread list was empty. It had been a show of an OPML that is hidden
from All Subscriptions, and became a feed of its own; Db::unmanage set
managed = false and left feeds.group_id pointing at the OPML, so
shown_in hid it with the list. 20 feeds in production were like it.

unmanage now sets the group from the feed's catalogue entry: none for
a show promoted when its list dropped it or went away, the list's for
a show promoted by editing its settings, which config keeps in the
group. A pass at start-up moves those already left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:57:17 +00:00
474be3e0a8 ipx 1.5.2-dev, ipx-directory 1.4.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:11:09 +00:00
ed9f008c6c Release ipx 1.5.1 and ipx-directory 1.4.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:03:13 +00:00
b72e65711f A feed is read up to 200 MB, not 50 (#357)
The Herd with Colin Cowherd's feed is 62 MB, its whole back catalogue, and had failed every check
since the 50 MB cap of #295 came in on 2026-10-19. The cap was there for an MP3 given as a feed;
200 MB still stops that, at four times the largest real feed seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:49:30 +00:00
62f8451700 Changelog: log lines' target names their module since the split (#356)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:57:04 +00:00
ecff2f8dcc The directory's lib.rs splits into leaf modules (#356)
The crate root held the wire types, Directory with its refresher and
reloader, and every /v1 handler. It is now the module list and the
re-exports ipx and the directory's main use, so their paths are as
before:

  wire.rs       Listing, FeedPage, SnapshotEntry, ... and MAX_SNAPSHOT
  directory.rs  Directory, Shared, the refresher and the reloader
  api.rs        the router, the token check and the /v1 handlers

Directory's fields and methods are pub(crate) for the handlers beside
it. Its two tests that read those fields went with it, on a scratch
Directory of their own; the API tests stay with the API. Every
definition and doc comment line of the old lib.rs is in exactly one of
the new files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:48:05 +00:00
ff5d786602 ipx-feed's lib.rs splits into leaf modules (#356)
lib.rs held 2,000 lines: fetching, parsing, OPML, Patreon, discovery,
artwork, categories, Jev and redaction, and their tests. It is now the
module list and the re-exports, so ipx and the directory call
feed::fetch, feed::parse and the rest as before:

  fetch.rs      fetch and its limits    parse.rs     RSS and Atom
  outlines.rs   OPML (not opml.rs, which would shadow the opml crate)
  patreon.rs    a creator's shows       discover.rs  finding a feed
  wordpress.rs  WordPress.com's API     failure.rs   failures in words
  privacy.rs    redact, looks_private   artwork.rs   https art, icons
  category.rs   Apple's categories      jev.rs       Jev's category

Moved verbatim, private items pub(crate) where a sibling calls them,
calls across modules module::function. Every definition and doc comment
line of the old lib.rs is in exactly one of the new files; each test
went with what it tests, and all 40 run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:41:14 +00:00
4f4fd4c3fe ipx's main.rs splits into leaf modules (#356)
main.rs held 3,200 lines: the command line, the shared context, the
daemon, scans, downloads, the catalogue and OPML, and their tests. The
style guide keeps a root module to declarations and a little glue, the
code in leaf modules named for what they hold:

  cli.rs        the commands, run and user_cmd, ago and duration
  ctx.rs        Ctx and CfgEdit, and Ctx::new, which main() built inline
  daemon.rs     the daemon's loop, web server, shutdown, status, reload
  scan.rs       due_at/due_after, scan_one, policy, downloads, torrents
  catalogue.rs  add/rm/leave/remove_user, subscriptions, retiring lists
  lists.rs      OPML import and syncing an OPML's or a creator's feeds
                (not opml.rs, which would shadow the opml crate)
  logging.rs    logging::init and the JSON lines with their trace
  test_util.rs  the context, feeds and feed server tests share

first_password moves to auth.rs and reap to retention.rs. Each test went
with what it tests; all 151 still run. main.rs is the module list and a
main() that hands the command on, 113 lines.

Moved verbatim, then made pub(crate) where a sibling calls it, with
calls across modules written module::function as the guide asks.
Everything the old main.rs defined is defined once in the new files.

With Ctx no longer a pub type at the crate root, Db's pub methods stop
counting as public API, and the compiler sees what nothing outside tests
calls: entries_in, count_in, unread_count and feed_description are now
#[cfg(test)], and media_feeds, called by nothing, is gone.

ipx depends on opentelemetry_sdk directly now (it was already in the
lock through ipx-directory), for logging::init's signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:35:24 +00:00
f22c1993b1 Import type names instead of writing their paths (#355)
The style guide imports types and uses them unqualified. 222 places
wrote a path such as std::collections::HashMap or std::time::Duration
inline; each file now imports the type once, one use a module, and
names it bare. Done with a script, file by file, only where the name is
unambiguous there:
- one path for the name throughout the file, and not a name the file
  already binds otherwise (its own types, other imports, a function's
  local use);
- not a name generic enough to mislead bare (Error, Result, Value,
  Mutex, Client, Request, Response, Command, Event, Context, Map, Ok);
- only code a normal build compiles, so no import is unused outside
  tests or names a crate only tests depend on.

193 paths stay, those cases above. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:19:10 +00:00
d33d83043d Doc comments on ipx-feed's and ipx-directory's public items (#354)
The style guide asks for doc comments on a library's public API. Every
type, function, constant and static the two libraries export now has
one, saying what it is for or what it does that its name does not:
fetch follows redirects itself so credentials stay on the feed's host,
Config::parse refuses repeated token names, Store::due_one is the
admin's Refresh. Fields and variants whose names say it all have none,
as the guide allows.

category_path's comment had slid above categories(), which then had
two; each has its own again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:12:14 +00:00
41affc8d8e Follow the Rust style guide's smaller rules (#353)
Measured against /src/styleguide/rustguide.md:
- Each use statement imports from one module, and free functions are
  called through theirs (db::step, db::now, jsonwebtoken::decode,
  crate::feed::redact, unix::signal) rather than imported bare.
- unsafe_code is forbidden workspace-wide. The one unsafe was a test's
  set_var, which is unsound while other tests' threads read the
  environment; the test now reads PATH, which every run has.
- Locks say why they may panic: expect("a panic elsewhere poisoned this
  lock") in place of 25 unwraps, and unique_slug's endless search says
  it cannot end.
- No combinator chains in for headers: each is a named variable first.
- #[must_use] on the 27 functions clippy's must_use_candidate names.
- A literal bool passed where its meaning is not plain gets its name
  (/* keep_open */ true, /* piece */ false, ...).

No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:07:09 +00:00
59c7f5f213 Back to -dev after ipx 1.5.0, ipx-directory 1.4.0 and ipx-web 1.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:48:48 +00:00
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
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