98 Commits

Author SHA1 Message Date
5ef01593d7 One place to get an episode's file where you are (#457)
Getting an episode onto a phone took two buttons in two states: download
it to the server, wait, then keep it on the device, beside Save to this
computer, Share and Delete, five buttons that did not say where each
copy went. Now a file has one button that brings it to where you are.
On a phone or in the iOS app it ends on the device; when iPX has not got
the file yet, iPX fetches it and the device's copy follows once iPX says
it is done (remembered in localStorage, so a page closed meanwhile
catches up when a list next shows the file). On a computer it has iPX
fetch the file, and with iPX's copy there saves one. A line beside the
size says where the file is, and the rarer actions, two of them
destructive, are under a menu.

isPlayable is false for any file not on iPX, so the phone's button is
decided by playsKind, the same test without the path. download_done now
marks the file as on iPX in what the page holds, so the open file's box
changes at once rather than after the list is fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 18:40:49 +00:00
47e093e313 Phone reader: back and the item's actions float over the text (#456)
On a phone they sat above the title and scrolled away with it, so
marking, pinning or sharing a long post meant scrolling back to its
top. Now back sits alone on the left and the actions in a capsule on
the right, sticky at the top of the reader, as Gmail's iOS reader does.
Each theme's own panel, frosted, so no theme changes colour; Glass gets
its sheen and a denser fill, since the text scrolls right under them,
and Classic its toolbar metal. Read and pin redraw the reader, which
now keeps its scroll position, since they are pressed mid-post. The
desktop reader is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 17:43:45 +00:00
757ce5fbb3 Share never hands over a private feed's file (#455)
Share on a paid episode's file gave the file's own address, which for
Patreon is patreon.com/api/rss/u/<your id>/e/<n>.mp3?sig=..., signed for
the subscriber: whoever got it could download the episode as them. A
private feed's episode is now shared by its page alone, from the file
too, and with no page there is nothing to share. The access warning
matched any patreon.com address, so it warned on a post's page, which
asks for a sign-in and carries nothing of yours; it now matches
Patreon's feed and file addresses only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 17:34:17 +00:00
45ef1e83f2 Share says why an episode gets no play link (#454)
A private feed's episode, or one ipx-play could not make a link for,
was offered its own address with no word of why, so someone expecting
a page anyone can play could not tell it was on purpose. The menu now
says the feed is private to them, or that the link could not be made
just now, beside the warning for an address carrying their access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:26:01 +00:00
b3636e648f Share works in the iOS app on an episode of a private feed (#452)
With no ipx-play link (a private feed, or no ipx-play set up), Share fell
back to the share sheet after the request for the link, which WebKit
refuses once an await has come between the press and it, and a Patreon
address went through confirm() first, which the iOS app answers Cancel
without showing anything (#453). Taps sent the request and nothing
appeared. That address now goes through the same menu as an ipx-play
link, so the sheet opens from a press of its own, and the warning about
an address that carries your access is written in the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 16:57:25 +00:00
c133adf398 docs/play.md: the tunnel and traces reach ipx-play through the host's address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:35:52 +00:00
ac246682f0 Dockerfile.play copies web/logo-dark.svg, which the directory crate embeds (#451)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:30:58 +00:00
Ray Slakinski
679a674fc9 Share an episode as a page, on ipx-play (#447)
Share handed over the raw file address or the item's link: no preview,
no time, nothing a person without iPX could do much with. Now, as
Overcast does, an episode shares as a short link to its own page,
play.ipxreader.app/e/<id>, which anyone can open: the show's art and
name, the episode's title, date, length and notes, a player on the
publisher's own file starting at ?t= if a time was shared, buttons to
subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, and Open
Graph tags for a preview where the link is pasted.

ipx-play is a service of its own (crates/play), built like the
directory: ipx sends it the episode with its token (POST /v1/shares)
and gets the link back. The id comes from the feed's address and the
guid, so the same episode is one link whoever shares it. Only episodes
of feeds the directory would be sent go there, by the directory's own
rule (snapshot_entries), so the two cannot disagree on what is private;
ipx-play also refuses an address that looks private. Anything else, and
any server without ipx-play set up, shares as before.

In the page, Share on an episode opens a menu once the link is back:
Share link, Share from 12:34 (the player's clock, or where you left
off), Copy link. A menu because the share sheet needs a press of its
own, and the request for the link used up the one that opened it.

The page runs no script: the start time is the media fragment #t=, and
its CSP loads nothing but its stylesheet, the art and the file.

Admin, Directory, Sharing episodes sets the address and token. The
browser suite starts its own ipx-play; it was not run here (no Caddy on
this Mac).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:24:18 -05:00
Ray Slakinski
171c77475a iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)
The server address and the player token were set from alerts that
opened on shaking the phone, which nobody finds. Settings now has a This
device section in the app: the server, the player token and its
address, and a Make button that creates an API token and hands it
straight to the app's keychain. The app sends what it has at ready
(appSettings) and takes setServer, setToken and clearToken; a new
server rebuilds the web view as the alert did. The first-launch prompt
and the Cannot reach alert stay native, since there is no page then.

The iPhone is portrait only; the iPad keeps all four orientations, which
Split View and Slide Over need (#434 checked the iPad's layout).

Build 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:39:54 -05:00
Ray Slakinski
69f1a38e9f iOS tests: CarPlay's episodes need a downloaded file (#443)
6d6bfe6 added Enclosure.path, which the tests' enclosures lacked, so the
test target no longer built. They now have one, and a test checks that a
picture and a file not yet fetched are passed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:33:32 -05:00
6d6bfe6e1d iOS CarPlay: the phone's bar takes up the car's episode; only playable shows (#442, #443, #444)
#442: a car's pick told the page `replaced`, and the page closed its player bar, so leaving the
car with the episode playing left nothing on the phone to pause it. And when the car launched the
app, the page loaded later and was never told anything played. Now the app sends `nowPlaying`
(the item, its place, playing or not) on a car pick, at the page's `ready`, and on coming
forward, and the page's bar takes it up and drives the phone's player. `replaced` is still
handled for builds before this one.

#443: Shows listed every feed with items, blogs included. Now only feeds with a downloaded file,
and an episode only with a downloaded audio file: /media/{id} answers 404 before the download.

#444: tests/native-bridge.ts read its failure count before the CarPlay checks, so those could
fail and still exit 0. The count is read last now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:08:14 +00:00
a55f08d4ff Tests for push passes, downloads, OPML round trips and directory refresh
Each against a local stand-in server: Apple's push service answering per token, a feed host
serving a login page and a torrent, a directory refresh of feeds that cannot be read. push.rs
takes the APNs address as a parameter so the test can point it at the stand-in. coverage.sh asks
for 8 GB free, not 9: its container is capped at 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:04:10 +00:00
75d099474c Terminal reader: play with mpv, mark read on move, folders and pins (#439, #440, #441)
p hands the item's downloaded file (/media/{id}) to mpv in its own window, with the token as a
header. Moving onto an item with j or k marks it read, as selecting one on the page does. The
feed list follows the page's order: pinned first, a subscribed OPML a folder that Space opens,
its unread the sum of its feeds', and P pins or unpins. scripts/build-tui.sh cross-builds the
binary for Linux and macOS under the same cap as the image builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:04:02 +00:00
3a3900e959 Image builds keep compiled crates between builds (#438)
The ipx Dockerfile built its dependencies in a layer of stub crates,
which any change to Cargo.toml or Cargo.lock invalidated: all of them
compiled again in release, 405 crates, 170 s at the builder's cap. The
directory's had no such layer and compiled everything every time. Both
now build with cargo's registry and target/ as BuildKit cache mounts.
Measured on Tower: a Cargo.toml change rebuilds three crates in 52 s
instead of 405 in 170 s. The peak is about the same, since the ipx
crate itself is most of it; what goes is the minutes at the cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:05:12 +00:00
f9e4c1d800 CLAUDE.md: the home page deploys with wrangler and an API token from /src/.envrc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:38:12 +00:00
dccf9e1762 prod-check skill: the server's command in the container is ipxd (#437)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:32:55 +00:00
Ray Slakinski
2bb7ed2109 iOS: CarPlay (#420)
A CarPlay scene beside the phone's: In Progress, Unread and Shows as
CPListTemplates from ipx's API, read with the player's credentials, and
a pick played through the same Playback, so it resumes from the saved
place, saves as it goes and marks read at 90%. Now Playing is the
system's template over Playback's now-playing info.

When the car starts a different episode the page is sent
{t:"replaced"} and closes its player bar. Left open, the bar would have
gone on as the old episode while the host's clock belonged to the new
one, saving that time and marking the wrong item read.

The badge's fetch moves into API.get, which CarPlay shares. Build 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:10:34 -05:00
711b0395f1 A terminal reader, ipx; the server is ipxd (#437)
ipx-tui (crates/tui) is iPX's reader in ratatui: your feeds, a feed's
items, an item's text as plain text, read and flagged as on the page,
through the API with an API token. It installs as `ipx`, so the daemon's
binary is now `ipxd`: the Dockerfile, release script, tests and every
doc and message that names its commands follow.

Naming the binary ipxd alone renamed its crate, and with it every log
target (ipx::scan became ipxd::scan): the default filters are ipx=info,
so nearly every line would have been dropped and the dashboard's queries
on the targets gone blank. The daemon's code is now a library still
called ipx, under a five-line ipxd main.rs, and the targets are as they
were.

ratatui is taken without its default features: 32 new packages in place
of 78.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-09 11:52:10 +00:00
Ray Slakinski
f844cf349a iOS: Siri and Shortcuts, and a widget (#427, #428)
Siri: App Intents with phrases that need no setup: Resume, Pause,
Skip forward, Skip back, Set a sleep timer, How many unread. The
playback ones are AudioPlaybackIntents, run in the app and allowed to
start audio from the background. Playback is now one shared instance
(Playback.shared) so Siri and the widget reach it with no page loaded.
It remembers the last episode and its place (LastPlayed), so Resume
works with the app closed; the page, opened afterwards, does not know
about an episode started that way.

Widget: small and medium on the home screen, circular and rectangular
on the lock screen. It shows the unread count and the last episode,
with a Resume or Pause button (PlayPauseIntent, shared with Siri). It
draws what the app writes to the App Group group.net.sdf1.ipx. The app
asks for a redraw on play, pause, a new episode or a new count, not on
every position, since iOS rations redraws. Shared/ builds into both
targets, with WIDGET set in the widget's, where the player does not
exist.

ipx's push now carries content-available, which wakes the closed app
for a moment to give the widget the count the icon just got (#433).

Tested: unit tests for the snapshot and the remembered place, 19 in
all; in the simulator, playing an episode wrote the snapshot to the
App Group. Not testable there: Siri, and the widget drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:33:04 -05:00
8f888ae10e Admin page: an error toast no longer throws for want of haptic (#436)
toast() calls haptic() on an error since cf0dad7 (#430), and haptic is
native.ts's, which admin.js is not built from: every error toast on the
admin page threw a ReferenceError and showed nothing. Never deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:04:12 +00:00
Ray Slakinski
da6cda28a5 iOS: the entitlements file is XcodeGen's output, not source (#433)
project.yml holds aps-environment; the plist it writes is ignored, as
Info.plist is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:47:41 -05:00
Ray Slakinski
bd535851b0 The iOS app's icon count, pushed by ipx (#433)
Background refresh runs when iOS chooses, and for this app that has
been rarely, so the icon's unread count lagged until the app was
opened (#421). Now ipx sends the count:
- ipx: a push_devices table and POST/DELETE /api/push/devices. With
  [push] set to an APNs key, a task compares each phone's All
  Subscriptions unread count with the last sent, once a minute, and
  sends a changed one to Apple as a badge-only push: no alert, no
  sound. A token Apple says is gone is forgotten. The signing token is
  ES256 over the team and time, made again every forty minutes, inside
  Apple's limits.
- iOS: the aps-environment entitlement. The app registers for push
  once badge permission is given, each launch, and hands its token to
  the page.
- Page: registers the token with ipx as the person signed in, since
  the player's address (ipxapi) lets through only the player's paths,
  and removes it on signing out.

jsonwebtoken here is built without PEM support, so the .p8 key's
base64 is decoded in push.rs rather than turning on a feature and
changing Cargo.lock for one file.

cargo test -p ipx on macOS: 182 pass; the 3 that fail read /proc and
fail there without this change too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:47:28 -05:00
Ray Slakinski
e6414941ab iOS: the icon's count follows the page's (#421)
Reported: the unread count on the icon only changed when the app was
opened. Two reasons:
- Items read in the page never reached it, since only the player's own
  mark-read refreshed the badge.
- The background refresh that should bring new items runs when iOS
  chooses, which for this app has been rarely.

The page now sends All Subscriptions' count ({t:"unread", n}) each time
its list is drawn with a new one, so reading, and new items arriving
over the event stream while the app is open, set it at once. While an
episode plays the app is awake with the screen locked, so the player's
tick also re-checks every quarter of an hour.

With the app closed and nothing playing, the count still waits on iOS's
refresh. Push (#433) is what makes that exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:18:43 -05:00
Ray Slakinski
b455c1ec8c iOS: iPX opens with no connection (#435)
A WKWebView runs no service worker unless the app lists its domains
in WKAppBoundDomains and the web view limits itself to them. So the
Info.plist lists ipodderx.sdf1.net, with Cloudflare Access's
(rays-sdf1.cloudflareaccess.com) and Authentik's (authentik.sdf1.net)
hosts so signing in still works. For a server in that list the web
view is made app-bound and #389's worker runs: the page and its lists
open offline from the second launch, since the first only installs the
worker.

The costs:
- Navigation elsewhere is refused, and the failure looked like the
  server being down. decidePolicyFor sends such links to Safari.
- With the key present, WebKit cuts the bridge for any server not in
  the list. That server then gets the page with its own audio, not
  the phone's player.

The web view is made again when the server changes, since being
app-bound is fixed when it is made.

A debug build adds localhost through a build script, so the simulator
can test against a page served on the Mac; the release archive's plist
was checked to have only the three. Tested in the simulator: opened
twice online, the server stopped, relaunched, and the page came up from
the worker. With the bridge still up on the app-bound host, the
phone's player played.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:08:27 -05:00
Ray Slakinski
cf0dad7380 iOS: presses are felt (#430)
The page asks the app for a haptic ({t:"haptic", kind}) and the app
plays the phone's own. A light tap for play, pause, flag and pull to
refresh; the system's success for marking read, one item or all; its
warning with an error toast. A browser has no host to ask, and an
older app ignores the message, so there is nothing to gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:57:51 -05:00
Ray Slakinski
2e031763fa Player: a sleep timer, and AirPlay in the app (#429)
The sleep timer pauses after 5 minutes to an hour, fading out over the
last ten seconds. In the iOS app the phone's player keeps the time:
with the screen locked the web view is frozen, and a timer in the page
would not fire until the app was opened again. A browser plays on in a
background tab, so there the page keeps it.

The app says at ready what it does itself ({t:"host", can}), so a page
deployed ahead of the app falls back to its own timer and shows no
AirPlay button an older app would ignore. This deploy order problem
came up with #425.

AirPlay opens AVRoutePickerView by pressing its button from code; there
is no API to present it. On iOS 27 the picker opens on the button's
primary action, not touch-up, so that is what is sent, with touch-up
for versions that have nothing on it. The simulator has no AirPlay
devices and never showed the picker, so this is unverified until it
runs on a phone.

On a phone the player's one line left the title two letters wide once
the two controls were added, so the title takes the top line and the
controls flank play below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 -05:00
Ray Slakinski
ecc248307e iOS: episodes kept on the phone play with no connection (#425)
A WKWebView runs no service worker, so the page's Keep button (#412)
was not shown in the app and everything played from the server. Now the
page asks the app to keep the file (keep, unkeep, clearKept), and the
app answers with what it holds (kept, keepFailed) whether or not
anything plays. The app fetches with a background URLSession, so a
download goes on with the app closed, into Application Support (not
backed up; the files can be fetched again), and Playback plays from the
file when there is one. The DeadAir copy is kept under its own path, as
the browser keeps it. Places and finishes reached offline wait in an
outbox, newest per address, and go when the network is back.

Checked in the simulator against a local server: kept /media/42 and
/media/42/deadair, then with the server refusing /media, a fresh launch
played each from the phone and asked the server for nothing. The page
itself still needs a connection to open (#435).

Deploy the app (build 6) before the page: a new page in an old app
shows a Keep button nothing answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:40:01 -05:00
Ray Slakinski
05e522ac93 iOS: ask for the background refresh as the app leaves, too (#421)
Build 5. The badge's refresh was requested only when the app came
forward; iOS asks for it as the app goes to the background. The
request made on coming forward stays. The timing is still iOS's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:31:21 -05:00
Ray Slakinski
9541f6c6f4 Phone: the bottom totals are centred, low on the screen (#424)
Centred, and the bottom padding is what the home indicator leaves
(safe-area minus 17px) rather than all of it, so the line sits just
above the indicator and the list gets back about 20px. A player open
still takes over the inset, as before. Checked in the simulator in the
glass theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:26:56 -05:00
Ray Slakinski
ca54e83f9c Phone: the bottom totals are text alone, set in from the sides (#424)
#status kept its own background and top rule to the bottom edge on a
narrow screen, where the rounded corners of the phone cut the band off
and the text sat against the curve; the glass theme's tinted, blurred
band made it plainest. Transparent, no rule, 24px in from each side.
Checked in the simulator against the built page in the glass theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:25:27 -05:00
Ray Slakinski
8d4c70a571 Admin page: the top bar clears the status bar in the iOS app (#423)
admin.html's viewport meta lacked viewport-fit=cover, which index.html
has. Without it env(safe-area-inset-top) is 0, so the top bar's padding
did not clear the clock in the app's full-screen web view, and the back
arrow sat under it and could not be tapped. Seen in the simulator, the
built page served locally: the arrow and the title level with the
clock before, below the status bar after.

Also a launch argument, -ipx.noBadgePrompt, so a simulator run can get
past the badge permission dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:18:55 -05:00
Ray Slakinski
4957c297b7 Stop tracking ios/.envrc
It was swept in by the previous commit and holds a Cloudflare Access
service token's secret. The file stays on disk and is ignored; the
secret is in 3626a30 and has to be treated as exposed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:11:22 -05:00
Ray Slakinski
3626a30b52 iOS: the player goes to ipxapi.sdf1.net with one API token; unread badge (#419, #421)
The Access service token plus API token pair was three values to enter
and Access in front of both. The player, the badge and CarPlay now send
the ipx API token alone to https://ipxapi.sdf1.net: a route of the
rays-unraid tunnel to the same ipx-web, with no Access application, held
to the paths the app uses by a custom firewall rule on the sdf1.net zone
(403 for anything without a bearer header or off the list: the page, the
login form, /api/login, ?token= sign-in, the admin API). docs/sso.md
says so, since it also says not to bypass Access for /api/*; this is
that warning's narrow version, and the rule is on Cloudflare, not here.

The unread badge (#421) shows All Subscriptions' count, as the page's
badge does: set when the app comes forward, after an item is read, and
by a background refresh iOS runs now and then. The test target had no
Info.plist and could never have run; it does now.

Build 4. Not yet run on a phone with the token set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:11:03 -05:00
Ray Slakinski
7d4456bb2f iOS: build 2, with the player tokens (#419)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:10:49 -05:00
Ray Slakinski
f6042e9b5f iOS: player tokens in the keychain, and a build that uploads (#419)
The player rode only on the web view's Cloudflare Access cookie, so it
stopped with "Sign in again" when that session ended, and CarPlay (#420),
which has no web view, would have had nothing at all. Credentials.swift
keeps an ipx API token and an Access service token in the keychain, and
the player sends them (Authorization: Bearer, CF-Access-Client-Id and
-Secret) on its audio, position, read-flag and artwork requests. Without
all three it uses the cookie as before. Shake, Player Tokens enters them.

project.yml gets what TestFlight needs: the team, a numeric version
(App Store Connect refuses 0.1.0-dev), upside-down portrait (an iPad
build fails validation without every orientation) and
ITSAppUsesNonExemptEncryption. Resources/Info.plist is generated by
XcodeGen and now ignored.

Not run on a phone: the header option on AVURLAsset is read by name, as
the SDK has no constant for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:08:15 -05:00
a581e3fc88 An iOS app: the page in a web view, with the phone's own player (#418)
web/src/native.ts (#45) has handed playback to a host since September, and
there was no host in this repo: the earlier app, rays/ipodderx-app, grew native
lists beside the page and is left behind. This one is the page and a player:
a WKWebView of ipx-web, AVPlayer behind the bridge with the audio session to
play on locked, now playing and the remote commands (15 back, 30 forward,
scrubbing), and positions and read written from the player's own clock, with
DeadAir's copy named so the server reads the position on its clock.

Built with XcodeGen on a Mac; nothing here compiles the Swift. Its icon is
web/logo.svg at 1024.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 21:50:43 +00:00
6d64cba374 A kept episode's row says On this device (#413)
Only the keep button in the files pane said an episode was kept, so finding
what was on the device meant opening items one by one. The row's line says
it, and keeping or removing one redraws the rows whose state changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:36:12 +00:00
b209f55d2c Admin page: Save waits for a change, and the phone layout fits (#414)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:34:17 +00:00
c6dcb7f34e Admin page laid out as a feed's settings are (#414)
Prototype for approval on the issue; not to be merged until then.

The page had grown one captioned box at a time: at 390 px the tabs ran off
the edge and hid Log, Server was eleven full-width boxes (1150 px wide on a
desktop for a one-digit number) with units and the meaning of 0 in the
labels, and Directory's Save sat between its settings and the lists below.

Each tab is now named parts in .fsec, the layout #290 gave a feed's settings,
with its switches, numbers with their unit after the box, Server's Save
sticky and greyed until a change, the tabs in their own row on a phone, the
Proxy tag by the account's name, and the log's explanation moved into each
source tab's tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:34:17 +00:00
88f4e4765b The search box says Search (#417)
On a phone it read "Search All S", the list's name cut off by the narrow box.
The placeholder is Search everywhere; the list it searches stays in the box's
accessible name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:28:56 +00:00
1b0b155226 One Check every feed button: the top bar's (#416)
All Subscriptions' header had its own, beside Mark everything read, doing
what the top bar's Refresh does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:26:53 +00:00
5a8a7fb0cc Subscribing wakes the Signals judge, so a known feed's items reach Signals at once (#415)
The judge woke for a scan's new items and for changes to Signals, and
otherwise every five minutes. A feed the server already had brings no new
items when someone subscribes, so its recent items waited for the next idle
pass before the new subscriber's Signals saw them. The browser test for a
Signal's new hits ran into this whenever an earlier test had subscribed to
the same fixture feed and left: it waited 20 s and failed in the full suite,
passing alone. scan_soon, which every subscribe from the API calls, wakes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:26:50 +00:00
2c4d03668f Episodes can be kept on the device to listen offline (#412)
Offline mode (#389) kept lists, text and pictures, but the worker let
every /media/ request pass, so no episode played without a connection.

A downloaded episode's files pane has a keep button beside Play. It
fetches the copy that person plays, the file or, when DeadAir cuts
something for them, the version without its dead air (/media/{id}/deadair,
the save button's), whole into a cache of its own, ipx-episodes, and
asks once for persistent storage. A kept episode plays as
/media/...?offline, online or not, and the worker answers that from the
cache with the byte ranges an audio element asks for (206, 416, 200);
not kept there, it goes to the server as before. Pressing it again takes
it off; signing out clears it with everything else. Only someone's
choice is kept, so there is no budget: a full device is the quota's
refusal, said in words. The button is data-a="offline", not "keep",
which the Directory's show page already uses for subscribing.

Places saved from DeadAir's version were read in its clock only if ipx
had handed out that file's playlist since it started (state.playing). A
kept copy plays without one, so the player now names the file in the
position it saves (deadair: <enclosure>) whenever it plays DeadAir's
clock, and ipx loads that file's plan when it has none, for that item's
file only. Checked the beacon is queued by the worker offline: it is.

Tests: playing a kept episode offline, its place queued and sent once
back, and taking it off (the offline show plays quiet.mp3, the only real
MP3 among the fixtures); the worker's ranges; which copy is kept and
played, in page-unit; the server reading a flagged place in DeadAir's
clock without a playlist, and ignoring another item's file. The files
pane's one-row test counts five buttons and checks a phone's width too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 15:31:04 +00:00
07923d665b Page coverage keeps what a document ran before it unloads, and every page (#407)
The fixture took Playwright's page.coverage once, at the end: a reload
or a sign-in's redirect had by then thrown the first document's script
away with its counts, so reloadKeepingPlace read as never run though the
new-version test runs it, and the second people's pages (piper's, opal's)
were not recorded at all.

Each page a test opens, its own and those of the contexts it makes, now
records through a CDP session of the fixture's own (precise coverage,
sources fetched as each script is parsed), and every document stops at a
breakpoint in a beforeunload listener the fixture adds, where it is taken
before the document goes. Taking it on the navigation's event came after
the document had gone, and holding the document's request (Fetch, as
page.route before) held the take up behind it, the renderer answering
nothing while the navigation waited. A page or context is taken again as
it closes. Page lines 80.0% to 84.5%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 14:37:49 +00:00
f5a3847a21 Production's log lines name their trace again (#411, #408)
ba18b44 had the JSON layer find a line's span context through the
dispatch it was registered with, and production's logging wrapped that
layer in an Option, whose Layer impl does not pass on_register_dispatch
on: the layer never got its dispatch and every line went out without
trace_id and span_id. The log test built a subscriber of its own,
without the Option, and passed.

Both services now box their stderr layer instead of an Option of each
kind (a Box passes registration on), the formatter falls back to the
default dispatcher when it was never registered, which a global
subscriber's lines find, and the log test builds the subscriber with
the function init uses, writing to its buffer, so a wrapper like this
one is under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:41:04 +00:00
ba18b445ec Log lines name their trace under a scoped subscriber too (#408)
WithTrace found a line's span with Span::current(), which asks the
thread's default dispatcher; inside an event under a scoped subscriber
(with_default), tracing's re-entrancy guard answers with none, so every
line went out without trace_id and span_id. Production, on the global
dispatcher, was not affected, but the log test had to go global for it.

The JSON layer now carries a small layer of its own that keeps the
subscriber it is registered with (on_register_dispatch, a weak handle),
and the formatter takes the registry's current span from its own
context and asks tracing-opentelemetry for its context through that
handle (get_otel_context), with no dispatcher lookup. The log test runs
scoped again; with the old formatter it fails for want of the ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:34:24 +00:00
8acf57961e Ten wrong passwords from one address hold it off sign-in for a quarter hour (#409)
POST /api/login answered every wrong password 401 for as long as someone
kept trying. Now ipx counts them by the client's address: the tenth in a
window holds that address off until the window, which starts at its first
wrong password, ends, answered 429 with how long is left. Counted by
address and not name, since by name anyone could lock anyone out; a right
password does not reset the count, or a guesser with an account of their
own could clear it between guesses.

Behind Caddy every peer is Caddy, so the address is the last one in
X-Forwarded-For, taken only from trusted_proxies. Caddy now sets that to
{client_ip}: Cf-Connecting-IP for the tunnel's requests, believed only
from TUNNEL_PEER (servers trusted_proxies), and the peer for everyone
else, replacing whatever the client sent. Checked with Caddy against an
echo upstream: a forged XFF and Cf-Connecting-IP from elsewhere reach ipx
as the peer, the tunnel's Cf-Connecting-IP as itself.

The sign-in load test sends each attempt from its own address, so it
still measures a flood from many, whose passwords are all checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:18:38 +00:00
46392c55e1 The daemon test stops its daemons when an assertion fails (#400)
A failed assertion skipped the terminate at the end, and the two runs
that failed while the test was written left their daemons running for
an hour on their scratch sockets and ports. Each daemon the test starts
is now killed when it is dropped, however the test ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 05:55:39 +00:00
f6ba08d506 Coverage measures the page's Node tests and runs the Rust on Postgres (#407)
page-unit.ts and native-bridge.ts run the page's script outside a
browser, so the page's coverage counted none of what they test, and
native.ts read as mostly untested. In a coverage run they now run the
script from a file of its own, named as the browser names it, under
NODE_V8_COVERAGE (scripts/coverage-node.sh, before the browser suite in
npm run coverage:page and coverage.sh api), and the report's teardown
takes their record in, mapped to web/src by the build's source map: the
page's lines went from 73.3% to 80.0% of 3,043, native.ts to 93 of 120.

The cargo tests in coverage.sh's container run on Postgres, as
production does, when IPX_TEST_DATABASE_URL names the _test database,
from the shell or else /src/.envrc (that variable only); on SQLite
without it. The integration tests' own files are left out of the
totals; a crate's inline mod tests cannot be on stable Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:57:32 +00:00
7994f93f31 A download a restart stopped is taken up again at the start (#410)
A file download was never marked 'downloading', only torrents were, so
one under way when the daemon stopped stayed 'pending' and
requeue_interrupted had nothing to do. The first pass after a start
scans only the feeds that are due, and a pending file is fetched only
in a scan of its feed, so after a deploy a half-downloaded episode
waited for its feed's next check: an hour on the default schedule, a
day for a daily feed. Found by the daemon test of #400, where the
episode was not asked for again in a minute after a restart.

fetch_one now marks the file 'downloading' while it runs, as a
torrent's is; every way out of it already records 'done' or 'error',
and the scan's queue takes only 'pending', so a running download is
not queued twice. requeue_interrupted gives back the ids it put back,
and the daemon puts each on the worker's queue before it serves, where
download_one takes it whatever its feed's schedule says. The daemon
test now checks the restarted daemon asks for the episode again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:50:33 +00:00
5c5dd91d21 Browser tests for the Directory's show page, dialogs, keys and player (#405, #406)
The Directory (#405): a search nothing matches says so and shows no
tiles; a show's page draws what the directory says of it, so with a
long description More opens it and Less closes it, a failing feed says
why, its latest items are listed, someone who is not the directory's
admin is offered neither Refresh nor Remove, and Subscribe there says
so and opens the feed. Search, Back, Subscribe from a tile and an
admin's Refresh and Remove were already tested.

The page (#406), on a fixture show of four linked episodes: a
schedule's bar tapped lights that many of the newest; Download latest
3 queues three and they download; v opens the item's own page;
Unsubscribe from the feed's dialog leaves no feed open. Shift J and K
move between feeds, Right and Left open and close a folder from the
keyboard, r takes the lists again. The player keeps its speed and
volume across a reload, reports a stall of over half a second to the
server's log, and marks an item played to its end read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:45:11 +00:00
ee5610ba4e Browser tests for the page's live updates (#404)
events.ts's handlers for a download in progress, finished and failed,
and for Signals' new hits, ran in no test. The fixture server has a
show to download from by hand: one episode sent over two seconds and
one that always fails, added with a word nothing has so that nothing
downloads on its own. Downloading the slow one fills its bar, the count
line says how far, the toast names the file and the row shows it
downloaded; another person with a page open, not subscribed, hears of
none of it (#294). The broken one says which item failed and what to
do (#304), and its bar stops. A Signal open on its page shows its new
hits and its count as soon as a pass picks them out, without a reload;
its folder is opened from the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:37:45 +00:00
8e8307b569 Tests for the directory's Apple sync, metrics and dropped snapshots (#402)
The weekly Apple Podcasts sync runs unattended and ran in no test. Its
Apple address is now an argument (run passes Apple's), and its pause
between calls is none under test, so a test runs it against a stand-in
with one category: the top chart and the category's are taken each
show once in order, looked up, and filed as the apple-podcasts
snapshot with a feed that has a key in it refused as private; a feed an
admin blocked is left out of the next; and a sync whose lookup fails
is an error that leaves the last good snapshot as it was.

/metrics serves every directory_ metric the dashboard and alerts
query, read out of them. Dropping a server's snapshot through the API
is for admins only, and takes the feeds only it listed, keeping one
another server lists too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:32:28 +00:00
c00d5383de Tests for feeds that move, WordPress.com, Patreon and ipx add/rm (#401)
follow_move, which rewrites a feed's address in the catalogue after a
permanent redirect, ran in no test; the fetch only said where a feed
went. It now: the feed keeps its id, so its subscribers and their read
state, takes the new address in memory and in the database, and is
left as it was when another feed already has that address, or when it
is an OPML's, whose list would only put the old one back.

WordPress.com's browser check is answered from its API's posts, and a
Patreon creator's shows are asked of Patreon by the campaign its feed
names, each show the creator feed with its own show and credentials;
an unknown one is an error. Both against a stand-in: the two APIs'
addresses go through api_base, which a test points at a server of its
own; production's are unchanged.

ipx add names a feed from its title, keeps its folder and keywords,
refuses it a second time, and asked again to list it or with a
category changes only those; ipx rm takes it out of the catalogue and
the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:29:03 +00:00
2db42b5fae Tests for the daemon's start, shutdown and CLI (#400)
The daemon ran as a process in no test but the browser suite's, which
never stops it mid-job or starts a second. crates/ipx/tests/daemon.rs
runs the built binary on a scratch config and SQLite database, against
a feed served from the test whose episode takes minutes: ipx status
goes through the socket and with --local does the work itself; a first
start with no token writes one, keeps it out of the log, and it signs
in; a second daemon on the socket is refused and the first keeps
serving; SIGTERM mid-download stops it within 5 s, removes the socket,
and leaves the episode queued; it starts again cleanly; and with no
daemon, ipx status works on its own. A process of its own, since a
signal sent to the test binary would reach every test in it.

until_next_scan keeps its floor and ceiling: 10 minutes with nothing
to check, 30 s with a feed due now.

The abandoned download is taken again only at its feed's next check,
not at the next start: #410.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:24:54 +00:00
1253b470a5 Tests for torrents: a real swarm, the guard and the scan's deferral (#397)
torrent.rs's fetch, and scan.rs's spawn_torrent and torrent_one, ran in
no test. A test now makes a swarm on 127.0.0.1: a seeding librqbit
session and a web server that serves the .torrent and answers as its
tracker, naming the seed. A detached torrent then downloads through
ipx's own session into the enclosure's own folder, the row is recorded
done with that folder as its path, the file is byte for byte the seed's,
and the slot is free again. The .torrent is refused from an internal
address by the guarded client unless fetch_internal allows it. A scan
hands a .torrent enclosure to the torrent worker, counted as a torrent
and left 'downloading' while it waits for a slot, and skips it, still
counted, with torrents off.

A test's torrent session joins no public DHT and sends no LAN
multicast (cfg!(test) in Torrents::new); production's is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:19:59 +00:00
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
bda2ef07be Browser tests for the directory's public pages (#395)
The pages anyone can browse at directory.ipxreader.app (site.rs) were
at 4% under the browser suite, which only called the directory's API.
directory-site.spec.ts opens them as a visitor would: the home page's
chart, a category and A to Z, sorted and as many as it says; the
podcasts and blogs tabs, which split the feeds between them; search
from the header, and a search that finds nothing; a feed's page, its
address, and Copy putting it on the clipboard (site.ts, the pages' one
script); an unknown feed's 404; and robots.txt and the sitemap, which
lists every feed's page. No script error, nothing the security policy
refuses, and no failed answer of the directory's own on any page; the
fixtures' covers are their publisher's and one is missing on purpose.

site.rs is at 87% under the browser suite now, the directory at 69%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:46:49 +00:00
194a9250e7 The API's coverage is measured, and every suite's is in Grafana (#394)
The cargo tests put web.rs at 17% and daemon.rs at 10%, because the
browser suite, which drives the API, ran a daemon nobody measured.
`sh scripts/coverage.sh api` builds an instrumented ipx and
ipx-directory in the capped coverage container and runs the browser
suite here against them (IPX_API_COVERAGE): web.rs is at 80% and
daemon.rs at 77%. What it does not reach: access.rs (Cloudflare Access,
0%; its unit tests 73%) and the directory's public pages (site.rs, 4%).

The coverage image is Ubuntu 24.04 now, as code-server is: built on
Debian trixie, the binaries needed glibc 2.41 and code-server has 2.39.
In that mode the suite starts those binaries rather than building its
own, its tests' `ipx` commands too (global-setup's IPX_BIN), passes
LLVM_PROFILE_FILE to both servers, and stops them with SIGTERM, since
an instrumented binary writes its profile as it exits and Playwright
otherwise kills them outright.

Reports go to target/coverage/cargo, api and page. Each run ends with
scripts/coverage-metrics.ts, which writes every suite's lines covered
and in all, by crate, to node-exporter's textfile directory; the
monitoring project's node-exporter now reads it (one flag, its compose
file backed up beside it), so Prometheus keeps them as
ipx_coverage_lines, and the iPX dashboard's Coverage row draws them:
each suite now, over time, and by part. The cargo tests' totals hold
their own code, which runs whole, so they read higher than the code
alone; the panels say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:33:49 +00:00
352e2b77a5 A test signs in with the sign-in form (#393)
Every browser test signed in with the token link, so the form was never
run: login.ts at 0% in the page coverage (#390). login.spec.ts, on the
suite's page before anything signs it in: signed out, the app sends you
to the form, its name field focused; a wrong password shows the
server's "wrong name or password" and leaves you signed out; the right
one, submitted with Enter, opens the app as admin. With the form
changed to show only its fallback message, the second fails, as it
should. login.ts is at 87% of lines now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:16:42 +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
b509ea4b35 Coverage for the Rust and the page, and tests for the offline gaps (#390, #391)
Nothing measured what the tests reach. scripts/coverage.sh runs the
cargo tests under cargo-llvm-cov in the ipx-coverage image
(Dockerfile.coverage, built by build-image.sh coverage), in a container
held to the image builder's 6 GB and six threads, one crate at a time,
with the same room and load checks: an instrumented build rebuilds every
dependency, the kind of build that took Tower down on 2026-11-04. The
first run stayed under load 8 with 11 GB free; 69% of lines. web.rs and
daemon.rs read low because the browser suite drives them against a
daemon that is not instrumented.

npm run coverage:page runs the browser suite on a build of the page
(IPX_COVERAGE=1) whose script keeps a source map to each web/src file:
web/concat-map.mts maps the joined files line by line and swc takes it
as its input map. Each test's page records V8's coverage
(tests/ui/coverage.ts, which the specs now take test and expect from),
and monocart-coverage-reports writes it file by file: 73% of lines.
build.mjs requires the map helper only for that build, since
Dockerfile.web's Node does not run TypeScript, and not with a top-level
await, which stopped the CommonJS tests requiring build.mjs.

The offline tests move to offline.spec.ts, in TypeScript, and stand
alone (they add their feed and set the read state they need), with what
#391 found untested: another account signing in clears what was kept, a
position saved offline waits and is sent, and pictures past the budget
go oldest first (keepPictures takes the budget, and trims whether or
not anything new came). The fixture JPEGs were a few bytes no browser
could draw; they are real now, and the artwork test checks one draws.
tsconfig.tools.json type-checks the tests' and build's TypeScript, in
npm run typecheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:55:19 +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
742f76969e An open page takes up a new version by itself after a deploy (#388)
The live stream already came back after a deploy and the lists caught
up, but the page kept running the script it had loaded, in a tab or the
home-screen app, until reloaded by hand. Back after a drop, the page now
asks for / again without the cache; another app.js named there is a new
version. With nothing to lose it reloads, and the item that was open,
carried across in sessionStorage whole since a read one is off the
Unread list, is opened again. While an episode plays, a dialog is open,
a search is set or a field is typed in, it offers a reload in a toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:04:55 +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
d3b8c4cd01 A short Unread list read through no longer says more to load (#385)
hasMore() compared nextOffset(), which on Unread counts only the loaded
items still unread, with S.total from the answer: once they were read it
was 0 against that total, so the page asked for a page that came back
empty and took its total, 0, with the items still on screen. Whether
more is left now comes from the last answer itself, its offset and items
against its total; reading takes an item off the server's list and off
nextOffset() alike, so what that answer said is left stays true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
8a66f9c7ee prod-check names each trace's service and an event's fields (#387)
Two 10 s error traces named `poll` read as unexplained in a check: they
are hnopml's, a Hacker News fetch timing out, which the event's own
`error` field said all along. `traces` now prints the root service,
`trace` marks where the service changes and prints an event's fields,
and the skill's error-trace search keeps to ipx's three services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
1b2343cf28 prod-check reads swipes and a reader that will not scroll (#384)
Step 7 counts swipes by outcome, lists the ones that bounced at the end,
and every scroll_stuck with its state, and says how to read each: an end
with more to load is #383 back, and a scroll_stuck is filed with its
state and the swipes before it, being the one record of that bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:56:15 +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
385589ff77 Swiping past the 50th item fetches the next ones (#383)
The item list is fetched 50 at a time, and only the list's Load more
button asked for more. A swipe or j stops at the last item loaded, so
after 50 items the reader bounced on every swipe until a reload brought
a fresh page of unread ones.

Turning to within five of the end now fetches the next page behind. Its
offset counts what is still unread on the Unread tab: the items read
here have left the server's unread list, and Load more's flat 50 a page
skipped as many unread items as had been read. A refresh (a return from
the background, a live update) always asks for the first page, as the
kept list it fills is one, where it used to ask for whatever page Load
more had last reached. A page appended after new items arrived no longer
repeats ones already loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:36:45 +00:00
c8cbb3047a Images build in a capped BuildKit builder
A plain `docker buildx build` of ipx on 2026-11-04, its dependency layer
invalidated by a Cargo.toml change (#382), rebuilt everything in release
on all twelve threads with 5 GB free and no swap. Tower thrashed for
three hours with every core pegged and had to be rebooted.

scripts/build-image.sh now builds every image: it refuses under 9 GB
available or above load 12, and builds in the docker-container builder
ipx-capped, held to 6 GB and six threads by its cgroup, so a build that
outgrows that is killed instead of stalling production. The builder
pulls base images through mirror.gcr.io, which replaces tagging local
copies to dodge Docker Hub's 429s. CLAUDE.md deploys only through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:23:23 +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
72e4a97f87 ipx-web uses Caddy's global metrics option (#381)
The browser tests ran Debian's Caddy 2.6, which has no global metrics
option, so the Caddyfile nested it in servers, which production's 2.11
logs as deprecated at every start. The tests now run Caddy 2.11 from
Caddy's own repository (install.sh), the version the caddy:2 image has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:23:27 +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
08b28f0598 A tap's span ends with its own requests' answers (#379)
traceparent keeps one trace for any request within a minute of the last,
and the tap span ran to the last answer in that trace, so a live update's
refresh 13 s later made a tap answered in 0.9 s read as 15 s (trace
a895551d20626fb6dc071d336262b772). Now a request counts towards the tap
only when one of the tap's own is in flight or it begins within two
seconds of the tap or its last answer; it still joins the trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:05:31 +00:00
5ee633e700 Changelog: no blank line inside Fixed (#378)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:48:02 +00:00
070b39e840 The page stops recursing when a tap ends long after its answer (#378)
endTap reported the tap before clearing it; report's traceparent starts a
new trace when the last is over a minute old, and newTrace ends the open tap
again, so hiding the page (switching apps on the phone) a minute after using
it recursed until the stack ran out. Timings are never throttled, so nothing
broke the loop. Introduced in #376.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:47:51 +00:00
c4f0a362d8 The directory's refresher alert waits out a restart (#365)
Its rounds come in bursts about twelve hours apart and its count starts
again at each restart, so for hours after a deploy no check had been
counted and the rule would have fired. It judges only once the directory
has been up 13 hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:35:46 +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
eae0ef5061 The page tells two feeds' items with one guid apart (#361)
Rows, the selection, the items turned past and the player were matched
by guid alone, so in All Subscriptions two feeds giving a post the same
guid (a blog's RSS and Atom) were one item to the page: clicking the
second selected and marked the first, and the second never read. They
are matched by feed and guid now (entryKey, the key readWrites already
used); data-guid stays on the row for the tests, data-key is matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:00:14 +00:00
2c7e23c346 An item's text takes four fifths of the reading pane, not 80 characters (#360)
Asked for as a share of the window. The pane is the window less the
feed list, so a share of the window would never bind; a share of the
pane does. On a phone the pane is the screen, and keeps all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:49:44 +00:00
cad14ec09d An item's text keeps to about 80 characters a line, not 70 (#360)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:44:09 +00:00
cbc0c48f46 An item's text keeps to about 70 characters a line (#360)
Across a wide window a line ran past a hundred characters. On trial:
reverted if it does not suit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:40:58 +00:00
56778df9c5 CLAUDE.md: ipx-web's base images from Google's mirror too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:36:19 +00:00
4249393f2a An interface font and a reading font in Settings (#359)
The font chosen before is the interface's, under the same cookie, so a
choice already made stays as it was. The reading font, ipx_rfont, sets
an item's title and text and is the interface's until chosen. Text read
in a serif gets a line height of 1.75 for 1.65, whichever setting put
it there. Each face's stack is a custom property, used by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:35:02 +00:00
75afaa291c Reading fonts in Settings: Atkinson Hyperlegible, Lexend, Literata, Source Serif, OpenDyslexic (#359)
All under the SIL Open Font License, taken from Fontsource's npm
packages with their licences beside them in web/fonts. Atkinson is the
Braille Institute's 2025 Next, one variable file. Each is its Latin
subset only, 33-118 KB, fetched once a browser chooses it; anything
outside Latin falls to the next font of the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:26:02 +00:00
ea5c15cc58 The font's default is called Default (set by the theme) (#359)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:18:21 +00:00
6f38c0ad9b Choose a font in Settings, beside the theme (#359)
Kept as the theme is, in a cookie each browser its own, and put on the
page by boot.js before the first frame. Theme's own, the default, adds
no rule, so Modern's Inter, Classic's Lucida Grande and Glass's system
face are as they were. The others are on the device already or served
by ipx (Inter), so choosing one downloads nothing. Their rules are
last in app.css, where they tie with each theme's own body font and win.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:10:28 +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
195 changed files with 14304 additions and 1542 deletions

View File

@@ -3,7 +3,8 @@
"allow": [
"Bash(ffprobe -v error *)",
"Bash(env)",
"mcp__outline__read_document"
"mcp__outline__read_document",
"Bash(sh scripts/build-tui.sh)"
]
},
"hooks": {

View File

@@ -10,11 +10,12 @@ Production is three containers in the Arcane project `ipx` on Tower
| 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-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) | `job="ipx-web"`: Caddy's own `caddy_http_*`, from `METRICS_BIND`, published as `192.168.1.130:9190` (#371) |
| `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 |
| `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) | `job="ipx-directory"`, its `/metrics` |
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
Grafana's alert rules (`grafana/alerts.py`, #365) watch all of it between checks: start by asking
which are firing (the monitoring section). 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
@@ -60,19 +61,24 @@ Lines inside a traced span, requests and scans, also carry `trace_id` and `span_
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`.)
What is routine is logged at debug, which goes to the admin page's Log view and not to Loki: a
request that went well (#364), a scan's end (`scan_done`, #363), a feed not due (`feed_skip`), a
feed starting (`feed_start`), and a feed that brought nothing (`feed_done` with every count 0).
Count those from the metrics, not the log.
| 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::http` | `method`, `path`, `route`, `status`, `duration_ms` | a warning for each request answered 4xx or 5xx; `route` is the pattern, empty for an unrouted path. Every request, as info, until #364; now those that went well are only in `ipx_http_request_duration_seconds` |
| `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::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving on the socket (`-> {...}`) and the answer to `ipxd status` |
| `ipx::daemon`, `ipx::cli`, `ipx::catalogue`, `ipx::scan`, ... | message, sometimes fields | start-up, shutdown, account and config messages, under the module that logs them; plain `ipx` before 2026-10-24, when main.rs was split (#356) |
| `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 |
| `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`, `player_error` and `scroll_stuck` (a drag that should have scrolled the reader and did not, its snapshot in `state`, #384) as warnings; `swipe` (a swipe in the reader: `msg` next, prev, list, end or short, `state` its position, `5/50 of 312, more to load`, #384), `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
Events (`ev`): `feed_start` (debug), `feed_done` (new, downloaded, failed, torrents), `feed_skip`
(not due, debug), `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
`torrent_deferred`, `reaped`, `reap_done`, `scan_done` (feeds; debug), `status` (feeds, pending, downloaded: the answer to
`ipxd status`, only when someone runs it since #218), `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.
@@ -89,7 +95,7 @@ cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not
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:
from 2026-10-11 15:15 UTC. Gauges, but for the counters (`_total`) and the one histogram:
| metric | what |
|---|---|
@@ -102,10 +108,14 @@ from 2026-10-11 15:15 UTC. All gauges:
| `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_worker_busy`, `ipx_worker_job_seconds{kind}` | the worker's job now (`scan`, `reap`, `download`, `status`, `reload`) and for how long; `ipxd status` still answers 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_db_query_seconds{kind}`, `ipx_db_wait_seconds` | histograms (#367): each statement's time once it has a connection, by its first word (`select`, `insert`, ...); and the wait for a connection, measured for hand-written SQL only, about half the queries. A wait growing while queries stay flat is the pool full |
| `ipx_page_seconds{ev}` | histogram: what the page timed, `list_shown`, `play_start`, `stall`, as the `ipx::client` lines say one by one (#369) |
| `ipx_feed_failures_total{error_type}`, `ipx_new_items_total`, `ipx_downloads_total{result="saved"\|"failed"}`, `ipx_deadair_saved_seconds_total` | counters of what the events say (#368): feeds failed by kind, as the `feed_error` lines' `error.type`; new items; downloads; seconds of silence DeadAir found |
| `ipx_http_request_duration_seconds{method,route,status}` | histogram: each request answered and its seconds, buckets 5 ms to 10 s; `_count` is requests, `_sum` their time. `route` is the pattern, `""` for an unrouted path; the event stream is left out (#364). From 2026-10-25 |
| `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 |
@@ -116,8 +126,8 @@ from 2026-10-11 15:15 UTC. All gauges:
| `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) |
| `directory_process_resident_bytes`, `directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`, `directory_uptime_seconds`, `directory_build_info`, `directory_http_request_duration_seconds{method,route,status}`, `directory_feed_checks_total`, `directory_feed_checks_failed_total`, `directory_snapshots_total` | the directory's (the last four #370), `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 daemon counts it every 30 s |
### The checks
@@ -140,16 +150,20 @@ a count says something happened, the lines and traces say why.
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))'
$Q prom 'sum by (method, route, status) (increase(ipx_http_request_duration_seconds_count{job="ipx",status=~"4..|5.."}[$range])) > 0' 24h
$Q prom 'topk(10, histogram_quantile(0.95, sum by (method, route, le) (rate(ipx_http_request_duration_seconds_bucket{job="ipx",route!=""}[$range]))))' 24h
$Q logs '{container="iPX"} |= "\"target\":\"ipx::http\""' 24h
```
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.
Any 5xx is worth a look; the log has each failed request's own line, with its trace id. 401s
are people signing in, not a problem unless one address is hammering. The 95th percentile is
read off the buckets, so it says which bucket, not the exact time: for a slow route, find its
spans (check 5) and see which step holds the time. The histogram restarts at 0 with the daemon,
which `increase` and `rate` allow for.
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 prom 'sum(increase(ipx_jobs_total{job="ipx",kind="scan"}[$range]))' 6h
$Q prom 'max_over_time(ipx_downloads_pending{job="ipx"}[$range])' 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
@@ -158,8 +172,9 @@ a count says something happened, the lines and traces say why.
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
scanning every minute (#114), so expect a scan every few minutes, most of one or two feeds;
none at all for over 10 minutes means the worker is stuck. A scan's end (`scan_done`) is
logged at debug since #363, so it is in the page's log view and not in Loki. 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
@@ -170,9 +185,15 @@ a count says something happened, the lines and traces say why.
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 traces '{resource.deployment.environment.name="production" && resource.service.name=~"ipx|ipx-web|ipx-directory" && status = error}'
$Q trace <id>
```
Other services on Tower send production traces too (hnopml's `poll`, a Hacker News fetch that
times out now and then): `traces` names each trace's service and `trace` prints an event's
fields (its `error`), so say whose a failure is before reading on. Tempo keeps about two weeks. A trace id from a log line that Tempo answers 404 for, or a
search that finds only the last quarter of an hour, is Tempo not reading its stored blocks: see
the monitoring section.
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
@@ -193,9 +214,18 @@ a count says something happened, the lines and traces say why.
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.
with. A trace begun in the page (#190, #192) has the tap or key as its root span, `tap` or
`key` (#376), from the moment it began to its last API answer, with the requests it made under
it (API calls, the playlist and audio of a play, position saves, the page's own report): a tap
long with its requests short is the page itself, drawing. The page reports the tap when the next
one begins or it is hidden, so the root arrives last; a trace with no name in `$Q traces` is
requests no tap began (a live update's, a timer's), or a tap not reported yet.
Every command the worker runs from the socket, the CLI or the page is a `command` span (`cmd`,
#373) with its job inside; a scan on the clock is a `scan` of its own. A list's sync is
`list_sync` (`list`, `listed`) with steps `upsert_listed`, `subscribe_readers`, `drop_unlisted`,
and an OPML upload `opml_import` (#372). The daemon's start is a `start` trace (#374), from
`open_database` and `assemble_config` to `daemon started`: how long a deploy took to answer.
6. **The daemon's state, from Prometheus.** Pressure and leaks that requests and traces only
hint at.
@@ -213,6 +243,8 @@ a count says something happened, the lines and traces say why.
$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 'histogram_quantile(0.99, sum by (le) (rate(ipx_db_wait_seconds_bucket{job="ipx"}[$range])))' 24h
$Q prom 'histogram_quantile(0.95, sum by (le, kind) (rate(ipx_db_query_seconds_bucket{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
@@ -228,10 +260,10 @@ a count says something happened, the lines and traces say why.
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
15 minutes ago, is the worker stuck, which `ipxd status` does not show; 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
the pool's max mean the pool is what they wait on, and `ipx_db_wait_seconds` for how long. 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
@@ -242,6 +274,9 @@ a count says something happened, the lines and traces say why.
$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
$Q metric 'sum by (msg) (count_over_time({container="iPX"} |= "\"ev\":\"swipe\"" | json | __error__="" [$range]))' 7d
$Q logs '{container="iPX"} |= "\"ev\":\"swipe\"" |= "\"msg\":\"end\"" | json | line_format "{{.timestamp}} {{.user}} {{.feed}} {{.filter}} {{.state}}"' 7d
$Q logs '{container="iPX"} |= "\"ev\":\"scroll_stuck\"" | json | line_format "{{.timestamp}} {{.user}} {{.feed}} {{.state}}"' 7d
```
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
@@ -249,6 +284,19 @@ a count says something happened, the lines and traces say why.
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.
Swipes and a reader that will not scroll (#383, #384). An `end` swipe is the reader bouncing
off the last item loaded: one whose `state` says `more to load` is the page failing to fetch
the next page in time, and several in a row from one person at the same position is #383 back.
An `end` without `more to load` is someone at the real end of the list, and fine. Many `short`
against few `next` is swipes not being taken, worth a look at `swipeMin` in `gestures.ts`.
Any `scroll_stuck` is a reader someone could not scroll, the bug #383 reported and never
found: read its `state` before anything else. `under the finger` other than `#detail` or
something inside it names a layer lying over the reader; `inert` other than `#dpeek`, `dialog
open`, a `transform` other than `none`, or `peek left behind` name what the page left in a wrong
state; `shown Ns ago` small says it followed a return from the background. File it with the
`state` line and that person's swipes just before it (`$Q logs` on `ev=~"swipe|scroll_stuck"` and
the `user`), even when the cause is not clear yet: it is the one record of the bug.
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
## ipx-web, the page
@@ -256,23 +304,27 @@ a count says something happened, the lines and traces say why.
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.
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx, which counts
them in `ipx_http_request_duration_seconds` with its own time; Caddy's `duration` for them adds
the hop. This is the only log with a line for every request. Caddy's own metrics, `job="ipx-web"`
(`caddy_http_request_duration_seconds{code,handler,method}` and the like), count every request too,
502s included, which ipx never sees.
```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.*"'
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*" | request_uri != "/api/events"'
```
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
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: its
route in the histogram (check 3), or ipx's span inside Caddy's in the trace. 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.
SIGTERM) are by design and filtered out above, and so is `aborting with incomplete response` on
`/api/events`: a page closing or reconnecting its live-update stream, about 600 a week.
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
@@ -297,10 +349,13 @@ It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `sta
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
token file changes. Its lines carry their own fields and no span (#363). Its metrics, as
`job="ipx-directory"` (production `192.168.1.130:9188`): `directory_process_resident_bytes`,
`directory_process_open_fds`, `directory_feed_fetches_total`, `directory_feed_bytes_total`,
`directory_uptime_seconds`, `directory_build_info`. 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.
with a `feed` span for each check. Its lines inside a span carry `trace_id` and `span_id`, as
ipx's do (#375): a `feed check failed` leads to its check's span.
```sh
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
@@ -367,11 +422,41 @@ 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
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [24h]))" 24h | tail -1)"; done
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
docker logs --since 1h tempo 2>&1 | grep -E 'failed to poll|blocklist poll complete' | tail -2
```
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
`ipx`, `ipx-directory`, `ipx-web`, `node` and the monitoring's own, `prometheus`, `tempo`, `loki`
and `alloy` (#366), should be `up`. Alloy is scraped at `alloy:12345` because its command sets
`--server.http.listen-addr`; it listens on 127.0.0.1 without it.
The alert rules (#365) are `grafana/alerts.py`, written to
`/mnt/fast/arcane/projects/monitoring/grafana-provisioning/alerting/ipx.json` and loaded by
`POST /api/admin/provisioning/alerting/reload`. There is no contact point yet, so one firing is
only in Grafana: ask it which are, at the start of every check. The password is in the
monitoring project's compose file; never print it.
```sh
PW=$(grep -oP 'GF_SECURITY_ADMIN_PASSWORD=\K\S+' /mnt/fast/arcane/projects/monitoring/compose.yaml)
curl -s -u "admin:$PW" http://192.168.1.130:3444/api/prometheus/grafana/api/v1/rules | python3 -c 'import json,sys
for g in json.load(sys.stdin)["data"]["groups"]:
for r in g["rules"]: print(r["state"], r["name"])' | sort
```
Tempo looks over its stored blocks every 5 minutes. `failed to poll or create index for tenant`
means it could not, and then searches and trace lookups see only what it still holds in memory,
about the last 15 minutes, while its 2,000 blocks sit unread on disk (its search log says
`total_blocks=0`). On 2026-10-25 that was one block left empty, every file 0 bytes, by a write cut
short the night before: `unexpected end of JSON input`. Find it and move it out of
`/mnt/user/logs/tempo/blocks/single-tenant/`; the next look over, within 5 minutes, logs
`writing tenant index` and finds the rest. Tell the user, since it is the monitoring's, not ipx's:
```sh
for d in /mnt/user/logs/tempo/blocks/single-tenant/*/; do python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$d/meta.json" 2>/dev/null || echo "$d"; done
```
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
@@ -380,7 +465,7 @@ container after a deploy.
## Rules
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
`docker logs` and `docker exec iPX ipxd ...` 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.
@@ -409,6 +494,8 @@ 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.
count over a day): iPX a few hundred (new items, Signals, shares with the directory, the page's
timings; more on a busy day), ipx-web two thousand or so while the page is in use (a line a
request), ipx-directory several hundred. Since #363 and #364 iPX can go an hour at night with no
line at all, so judge it by the day. Nothing in a day means Alloy is not shipping it, or the
container is down.

View File

@@ -157,7 +157,8 @@ def main():
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}")
# Production holds other services' traces too (hnopml's `poll`); say whose.
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootServiceName', '?')} {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
@@ -199,7 +200,12 @@ def main():
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}"))
tr = fetch(f"http://tempo:3200/api/traces/{q}")
spans = trace_spans(tr)
service = {sp["spanId"]: next((a["value"].get("stringValue") for a in b.get("resource", {}).get("attributes", [])
if a["key"] == "service.name"), "?")
for b in tr.get("batches", tr.get("resourceSpans", []))
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]}
kids = {}
for sp in spans:
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
@@ -209,13 +215,19 @@ def main():
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 ''}")
parent = next((p for p in spans if p["spanId"] == sp.get("parentSpanId")), None)
# The service where it changes: a root, or a hop from ipx-web to ipx to the directory.
svc = f"[{service.get(sp['spanId'])}] " if parent is None or service.get(parent["spanId"]) != service.get(sp["spanId"]) else ""
print(f"{' ' * depth}{svc}{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"))
fields = {a["key"]: next(iter(a["value"].values()), None) for a in e.get("attributes", [])}
msg = fields.pop("message", None) or e.get("name")
# An event's own fields carry the why: `poll failed` alone hid hnopml's timeout.
extra = {k: v for k, v in fields.items() if not k.startswith(("code.", "thread.")) and k not in ("level", "target")}
# 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}")
print(f"{' ' * depth} - {msg}{' ' + json.dumps(extra) if extra else ''}")
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
show(k, depth + 1)

View File

@@ -1,8 +1,8 @@
#!/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.
# crates the commit touches and those built on them, not the workspace: ipx uses ipx-play and
# ipx-directory, ipx-play uses ipx-directory, and all 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
@@ -10,9 +10,13 @@ if ! cargo fmt --all --check >/dev/null 2>&1; then
exit 1
fi
if echo "$staged" | grep -q -e '^Cargo\.toml$' -e '^Cargo\.lock$' -e '^crates/feed/'; then
pkgs="ipx-feed ipx-directory ipx"
pkgs="ipx-feed ipx-directory ipx-play ipx ipx-tui"
elif echo "$staged" | grep -q '^crates/tui/'; then
pkgs="ipx-tui"
elif echo "$staged" | grep -q '^crates/directory/'; then
pkgs="ipx-directory ipx"
pkgs="ipx-directory ipx-play ipx"
elif echo "$staged" | grep -q '^crates/play/'; then
pkgs="ipx-play ipx"
elif echo "$staged" | grep -q '^crates/ipx/'; then
pkgs="ipx"
else

185
CLAUDE.md
View File

@@ -20,6 +20,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
| 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` |
| Shared episodes | `ipx-play` in the same project, `192.168.1.130:8102`, public at `play.ipxreader.app` through the tunnel with no Access in front: the page a shared episode opens, anyone's to see (#447). `/v1` takes a token. Config `/mnt/fast/appdata/ipx-play/play.toml` (token hashes; Tower's token entered in ipx's Admin, Directory, Sharing episodes, with the address `http://ipx-play:8102`), database `ipx_play` on the same Postgres, URL in `play.env` beside the compose file. See [docs/play.md](docs/play.md) | `/config/play.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`) |
@@ -48,9 +49,18 @@ Labels: `bug` for something wrong, `enhancement` for something missing. A proble
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.
**Build only with `scripts/build-image.sh`, never a bare `docker buildx build`.** On 2026-11-04
a plain build of ipx, its dependency layer invalidated by a `Cargo.toml` change, rebuilt
everything in release on all twelve threads with 5 GB free; Tower thrashed for three hours with
every core pegged and had to be rebooted. The script refuses to start under 9 GB available or
above load 12, and builds in the BuildKit container `ipx-capped` (made on first use), held to 6
GB of memory and six threads by its cgroup: a build that outgrows that is killed, and production
keeps running. If one is killed, say so and wait for the user to decide; do not raise the caps or go
around the script. Run the build in the foreground on its own, never chained after a cargo
command, and look at `MemAvailable` and the load again when it ends.
```sh
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
sh scripts/build-image.sh ipx
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
@@ -59,27 +69,42 @@ docker logs --tail 20 iPX
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
sh scripts/build-image.sh directory
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
```
ipx-play the same:
```sh
sh scripts/build-image.sh play
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-play
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-play
```
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
sh scripts/build-image.sh web
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
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` with
wrangler, which takes `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` from `/src/.envrc` in
place of a login:
```sh
sh site/build.sh
. /src/.envrc && npx -y wrangler@4.150.0 pages deploy site/dist --project-name ipxreader --branch main --commit-hash "$(git rev-parse HEAD)"
curl -sL https://ipxreader.app/ | head -5
```
`--branch main` makes it the production deployment that ipxreader.app serves; another branch is a
preview at its own `*.ipxreader.pages.dev` address. The token is an account API token with
Cloudflare Pages: Edit on this account and nothing else (dashboard, My Profile, API Tokens). If it
is missing or expired, ask the owner for one rather than working around it. 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
@@ -94,24 +119,18 @@ block page, and ipx then logged a feed as failing with `invalid peer certificate
**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: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: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.
**Base images come from Google's mirror** (`mirror.gcr.io`, set in `scripts/buildkitd.toml`),
not Docker Hub, which rate-limits this host with `429 Too Many Requests`. The capped builder
cannot see images the Docker daemon has stored, so pulling and tagging them locally does nothing
for it. If `buildkitd.toml` changes, remove the builder (`docker buildx rm ipx-capped`) so the
next build makes it again with the new config. The builder also keeps the compiled crates
between builds (cache mounts, #438), so a `Cargo.lock` change compiles only what changed; removing
it throws them away, and the next build of each image compiles all of them again.
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
and `ipxd 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.
@@ -123,12 +142,20 @@ a daemon by hand for testing, **stop it by its own PID**: start it with `& echo
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.
## The iOS app
`ios/` is the page in a `WKWebView` with the phone's own player behind `web/src/native.ts` (#418);
see [ios/README.md](ios/README.md). It builds only on a Mac with Xcode, from `ios/project.yml`
through XcodeGen: nothing here compiles or checks the Swift, so say so when handing over a change
to it. A change to the bridge's messages is a change to `native.ts`, `tests/native-bridge.ts` and
`ios/Sources/Bridge.swift` together, and the page deploys first.
## 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. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mts` lists which files
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mts` 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
@@ -142,23 +169,43 @@ not signed in to `/login` and anyone but an admin away from `/admin` (and refuse
`/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
`web/boot.ts`, 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
`web/build.mts` lists them, sharing one top-level scope as the single inline script did; a new
file goes into that list. Top-level names are kept as they are, because markup calls some by
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
After any edit to it:
```sh
npx tsc -p .
node tests/page-smoke.js
node --test tests/page-unit.js
npm run -s typecheck
node tests/page-smoke.ts
node --test tests/page-unit.ts
```
**All the code here is TypeScript** (#392): the page, the service worker, the tests, the build, the
home page's generator and the load tests. Node runs a `.ts` file as it is, by stripping the types,
so it takes only TypeScript that erases (`erasableSyntaxOnly`: no enums, namespaces or constructor
parameter properties) and imports name their `.ts` files; k6 strips its own. What a browser loads
on its own is compiled by `web/build.mts` with swc (`boot.ts`, the worker). The directory's
`site.js` is compiled from `crates/directory/src/site.ts` and committed, since its build has no
Node: after editing `site.ts`, `node web/build.mts --directory-script`, which
`node tests/directory-script.ts` checks. `npm run -s typecheck` checks five programs, each with its
own globals: the page (`tsconfig.json`, which also writes the page's declarations to
`target/page-types` for the browser tests' `page.evaluate` calls), the worker, Node's
(`tsconfig.tools.json`), and k6's (`tsconfig.k6.json`).
The service worker that keeps iPX readable offline (#389) is TypeScript too, `web/sw/sw.ts`,
a script of its own built to `/sw.js`, and checked by `tsconfig.sw.json` against a worker's
globals, which cannot share a program with the page's. It goes to the network first for
everything, so online nothing is an old copy, but for an episode someone kept on the device
(#412): the page asks for it as `/media/...?offline`, and the worker answers from its
`ipx-episodes` cache, byte ranges and all. The browser tests block it
(`serviceWorkers: 'block'`), since it answers before `page.route` can; the offline tests allow it.
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
@@ -181,13 +228,17 @@ 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
npm run -s typecheck # the page, the service worker, the tests', build's and load tests' TypeScript
node tests/page-smoke.ts
node tests/directory-script.ts # the directory's site.js is its site.ts as it stands
node --test tests/page-unit.ts # the page's own functions, called directly, in a second
node tests/native-bridge.ts # the page hands playback to a native shell
node tests/contrast.ts # every theme's palette against WCAG AA
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
node tests/load/run.ts # k6: many people at once against a scratch daemon with 1,500 feeds
sh scripts/coverage.sh # which Rust lines the cargo tests reach, capped like a build (#390)
sh scripts/coverage.sh api # which Rust lines the browser suite reaches through the API (#394)
npm run coverage:page # which lines of web/src the browser tests reach (#390)
```
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
@@ -198,15 +249,18 @@ session was stopped. So:
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:
* Test only what the change is in. ipx uses ipx-play and ipx-directory, ipx-play uses
ipx-directory, and all 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 |
| `crates/directory/` | `cargo test -p ipx-directory`, then `-p ipx-play`, then `-p ipx` |
| `crates/play/` | `cargo test -p ipx-play`, then `-p ipx` |
| `crates/feed/` | `cargo test -p ipx-feed`, then `-p ipx-directory`, `-p ipx-play`, then `-p ipx` |
| `crates/tui/` | `cargo test -p ipx-tui`; it uses only the API, none of the other crates |
| `Cargo.toml`, `Cargo.lock` | all five, 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.
@@ -254,8 +308,8 @@ seeking through files, a flood of wrong passwords. Things about them:
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.
* `ipxd status` runs every second throughout, and a run fails if one takes 5s.
* One at a time: `node tests/load/run.ts 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
@@ -265,6 +319,30 @@ seeking through files, a flood of wrong passwords. Things about them:
* 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.
Coverage (#390, #394) says what the tests leave untested; read it before deciding a change is
covered. `sh scripts/coverage.sh` runs the cargo tests instrumented in the `ipx-coverage` image
(`sh scripts/build-image.sh coverage` makes it), in a container held to the builder's 6 GB and six
threads, one crate at a time, into `target/coverage/cargo` (`summary.txt`, `lcov.info`, `html/`).
`sh scripts/coverage.sh api` builds an instrumented ipx and ipx-directory there and runs the
browser suite here against them (`IPX_API_COVERAGE`), into `target/coverage/api`: what the API
and the daemon really run, which the cargo tests alone put at 17% for `web.rs`. The image is
Ubuntu 24.04, as code-server is, or the binaries it builds would not start here (glibc); the
suite stops its servers with SIGTERM in that mode, since an instrumented binary writes its profile
as it exits. The same run measures the page; `npm run coverage:page` does that alone, on a build of
the page with a source map to each `web/src` file (`IPX_COVERAGE=1`, `web/concat-map.mts`), into
`target/coverage/page`. The specs take `test` and `expect` from `tests/ui/coverage.ts`, not from
`@playwright/test`, so every test is counted, every page it opens (a second person's context
too), and each document before it unloads: the fixture stops it in `beforeunload` at a breakpoint
of its own to take what it ran, since a reload or sign-in otherwise throws that away (#407). The
unit tests and the native-bridge check are measured in Node first (`scripts/coverage-node.sh`);
the service worker is not.
Each run ends with `node scripts/coverage-metrics.ts`, which writes every suite's last numbers to
`/mnt/fast/appdata/node-exporter/textfile/ipx_coverage.prom`; node-exporter's textfile collector
(the monitoring project's compose file) serves them as `ipx_coverage_lines{suite,part,state}`, job
`node`, and the iPX dashboard's Coverage row draws them. A coverage run started by hand updates
Grafana; nothing runs one on its own.
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.
@@ -313,18 +391,20 @@ ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
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.
* **The Grafana dashboard reads the log's fields and the metrics' names** (`grafana/dashboard.py`).
Production logs JSON (`IPX_LOG_FORMAT=json`); the events' `ev`, `feed`, `new`, `bytes`, `msg`
(`log_event` in ipc.rs) and the metrics in metrics.rs (requests are
`ipx_http_request_duration_seconds`, #364) are what the panels query. Rename one and its panels
go blank without an error; regenerate the dashboard to match. Routine lines (a request that went
well, a scan's end) are debug: the admin page's Log view has them, Loki does not.
* `/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
different task. `ipxd 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 `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
command, the healthcheck's `ipxd 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
write lock that took made `ipxd 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. 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`
@@ -342,7 +422,9 @@ Every change gets one line under `## [Unreleased]` in the changelog of the servi
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
and ipx-web) [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory),
[crates/play/CHANGELOG.md](crates/play/CHANGELOG.md) (ipx-play) and
[ios/CHANGELOG.md](ios/CHANGELOG.md) (the iOS app). 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
@@ -357,6 +439,7 @@ release:
|---|---|---|---|
| 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-play | `crates/play/Cargo.toml` | `crates/play/CHANGELOG.md` | `play-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

386
Cargo.lock generated
View File

@@ -115,6 +115,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arc-swap"
version = "1.9.2"
@@ -507,6 +516,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -519,6 +534,15 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.4.5"
@@ -650,6 +674,20 @@ dependencies = [
"memchr",
]
[[package]]
name = "compact_str"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "compression-codecs"
version = "0.4.41"
@@ -679,6 +717,15 @@ dependencies = [
"typewit",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -768,6 +815,33 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.13.1",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -994,6 +1068,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -1063,6 +1138,15 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dontfrag"
version = "1.0.1"
@@ -1507,6 +1591,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashlink"
@@ -1843,6 +1932,28 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
dependencies = [
"darling 0.24.1",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "intervaltree"
version = "0.2.7"
@@ -1860,7 +1971,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "1.5.1"
version = "1.5.2-dev"
dependencies = [
"ammonia",
"anyhow",
@@ -1872,6 +1983,7 @@ dependencies = [
"futures-util",
"ipx-directory",
"ipx-feed",
"ipx-play",
"jsonwebtoken",
"librqbit",
"opentelemetry",
@@ -1895,7 +2007,7 @@ dependencies = [
[[package]]
name = "ipx-directory"
version = "1.4.1"
version = "1.4.2-dev"
dependencies = [
"ammonia",
"anyhow",
@@ -1945,6 +2057,46 @@ dependencies = [
"url",
]
[[package]]
name = "ipx-play"
version = "0.1.0-dev"
dependencies = [
"ammonia",
"anyhow",
"axum",
"chrono",
"clap",
"ipx-directory",
"ipx-feed",
"opentelemetry",
"percent-encoding",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"url",
]
[[package]]
name = "ipx-tui"
version = "0.1.0-dev"
dependencies = [
"anyhow",
"chrono",
"clap",
"ratatui",
"reqwest",
"serde",
"serde_json",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2120,6 +2272,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "kasuari"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
dependencies = [
"hashbrown 0.17.1",
"portable-atomic",
"thiserror 2.0.20",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2483,12 +2646,33 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "line-clipping"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2504,6 +2688,15 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2639,6 +2832,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -2806,6 +3000,15 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_threads"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2921,6 +3124,39 @@ dependencies = [
"num-traits",
]
[[package]]
name = "palette"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
dependencies = [
"approx",
"libm",
"palette_derive",
"palette_math",
]
[[package]]
name = "palette_derive"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "palette_math"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
dependencies = [
"libm",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3364,6 +3600,72 @@ dependencies = [
"rustversion",
]
[[package]]
name = "ratatui"
version = "0.30.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-widgets",
"serde",
]
[[package]]
name = "ratatui-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [
"bitflags 2.13.1",
"compact_str",
"hashbrown 0.17.1",
"itertools 0.14.0",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"thiserror 2.0.20",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "ratatui-crossterm"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
dependencies = [
"cfg-if",
"crossterm",
"instability",
"ratatui-core",
]
[[package]]
name = "ratatui-widgets"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [
"bitflags 2.13.1",
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"time",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -3563,6 +3865,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.46"
@@ -4020,6 +4335,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -4298,6 +4634,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.11.0"
@@ -4344,6 +4686,21 @@ name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subtle"
@@ -4496,7 +4853,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -4887,6 +5246,29 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
[[package]]
name = "unicode-xid"
version = "0.2.6"

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["crates/ipx", "crates/feed", "crates/directory"]
members = ["crates/ipx", "crates/feed", "crates/directory", "crates/tui", "crates/play"]
resolver = "3"
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
@@ -14,6 +14,7 @@ unsafe_code = "forbid"
[workspace.dependencies]
ipx-feed = { path = "crates/feed" }
ipx-directory = { path = "crates/directory" }
ipx-play = { path = "crates/play" }
ammonia = "4.1.4"
anyhow = "1.0.104"
argon2 = "0.6.0"

View File

@@ -7,23 +7,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# 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 ./
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 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 crates/ipx/src/main.rs crates/feed/src/lib.rs && cargo build --release --locked -p ipx
# cargo's registry and target/ are BuildKit cache mounts, kept in the builder between builds, so
# a Cargo.lock change compiles only the crates that changed (#438): a dependency layer of stub
# crates went with any change to it and compiled all 500 again, holding the builder's memory cap
# for minutes. A cache mount is not part of the image, so the binary is copied out in the same
# step. The workspace's own crates are touched because cargo goes by mtimes and skips a rebuild
# if they look untouched.
RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=ipx-target,target=/src/target,sharing=locked \
touch crates/ipx/src/main.rs crates/ipx/src/lib.rs crates/feed/src/lib.rs \
crates/directory/src/lib.rs \
&& cargo build --release --locked -p ipx \
&& cp target/release/ipxd /usr/local/bin/ipxd
FROM debian:trixie-slim
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
@@ -31,7 +30,7 @@ FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates gosu ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
COPY --from=build /usr/local/bin/ipxd /usr/local/bin/ipxd
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
@@ -46,4 +45,4 @@ VOLUME ["/config", "/data", "/downloads"]
EXPOSE 8099/tcp 6881/tcp 6881/udp
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["ipx", "daemon"]
CMD ["ipxd", "daemon"]

23
Dockerfile.coverage Normal file
View File

@@ -0,0 +1,23 @@
# What scripts/coverage.sh runs the Rust tests in, to see which lines they reach (#390): the
# toolchain, LLVM's coverage tools and cargo-llvm-cov. No source: coverage.sh mounts the tree.
# Built and pushed like the services, by scripts/build-image.sh coverage, under its caps.
#
# Ubuntu 24.04, as code-server is (#394): `coverage.sh api` builds an instrumented ipx and
# ipx-directory here, under the cap, and the browser suite runs them in code-server, which a
# binary linked against Debian trixie's newer glibc (2.41 to Ubuntu's 2.39) would not start in.
# The same Rust as the tests are built with elsewhere: a newer one would rebuild everything with
# its own flags for nothing.
FROM ubuntu:24.04
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:$PATH
# cc for rusqlite's bundled SQLite and librqbit, as Dockerfile's builder; ffmpeg for DeadAir's
# tests, as the image ipx runs in; curl and certificates to fetch rustup.
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential ca-certificates curl ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.1 --no-modify-path \
&& rustup component add llvm-tools-preview
# cargo-llvm-cov's own build, as its README gives it, rather than compiling it here.
ADD https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz /tmp/llvm-cov.tar.gz
RUN tar -xzf /tmp/llvm-cov.tar.gz -C /usr/local/cargo/bin && rm /tmp/llvm-cov.tar.gz \
&& cargo llvm-cov --version && rustc --version
WORKDIR /work

View File

@@ -1,23 +1,27 @@
# 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.
# No node here: only ipx's own build runs web/build.mts.
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
# Compiled crates kept in the builder between builds, as in the main Dockerfile (#438). A target
# of its own: built with ipx's, the two would rebuild shared crates for each other's features.
RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=ipx-directory-target,target=/src/target,sharing=locked \
touch crates/directory/src/main.rs crates/directory/src/lib.rs crates/feed/src/lib.rs \
&& cargo build --release --locked -p ipx-directory \
&& cp target/release/ipx-directory /usr/local/bin/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
COPY --from=build /usr/local/bin/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

28
Dockerfile.play Normal file
View File

@@ -0,0 +1,28 @@
# ipx-play (#447), the pages shared episodes open. Built from the repo's root:
# sh scripts/build-image.sh play
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.
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
# The logo and icon its pages serve, compiled into the binary.
COPY web/logo.svg web/logo-dark.svg web/favicon.png ./web/
# Compiled crates kept in the builder between builds (#438), in a target of its own.
RUN --mount=type=cache,id=ipx-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=ipx-play-target,target=/src/target,sharing=locked \
touch crates/play/src/main.rs crates/play/src/lib.rs crates/directory/src/lib.rs crates/feed/src/lib.rs \
&& cargo build --release --locked -p ipx-play \
&& cp target/release/ipx-play /usr/local/bin/ipx-play
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 /usr/local/bin/ipx-play /usr/local/bin/ipx-play
ENV PLAY_CONFIG=/config/play.toml
# Unraid's nobody:users. It writes nothing to disk: the database is elsewhere.
USER 99:100
EXPOSE 8102
CMD ["ipx-play"]

View File

@@ -1,11 +1,11 @@
# 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
FROM node:24-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
RUN node web/build.mts /dist
FROM caddy:2
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a

View File

@@ -44,7 +44,7 @@ From source:
```sh
cargo build --release
./target/release/ipx daemon
./target/release/ipxd daemon
```
The first start creates **admin** with a random password, written to `admin-password` beside
@@ -52,7 +52,7 @@ config.toml (readable by ipx's user alone; the log says where, never what it is)
`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it:
```sh
echo -n 'a good password' | ipx user passwd admin
echo -n 'a good password' | ipxd user passwd admin
```
The UI is plain HTTP, so put TLS in front of it if it is reachable from outside your network.
@@ -62,7 +62,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
| | |
|---|---|
| [docs/configuration.md](docs/configuration.md) | Every config key, path and environment variable |
| [docs/cli.md](docs/cli.md) | Every command, including `ipx user` |
| [docs/cli.md](docs/cli.md) | Every command, including `ipxd user` |
| [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 |
@@ -73,8 +73,8 @@ 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
node tests/page-smoke.ts # the page script loads without throwing
node tests/native-bridge.ts # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```

View File

@@ -10,6 +10,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- A `robots.txt` and a sitemap listing the home page, A to Z, every category and every feed, so search engines find the pages, not only the links between them (#380).
- `/metrics` counts its requests and their times by route and status, the feeds it checks and those that failed, and the snapshots it takes (#370).
- Its log lines inside a trace carry the trace's and span's ids, as ipx's do, so a failed feed check leads to its trace (#375).
### Changed
- Its checks of YouTube channels go one at a time, two seconds apart, since YouTube refuses many asked together (#386).
- Its JSON log lines no longer carry their span, once on its own and again in a list, which said nothing the line's own fields did not (#363).
### Fixed
- A YouTube channel's check no longer fails on the 404 or 500 YouTube answers some feed requests with and does not mean: it is asked again, twice, a second apart (#382).
## [1.4.1] - 2026-10-24
### Fixed

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx-directory"
version = "1.4.1"
version = "1.4.2-dev"
edition.workspace = true
publish = false

View File

@@ -36,6 +36,7 @@ pub fn router(dir: Shared) -> Router {
.merge(v1)
// Anyone may browse, no token, read only (#215).
.merge(site::router())
.route_layer(axum::middleware::from_fn(remember_route))
// 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.
@@ -65,6 +66,14 @@ impl From<anyhow::Error> for ApiError {
#[derive(Clone)]
pub(crate) struct Who(String);
/// Puts the route matched on the response for `access_log`, which runs outside routing: by path,
/// each feed's page was a row of its own in the request metric.
async fn remember_route(route: axum::extract::MatchedPath, req: Request, next: Next) -> Response {
let mut resp = next.run(req).await;
resp.extensions_mut().insert(route);
resp
}
/// One line a request, with the token's name and never the token: what the Grafana panels read.
pub(crate) async fn access_log(req: Request, next: Next) -> Response {
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
@@ -92,6 +101,15 @@ pub(crate) async fn access_log(req: Request, next: Next) -> Response {
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
let status = resp.status().as_u16();
if path != "/healthz" {
let route = resp.extensions().get::<axum::extract::MatchedPath>().map_or("", |r| r.as_str());
crate::metrics::REQUESTS.observe(
&format!(
"method=\"{}\",route=\"{}\",status=\"{status}\"",
crate::metrics::method(method.as_str()),
crate::metrics::label(route)
),
start.elapsed().as_secs_f64(),
);
tracing::info!(
target: "ipx_directory::http",
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
@@ -174,6 +192,7 @@ pub(crate) async fn snapshot(
}
let r = dir.store.snapshot(&me.name, &entries).await?;
dir.wake.notify_one();
crate::metrics::SNAPSHOTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tracing::info!(
instance = %me.name, accepted = r.accepted, refused_private = r.refused_private,
refused_blocked = r.refused_blocked, refused_invalid = r.refused_invalid, "snapshot"
@@ -362,12 +381,78 @@ mod tests {
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);
// Crawlers are pointed at every page (#380).
let (s, robots, _) = get_page(&app, "/robots.txt").await;
assert!(s == 200 && robots.contains("Disallow: /v1/") && robots.contains("/sitemap.xml"), "{robots}");
let (s, map, _) = get_page(&app, "/sitemap.xml").await;
assert_eq!(s, 200);
assert!(map.contains(&format!("/feed/{id}</loc>")) && map.contains("/category/Podcasting</loc>"), "{map}");
// 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);
}
/// What the log layer wrote, kept for the test to read.
struct Lines(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Lines {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn a_snapshots_log_line_has_the_fields_grafana_reads() {
use tracing_subscriber::layer::SubscriberExt;
let buf = std::sync::Arc::new(std::sync::Mutex::new(vec![]));
let b = buf.clone();
// Global, as the directory's own is; other tests' snapshots land here too, and each of
// their lines must carry the same fields.
tracing::subscriber::set_global_default(
tracing_subscriber::registry().with(crate::otel::json_layer(move || Lines(b.clone()), false)),
)
.unwrap();
let (app, _dir) = app().await;
let snap = serde_json::json!([{ "url": "https://x.test/log.xml", "subscribers": 1 }]);
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await.0, 200);
let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
let lines: Vec<serde_json::Value> = text.lines().map(|l| serde_json::from_str(l).expect(l)).collect();
let snaps: Vec<&serde_json::Value> = lines.iter().filter(|l| l["message"] == "snapshot").collect();
assert!(!snaps.is_empty(), "no snapshot line in {text}");
for l in snaps {
assert!(l["instance"].is_string(), "{l}");
for n in ["accepted", "refused_private", "refused_blocked", "refused_invalid"] {
assert!(l[n].is_u64(), "{n} in {l}");
}
}
}
#[tokio::test]
async fn dropping_a_servers_snapshot_takes_only_the_feeds_nobody_else_lists() {
let (app, dir) = app().await;
let feed = |url: &str| serde_json::json!({ "url": url, "subscribers": 1 });
let mine = serde_json::json!([feed("https://only-rw.test/feed"), feed("https://both.test/feed")]);
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(mine)).await.0, 200);
let theirs = serde_json::json!([feed("https://both.test/feed")]);
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rwa"), Some(theirs)).await.0, 200);
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rw"), None).await.0, 403, "admins only");
assert_eq!(call(&app, "DELETE", "/v1/instances/rw-token", Some("rwa"), None).await.0, 204);
let left: Vec<(String, i64)> =
dir.store.instances().await.unwrap().into_iter().map(|i| (i.name, i.feeds)).collect();
assert_eq!(left, [("rwa-token".to_owned(), 1)]);
let gone = dir.store.due_one(&store::feed_id("https://only-rw.test/feed")).await.unwrap();
assert!(gone.is_none(), "nobody lists it now");
let kept = dir.store.due_one(&store::feed_id("https://both.test/feed")).await.unwrap();
assert!(kept.is_some(), "another server still does");
}
#[tokio::test]
async fn each_access_level_does_what_it_may_and_no_more() {
let (app, dir) = app().await;

View File

@@ -23,8 +23,10 @@ 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);
/// Apple asks for no more than about twenty calls a minute. A test's stand-in asks for none.
const PAUSE: Duration = if cfg!(test) { Duration::ZERO } else { Duration::from_secs(3) };
/// Where Apple's charts and lookup API are.
const APPLE: &str = "https://itunes.apple.com";
/// Syncs when a week has passed since the last, forever.
pub async fn run(dir: Shared, country: String) {
@@ -46,7 +48,7 @@ pub async fn run(dir: Shared, country: String) {
continue;
}
let span = tracing::info_span!("apple_sync", country = %country);
match sync(&dir, &client, &country).instrument(span).await {
match sync(&dir, &client, APPLE, &country).instrument(span).await {
Ok(()) => {
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
}
@@ -58,8 +60,8 @@ pub async fn run(dir: Shared, country: String) {
}
}
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?;
async fn sync(dir: &Shared, client: &reqwest::Client, apple: &str, country: &str) -> Result<()> {
let genres: Genres = get(client, &format!("{apple}/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() {
@@ -74,8 +76,8 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
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"),
Some(g) => format!("{apple}/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
None => format!("{apple}/{country}/rss/toppodcasts/limit={n}/json"),
};
match get::<Chart>(client, &url).await {
Ok(chart) => ids.extend(chart_ids(&chart)),
@@ -90,12 +92,13 @@ async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<(
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 url = format!("{apple}/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?;
crate::metrics::SNAPSHOTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tracing::info!(
shows = ids.len(),
accepted = result.accepted,
@@ -232,6 +235,72 @@ fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
mod tests {
use super::*;
/// A stand-in for Apple: one category, Arts (1301); the top chart is shows 1 and 2, Arts' is 2
/// and 3; the lookup gives 1 a public feed, 2 one with a key in it, 3 another public one. With
/// `lookup_fails`, the lookup is a 503.
async fn apple(lookup_fails: bool) -> String {
use axum::extract::{Path, Query};
let chart = |ids: &[&str]| {
let entry: Vec<_> =
ids.iter().map(|id| serde_json::json!({ "id": { "attributes": { "im:id": id } } })).collect();
serde_json::json!({ "feed": { "entry": entry } })
};
let (top, arts) = (chart(&["1", "2"]), chart(&["2", "3"]));
let app = axum::Router::new()
.route(
"/WebObjects/MZStoreServices.woa/ws/genres",
axum::routing::get(|| async {
axum::Json(serde_json::json!({ "26": { "name": "Podcasts", "subgenres": { "1301": { "name": "Arts" } } } }))
}),
)
.route(
"/us/rss/toppodcasts/{*chart}",
axum::routing::get(move |Path(c): Path<String>| {
let got = if c.contains("genre=1301") { arts.clone() } else { top.clone() };
async move { axum::Json(got) }
}),
)
.route(
"/lookup",
axum::routing::get(move |Query(q): Query<BTreeMap<String, String>>| async move {
if lookup_fails {
return Err(axum::http::StatusCode::SERVICE_UNAVAILABLE);
}
assert_eq!(q["id"], "1,2,3", "each show once, in chart order");
Ok(axum::Json(serde_json::json!({ "results": [
{ "feedUrl": "https://one.test/feed", "primaryGenreName": "Arts" },
{ "feedUrl": "https://two.test/feed?auth=0123456789abcdef0123456789abcdef", "primaryGenreName": "Arts" },
{ "feedUrl": "https://three.test/feed", "primaryGenreName": "Podcasts" },
]})))
}),
);
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(async move { axum::serve(l, app).await });
format!("http://{at}")
}
async fn apples(dir: &Shared) -> i64 {
dir.store.instances().await.unwrap().iter().find(|i| i.name == INSTANCE).map_or(0, |i| i.feeds)
}
#[tokio::test]
async fn a_sync_takes_apples_charts_as_a_snapshot_and_a_failed_one_keeps_the_last() {
let dir = crate::Directory::new(crate::store::Store::scratch().await, vec![], Default::default()).unwrap();
let client = reqwest::Client::new();
sync(&dir, &client, &apple(false).await, "us").await.unwrap();
assert_eq!(apples(&dir).await, 2, "the feed with a key in it is refused as private");
// An admin blocks one; the next sync leaves it out.
dir.store.block(&crate::store::feed_id("https://three.test/feed")).await.unwrap();
sync(&dir, &client, &apple(false).await, "us").await.unwrap();
assert_eq!(apples(&dir).await, 1);
// Apple failing is no reason to forget last week's charts.
assert!(sync(&dir, &client, &apple(true).await, "us").await.is_err());
assert_eq!(apples(&dir).await, 1, "the last good snapshot stands");
}
#[test]
fn apples_categories_are_read_and_compared_with_ipxs() {
let genres: Genres = serde_json::from_str(

View File

@@ -112,6 +112,7 @@ impl Directory {
Err(e) => Refreshed::Failed(format!("{e:#}")),
};
if let Refreshed::Failed(e) = &got {
crate::metrics::CHECKS_FAILED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
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
@@ -143,6 +144,7 @@ pub async fn refresher(dir: Shared) {
.await;
// One line a round, for the dashboard's refresher panel (#210); failures have their own.
tracing::info!(checked = due.len(), "feeds checked");
crate::metrics::CHECKED.fetch_add(due.len() as u64, std::sync::atomic::Ordering::Relaxed);
},
round,
)
@@ -215,4 +217,49 @@ mod tests {
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 a_refresh_lists_a_feed_and_not_one_that_could_not_be_read() {
use axum::routing::get;
let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Good Show</title>
<item><title>One</title><guid>g1</guid><enclosure url="https://x/1.mp3" type="audio/mpeg" length="1"/></item>
</channel></rss>"#;
let opml =
r#"<?xml version="1.0"?><opml version="2.0"><head/><body><outline xmlUrl="https://x/f"/></body></opml>"#;
let app = axum::Router::new()
.route("/good.xml", get(move || async move { rss }))
.route("/list.opml", get(move || async move { opml }))
.route("/garbled.xml", get(|| async { "<rss><channel" }))
.route("/gone.xml", get(|| async { axum::http::StatusCode::NOT_FOUND }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let allowed = ipx_feed::guard::Allowed::parse(&["127.0.0.1".into()]).unwrap();
let dir = Directory::new(Store::scratch().await, vec![], allowed).unwrap();
let paths = ["/good.xml", "/list.opml", "/garbled.xml", "/gone.xml"];
let entries: Vec<crate::SnapshotEntry> = paths
.iter()
.map(|p| crate::SnapshotEntry { url: format!("http://{at}{p}"), subscribers: 1, category: None })
.collect();
assert_eq!(dir.store.snapshot("tower", &entries).await.unwrap().accepted, 4);
let due = dir.store.due(REFRESH_EVERY, 10).await.unwrap();
assert_eq!(due.len(), 4);
for d in &due {
dir.refresh(d).await.unwrap();
}
assert!(dir.store.due(REFRESH_EVERY, 10).await.unwrap().is_empty(), "each was checked, failed or not");
let page = |path: &str| {
let d = due.iter().find(|d| d.url.ends_with(path)).unwrap();
dir.store.feed(&d.id)
};
let good = page("/good.xml").await.unwrap().unwrap();
assert_eq!(good.listing.title.as_deref(), Some("Good Show"));
assert!(good.listing.podcast && good.failing.is_none() && good.items.len() == 1);
for bad in ["/list.opml", "/garbled.xml", "/gone.xml"] {
// Never read, so never listed: a page of nothing is no use to anyone browsing.
assert!(page(bad).await.unwrap().is_none(), "{bad} is not listed");
}
}
}

View File

@@ -83,15 +83,16 @@ async fn serve(path: &Path) -> Result<()> {
let filter = || 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")?;
// Each line's own fields say all its span did, and the span said it again (#363); the trace
// and span ids lead to the trace instead, as ipx's do (#375). Boxed rather than an Option of
// each: Option's Layer does not pass on_register_dispatch on, which the JSON layer needs (#408).
let stderr: Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
ipx_directory::otel::json_layer(std::io::stderr, false).with_filter(filter()).boxed()
} else {
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter()).boxed()
};
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(stderr)
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()

View File

@@ -7,6 +7,10 @@
//! does not come down again is a leak. Not jemalloc, whose upstream was archived in 2025.
use axum::Router;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::Mutex;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering::Relaxed;
use std::time::Instant;
use tokio::net::TcpListener;
@@ -27,8 +31,75 @@ pub fn process() -> (Option<f64>, Option<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.
/// Upper bounds for a request's or a query's time, in seconds: most answer in milliseconds, and a
/// host stalling takes seconds.
pub const SECONDS: &[f64] = &[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
/// Prometheus's histogram, for ipx and the directory both: for each set of labels, how many
/// observations fell under each bound, how many all told, and their sum.
pub struct Histograms {
bounds: &'static [f64],
rows: Mutex<BTreeMap<String, Row>>,
}
/// Under each bound, all told, and the sum.
type Row = (Vec<u64>, u64, f64);
impl Histograms {
#[must_use]
pub const fn new(bounds: &'static [f64]) -> Self {
Self { bounds, rows: Mutex::new(BTreeMap::new()) }
}
/// Counts `v` under `labels`, written as Prometheus reads them (`a="x",b="y"`, or "" for
/// none); `label` escapes a value that came from outside.
pub fn observe(&self, labels: &str, v: f64) {
let mut rows = self.rows.lock().expect("a panic elsewhere poisoned this lock");
let (buckets, n, sum) = rows.entry(labels.to_owned()).or_insert_with(|| (vec![0; self.bounds.len()], 0, 0.0));
for (b, le) in buckets.iter_mut().zip(self.bounds) {
*b += u64::from(v <= *le);
}
*n += 1;
*sum += v;
}
pub fn render(&self, out: &mut String, name: &str, help: &str) {
let rows = self.rows.lock().expect("a panic elsewhere poisoned this lock").clone();
let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} histogram");
for (labels, (buckets, n, sum)) in &rows {
let sep = if labels.is_empty() { "" } else { "," };
for (le, b) in self.bounds.iter().zip(buckets) {
let _ = writeln!(out, "{name}_bucket{{{labels}{sep}le=\"{le}\"}} {b}");
}
let _ = writeln!(out, "{name}_bucket{{{labels}{sep}le=\"+Inf\"}} {n}");
let braces = if labels.is_empty() { String::new() } else { format!("{{{labels}}}") };
let _ = writeln!(out, "{name}_sum{braces} {sum}\n{name}_count{braces} {n}");
}
}
}
/// A request's method as a label: one outside the usual is "other", so a caller cannot make rows.
#[must_use]
pub fn method(m: &str) -> &'static str {
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].into_iter().find(|x| *x == m).unwrap_or("other")
}
/// A label's value as Prometheus's text format reads it.
#[must_use]
pub fn label(v: &str) -> String {
v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
}
/// Requests answered, by method, route and status (#370).
pub static REQUESTS: Histograms = Histograms::new(SECONDS);
/// Feeds the refresher has checked, and of them those that failed (#370).
pub static CHECKED: AtomicU64 = AtomicU64::new(0);
pub static CHECKS_FAILED: AtomicU64 = AtomicU64::new(0);
/// Snapshots taken from ipx servers and the Apple sync (#370).
pub static SNAPSHOTS: AtomicU64 = AtomicU64::new(0);
/// The directory's /metrics, in Prometheus's text format: memory, open files, the feeds it has
/// fetched and checked, snapshots and requests. Nothing from the database, so a scrape costs nothing and answers while it is down.
fn render(started: Instant) -> String {
let mut out = String::new();
let mut gauge = |name: &str, kind: &str, help: &str, labels: &str, v: f64| {
@@ -69,6 +140,20 @@ fn render(started: Instant) -> String {
&format!("{{version=\"{}\"}}", env!("CARGO_PKG_VERSION")),
1.0,
);
gauge("directory_feed_checks_total", "counter", "Feeds the refresher checked.", "", CHECKED.load(Relaxed) as f64);
gauge(
"directory_feed_checks_failed_total",
"counter",
"Feed checks that failed: the publisher's error, or a feed that would not parse.",
"",
CHECKS_FAILED.load(Relaxed) as f64,
);
gauge("directory_snapshots_total", "counter", "Snapshots taken.", "", SNAPSHOTS.load(Relaxed) as f64);
REQUESTS.render(
&mut out,
"directory_http_request_duration_seconds",
"Requests answered and the seconds each took, by method, route and status.",
);
out
}
@@ -92,6 +177,52 @@ pub async fn serve(listener: TcpListener) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_histogram_is_written_as_prometheus_reads_it() {
static H: Histograms = Histograms::new(&[0.1, 1.0]);
for v in [0.05, 0.5, 2.0] {
H.observe("route=\"/a\"", v);
}
H.observe("", 0.01);
let mut out = String::new();
H.render(&mut out, "t_seconds", "Help.");
for line in [
"# TYPE t_seconds histogram\n",
"t_seconds_bucket{route=\"/a\",le=\"0.1\"} 1\n",
"t_seconds_bucket{route=\"/a\",le=\"1\"} 2\n",
"t_seconds_bucket{route=\"/a\",le=\"+Inf\"} 3\n",
"t_seconds_count{route=\"/a\"} 3\n",
"t_seconds_bucket{le=\"0.1\"} 1\n",
"t_seconds_sum 0.01\n",
] {
assert!(out.contains(line), "{line} in {out}");
}
assert_eq!(label("a \"b\"\\"), "a \\\"b\\\"\\\\");
}
#[tokio::test]
async fn every_directory_metric_grafana_queries_is_served() {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(serve(l));
let text = reqwest::get(format!("http://{at}/metrics")).await.unwrap().text().await.unwrap();
let src = [include_str!("../../../grafana/dashboard.py"), include_str!("../../../grafana/alerts.py")].concat();
let names: std::collections::BTreeSet<&str> = src
.match_indices("directory_")
.filter(|(i, _)| !src[..*i].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
.map(|(i, _)| {
let rest = &src[i..];
&rest[..rest.find(|c: char| !(c.is_ascii_lowercase() || c == '_')).unwrap_or(rest.len())]
})
.collect();
assert!(names.len() >= 4, "{names:?}");
for n in names {
assert!(text.contains(&format!("# TYPE {n} ")), "{n} is queried by grafana/ but not served: {text}");
}
}
#[test]
fn metrics_say_memory_and_feeds() {
let text = super::render(std::time::Instant::now());

View File

@@ -17,6 +17,7 @@ pub fn provider(service: &'static str) -> anyhow::Result<Option<opentelemetry_sd
opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.with_id_generator(Ids::default())
.build(),
))
}
@@ -58,6 +59,117 @@ pub fn traceparent() -> Option<String> {
sc.is_valid().then(|| format!("00-{}-{}-{:02x}", sc.trace_id(), sc.span_id(), sc.trace_flags().to_u8()))
}
/// Random ids, as the SDK's own, but for a span built inside `with_ids`, which takes the ids it is
/// given: the page names its tap's span before ipx makes it (#376), and the SDK gives a span
/// builder no other way to be told them.
#[derive(Debug, Default)]
struct Ids(opentelemetry_sdk::trace::RandomIdGenerator);
thread_local! {
static GIVEN: std::cell::Cell<Option<(opentelemetry::trace::TraceId, opentelemetry::trace::SpanId)>> =
const { std::cell::Cell::new(None) };
}
impl opentelemetry_sdk::trace::IdGenerator for Ids {
fn new_trace_id(&self) -> opentelemetry::trace::TraceId {
GIVEN.get().map_or_else(|| self.0.new_trace_id(), |(t, _)| t)
}
fn new_span_id(&self) -> opentelemetry::trace::SpanId {
GIVEN.get().map_or_else(|| self.0.new_span_id(), |(_, s)| s)
}
}
/// Runs `f`, which builds one root span, with these ids for it. Synchronous, so nothing else on
/// this thread makes a span in between.
pub fn with_ids<R>(
trace: opentelemetry::trace::TraceId,
span: opentelemetry::trace::SpanId,
f: impl FnOnce() -> R,
) -> R {
GIVEN.set(Some((trace, span)));
let r = f();
GIVEN.set(None);
r
}
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
/// the object it writes; the directory's lines carry them too (#375). A line outside any traced span, or with no trace exporter, is unchanged.
/// One JSON object a line for Loki, each event's fields its own and the trace and span ids beside
/// them, for ipx and the directory both: the dashboard and the alert rules read these fields.
/// `current_span` adds the span's own fields under `span`, which ipx keeps and the directory
/// dropped (#363).
pub fn json_layer<S, W>(writer: W, current_span: bool) -> impl tracing_subscriber::Layer<S>
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
W: for<'w> tracing_subscriber::fmt::MakeWriter<'w> + Send + Sync + 'static,
{
use tracing_subscriber::Layer as _;
let registered = Registered::default();
tracing_subscriber::fmt::layer()
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
.event_format(WithTrace(
tracing_subscriber::fmt::format()
.json()
.flatten_event(true)
.with_current_span(current_span)
.with_span_list(false),
registered.clone(),
))
.with_writer(writer)
.and_then(registered)
}
/// The subscriber the log layer is part of, as it was handed over on registration. The span's
/// OpenTelemetry context is reached through it: `Span::current()` asks the thread's default
/// dispatcher, which inside an event under a scoped subscriber (`with_default`) is hidden by
/// tracing's re-entrancy guard, and every line went out without its ids (#408).
#[derive(Clone, Default)]
struct Registered(std::sync::Arc<std::sync::OnceLock<tracing::dispatcher::WeakDispatch>>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Registered {
fn on_register_dispatch(&self, subscriber: &tracing::Dispatch) {
let _ = self.0.set(subscriber.downgrade());
}
}
struct WithTrace<F>(F, Registered);
impl<S, N, F> tracing_subscriber::fmt::FormatEvent<S, N> for WithTrace<F>
where
F: tracing_subscriber::fmt::FormatEvent<S, N>,
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
N: for<'w> tracing_subscriber::fmt::FormatFields<'w> + 'static,
{
fn format_event(
&self,
ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
mut w: tracing_subscriber::fmt::format::Writer<'_>,
ev: &tracing::Event<'_>,
) -> std::fmt::Result {
use opentelemetry::trace::TraceContextExt;
// Without one (a wrapper that does not pass registration on, as Option does not), the
// default dispatcher, which a global subscriber's lines still find.
let registered = self.1.0.get().and_then(tracing::dispatcher::WeakDispatch::upgrade);
let dispatch = registered.unwrap_or_else(|| tracing::dispatcher::get_default(Clone::clone));
// The registry's own current span, not the one this layer's filter would show.
let sc = ctx
.current_span()
.id()
.and_then(|id| tracing_opentelemetry::get_otel_context(id, &dispatch))
.map(|cx| cx.span().span_context().clone());
let Some(sc) = sc.filter(|c| c.is_valid()) else {
return self.0.format_event(ctx, w, ev);
};
let mut line = String::new();
self.0.format_event(ctx, tracing_subscriber::fmt::format::Writer::new(&mut line), ev)?;
match line.trim_end().strip_suffix('}') {
Some(body) => writeln!(w, r#"{body},"trace_id":"{}","span_id":"{}"}}"#, sc.trace_id(), sc.span_id()),
None => w.write_str(&line),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -92,4 +204,21 @@ mod tests {
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(remote_parent(&axum::http::HeaderMap::new(), Some("traceparent=nonsense")).is_none());
}
#[test]
fn a_span_built_with_given_ids_takes_them_and_the_next_does_not() {
use opentelemetry::trace::{Span as _, SpanId, TraceId, Tracer as _, TracerProvider as _};
let tracer = opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_id_generator(super::Ids::default())
.build()
.tracer("t");
let (t, s) = (
TraceId::from_hex("4bf92f3577b34da6a3ce929d0e0e4736").unwrap(),
SpanId::from_hex("00f067aa0ba902b7").unwrap(),
);
let given = super::with_ids(t, s, || tracer.start("tap"));
assert_eq!((given.span_context().trace_id(), given.span_context().span_id()), (t, s));
let next = tracer.start("other");
assert_ne!(next.span_context().trace_id(), t);
}
}

View File

@@ -1,10 +1,17 @@
// Made from site.ts by `node web/build.mts --directory-script`: edit that, not this.
// 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);
});
//
// The source: the directory serves site.js, made from this by `node web/build.mts --directory-script`
// (#392), since its build has no Node to make it; tests/directory-script.ts checks the two agree.
document.addEventListener('click', (ev)=>{
const b = ev.target?.closest('[data-copy]');
// Only in a secure context; plain HTTP has no clipboard.
if (!b || !navigator.clipboard) return;
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(()=>{
b.textContent = 'Copied';
setTimeout(()=>{
b.textContent = 'Copy';
}, 1500);
});
});

View File

@@ -25,6 +25,8 @@ pub fn router() -> Router<Shared> {
.route("/search", get(search))
.route("/category/{name}", get(category))
.route("/feed/{id}", get(feed))
.route("/robots.txt", get(robots))
.route("/sitemap.xml", get(sitemap))
.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"))))
@@ -40,6 +42,48 @@ async fn asset(kind: &'static str, body: &'static [u8]) -> Response {
([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
}
/// The address this was asked for at, which both files must give whole: the public name through
/// the tunnel, whose cloudflared says https, or the LAN address. Only a host name's characters are
/// kept, since the header is the caller's to write.
fn origin(h: &axum::http::HeaderMap) -> String {
let get = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("");
let host: String =
get("host").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')).collect();
let scheme = if get("x-forwarded-proto") == "https" { "https" } else { "http" };
format!("{scheme}://{host}")
}
/// Crawlers may read every page but a search, which is any query at all, and the API, which takes
/// a token (#380).
async fn robots(h: axum::http::HeaderMap) -> Response {
let body = format!("User-agent: *\nDisallow: /search\nDisallow: /v1/\n\nSitemap: {}/sitemap.xml\n", origin(&h));
([(header::CONTENT_TYPE, "text/plain; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=86400")], body)
.into_response()
}
/// Every page there is to find: the home page, A to Z, each category and each feed (#380).
async fn sitemap(State(dir): State<Shared>, h: axum::http::HeaderMap) -> Response {
let all = match dir.pages_list().await {
Ok(a) => a,
Err(e) => return failed(e),
};
let o = origin(&h);
let cats: std::collections::BTreeSet<&str> = all.iter().map(|l| l.category.as_deref().unwrap_or("Other")).collect();
let mut b = String::from(
r#"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"#,
);
let paths = ["/".to_owned(), "/all".to_owned()]
.into_iter()
.chain(cats.iter().map(|c| format!("/category/{}", seg(c))))
.chain(all.iter().map(|l| format!("/feed/{}", seg(&l.id))));
for p in paths {
let _ = write!(b, "<url><loc>{}</loc></url>", esc(&format!("{o}{p}")));
}
b.push_str("</urlset>");
([(header::CONTENT_TYPE, "application/xml; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], b)
.into_response()
}
#[derive(serde::Deserialize, Default)]
struct Filter {
/// `podcasts` or `blogs`; anything else is both.

View File

@@ -0,0 +1,14 @@
// 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.
//
// The source: the directory serves site.js, made from this by `node web/build.mts --directory-script`
// (#392), since its build has no Node to make it; tests/directory-script.ts checks the two agree.
document.addEventListener('click', (ev) => {
const b = (ev.target as Element | null)?.closest<HTMLElement>('[data-copy]');
// Only in a secure context; plain HTTP has no clipboard.
if (!b || !navigator.clipboard) return;
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(() => {
b.textContent = 'Copied';
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
});
});

View File

@@ -19,9 +19,7 @@ quick-xml.workspace = true
reqwest.workspace = true
rss.workspace = true
serde_json.workspace = true
tokio.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true
[dev-dependencies]
tokio.workspace = true

View File

@@ -41,6 +41,41 @@ pub static FETCHED_BYTES: AtomicU64 = AtomicU64::new(0);
/// catalogue was 62 MB, and 50 MB here stopped it updating (#357).
pub(crate) const MAX_BODY: usize = 200 << 20;
/// How many times a YouTube feed's spurious failure is asked again, each after its turn.
const YOUTUBE_RETRIES: u32 = 2;
/// The least time between two requests for YouTube feeds from this process. A scan asked for six
/// at once, and channels in a row failed together while one asked alone a minute later read
/// (#386); asked again a second later, they failed again.
const YOUTUBE_GAP: Duration = Duration::from_secs(2);
static YOUTUBE_NEXT: tokio::sync::Mutex<Option<tokio::time::Instant>> = tokio::sync::Mutex::const_new(None);
/// Waits until `gap` after the last request's turn, one caller at a time: tokio's lock is fair,
/// so waiting feeds go in the order they asked. shortcut: 39 channels due together add up to 80 s
/// to that scan; a scan's other feeds wait behind them only when they come after in the order.
#[tracing::instrument(name = "youtube_turn", skip_all)]
async fn take_turn(next: &tokio::sync::Mutex<Option<tokio::time::Instant>>, gap: Duration) {
let mut next = next.lock().await;
if let Some(at) = *next {
tokio::time::sleep_until(at).await;
}
*next = Some(tokio::time::Instant::now() + gap);
}
/// YouTube answers some feed requests with a 404 or 500 it does not mean, in tens of
/// milliseconds, while the next request for the same feed reads; checked once a day, a channel
/// then missed most days (#382).
fn youtube_spurious(url: &reqwest::Url, status: StatusCode) -> bool {
is_youtube_feed(url.as_str()) && (status == StatusCode::NOT_FOUND || status.is_server_error())
}
/// A YouTube channel's or playlist's feed, which fails most reads in streaks that outlast
/// `fetch`'s retries, so a scheduler asks it again sooner than other feeds (#386).
#[must_use]
pub fn is_youtube_feed(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| u.host_str() == Some("www.youtube.com") && u.path() == "/feeds/videos.xml")
}
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
pub(crate) async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
@@ -68,7 +103,12 @@ pub async fn fetch(
let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
let mut url = start.clone();
let mut permanent = true;
for _ in 0..10 {
let mut retries = YOUTUBE_RETRIES;
// Ten redirects, and the retries beside them.
for _ in 0..10 + YOUTUBE_RETRIES {
if is_youtube_feed(url.as_str()) {
take_turn(&YOUTUBE_NEXT, YOUTUBE_GAP).await;
}
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
if let Some(tag) = etag {
req = req.header(IF_NONE_MATCH, tag);
@@ -100,6 +140,10 @@ pub async fn fetch(
if status == StatusCode::NOT_MODIFIED {
return Ok((Fetched::NotModified, moved));
}
if retries > 0 && youtube_spurious(&url, status) {
retries -= 1;
continue;
}
if !status.is_success() {
// WordPress.com answers some readers' feed requests with a browser check that only
// JavaScript passes, for hours at a time and whatever the User-Agent; its API has the
@@ -167,6 +211,38 @@ mod tests {
format!("http://{addr}")
}
#[tokio::test]
async fn youtube_turns_are_a_gap_apart_one_at_a_time() {
let (next, gap) = (tokio::sync::Mutex::const_new(None), std::time::Duration::from_millis(50));
let start = tokio::time::Instant::now();
// The first goes at once; three asking together then go one gap after another.
super::take_turn(&next, gap).await;
assert!(start.elapsed() < gap);
let t = || async {
super::take_turn(&next, gap).await;
start.elapsed()
};
let (a, b, c) = tokio::join!(t(), t(), t());
let mut at = [a, b, c];
at.sort();
for (i, at) in at.iter().enumerate() {
assert!(*at >= gap * (i as u32 + 1), "turn {} at {at:?}", i + 1);
}
}
#[test]
fn only_a_youtube_feeds_404_or_5xx_is_asked_again() {
use reqwest::StatusCode;
let yt = reqwest::Url::parse("https://www.youtube.com/feeds/videos.xml?user=x").unwrap();
let other = reqwest::Url::parse("https://example.com/feeds/videos.xml").unwrap();
let page = reqwest::Url::parse("https://www.youtube.com/watch?v=x").unwrap();
assert!(super::youtube_spurious(&yt, StatusCode::NOT_FOUND));
assert!(super::youtube_spurious(&yt, StatusCode::INTERNAL_SERVER_ERROR));
assert!(!super::youtube_spurious(&yt, StatusCode::FORBIDDEN));
assert!(!super::youtube_spurious(&other, StatusCode::NOT_FOUND));
assert!(!super::youtube_spurious(&page, StatusCode::NOT_FOUND));
}
#[tokio::test]
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
let base = redirecting_server().await;

View File

@@ -18,9 +18,75 @@ pub use artwork::{is_image, prefer_https, site_icon};
pub use category::{categories, category_path};
pub use discover::{expand_input, find_feed};
pub use failure::{Failure, explain_failure, failure_kind, is_temporary_lookup_failure};
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch};
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch, is_youtube_feed};
pub use jev::{JEV_URL, classify};
pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml};
pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key};
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
pub use privacy::{redact, redact_text, url_looks_private};
/// Where another service's API is (WordPress.com's, Patreon's): theirs, or in a test the stand-in
/// `test_api` serves.
pub(crate) fn api_base(real: &'static str) -> String {
#[cfg(test)]
{
let _ = real;
test_api::base()
}
#[cfg(not(test))]
real.to_owned()
}
#[cfg(test)]
pub(crate) mod test_api {
use std::io::{BufRead, BufReader, Write};
/// A stand-in for WordPress.com's and Patreon's APIs, and for the feeds that send ipx to them,
/// on a thread of its own so it outlives any one test's runtime. By path:
/// `/challenged` answers as WordPress.com's browser check does, `/wp/v2/sites/<host>/posts`
/// with one post, `/creator` as a Patreon creator feed of campaign 12345, and
/// `/api/campaigns/12345` with its two shows. Anything else is a 404.
pub(crate) fn base() -> String {
static AT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
AT.get_or_init(|| {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let at = format!("http://{}", l.local_addr().unwrap());
std::thread::spawn(move || {
for mut sock in l.incoming().flatten() {
let mut line = String::new();
let _ = BufReader::new(&sock).read_line(&mut line);
let path = line.split_whitespace().nth(1).unwrap_or("/").to_owned();
let (status, body) = answer(&path);
let _ = write!(
sock,
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
});
at
})
.clone()
}
fn answer(path: &str) -> (&'static str, String) {
if path.starts_with("/challenged") {
("403 Forbidden", r#"<html><script src="/__challenge/check.js"></script></html>"#.into())
} else if path.starts_with("/wp/v2/sites/127.0.0.1/posts") {
let post = r#"[{"date_gmt":"2026-10-09T13:34:00","guid":{"rendered":"https://blog.test/?p=1"},
"link":"https://blog.test/hello/","title":{"rendered":"Hello"},"content":{"rendered":"<p>Hi</p>"}}]"#;
("200 OK", post.into())
} else if path.starts_with("/creator") {
let feed = r#"<?xml version="1.0"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
<atom:link href="https://www.patreon.com/rss/12345?auth=x" rel="self"/><title>Creator</title></channel></rss>"#;
("200 OK", feed.into())
} else if path.starts_with("/api/campaigns/12345?") {
let shows = r#"{"data":{"attributes":{"name":"Glass Cannon"},
"relationships":{"shows":{"data":[{"id":"7","type":"collection"},{"id":"8","type":"collection"}]}}},
"included":[{"id":"7","type":"collection","attributes":{"title":" Main Show "}}]}"#;
("200 OK", shows.into())
} else {
("404 Not Found", String::new())
}
}
}

View File

@@ -22,13 +22,17 @@ pub fn is_patreon_creator(url: &str) -> bool {
matches!(patreon_parts(url), Some((_, None)))
}
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
/// creator's name, by number, or with no creator at all -- and the token and show are what
/// identify it.
/// Whether two URLs are the same feed. The same address over http and https is one feed:
/// oldvcr's blog was subscribed under both, every post twice (#362). One Patreon show has several
/// spellings -- by the creator's name, by number, or with no creator at all -- and the token and
/// show are what identify it.
#[must_use]
pub fn same_feed(a: &str, b: &str) -> bool {
// Parsing both is the slow part, and only Patreon's addresses can differ and still match.
a == b
fn bare(u: &str) -> &str {
u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")).unwrap_or(u)
}
// Parsing both is the slow part, and only Patreon's addresses can differ further and still match.
bare(a) == bare(b)
|| (a.contains("patreon.com")
&& b.contains("patreon.com")
&& patreon_parts(a).is_some_and(|p| Some(p) == patreon_parts(b)))
@@ -54,8 +58,8 @@ pub async fn patreon_shows(client: &reqwest::Client, url: &str) -> Result<(Optio
let campaign = patreon_campaign(&head).ok_or_else(|| anyhow!("the Patreon feed does not say whose it is"))?;
let api = format!(
"https://www.patreon.com/api/campaigns/{campaign}\
?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title"
"{}/api/campaigns/{campaign}?include=shows&fields%5Bcampaign%5D=name&fields%5Bcollection%5D=title",
crate::api_base("https://www.patreon.com")
);
let resp = client.get(api).send().await.context("asking Patreon for the shows")?;
if !resp.status().is_success() {
@@ -102,6 +106,21 @@ mod tests {
use super::*;
use crate::discover;
#[tokio::test]
async fn a_creators_shows_are_asked_of_patreon_by_the_campaign_its_feed_names() {
let client = reqwest::Client::new();
let creator = format!("{}/creator?auth=x", crate::test_api::base());
let (name, shows) = patreon_shows(&client, &creator).await.unwrap();
assert_eq!(name.as_deref(), Some("Glass Cannon"));
assert_eq!(
shows,
[("Main Show".to_owned(), format!("{creator}&show=7")), ("Show 8".to_owned(), format!("{creator}&show=8"))],
"each show is the creator feed with its own show, on the same credentials"
);
let nobody = format!("{}/nothing?auth=x", crate::test_api::base());
assert!(format!("{:#}", patreon_shows(&client, &nobody).await.unwrap_err()).contains("HTTP 404"));
}
#[test]
fn a_patreon_creator_is_a_list_of_its_shows() {
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
@@ -117,6 +136,13 @@ mod tests {
assert!(!is_patreon_creator(&format!("https://www.patreon.com/rss/x?auth={tok}&show=1")), "one show is a feed");
assert!(!is_patreon_creator(&format!("https://example.com/rss?auth={tok}")));
assert!(same_feed(
"http://oldvcr.blogspot.com/feeds/posts/default",
"https://oldvcr.blogspot.com/feeds/posts/default"
));
assert!(!same_feed("https://a.example/feed", "https://b.example/feed"));
assert!(!same_feed("https://a.example/feed", "https://a.example/feed/comments"));
// The show you already have by name is the one a bare token would add by number.
assert!(same_feed(
&format!("https://www.patreon.com/rss/glasscannon?auth={tok}&show=2073588"),

View File

@@ -15,7 +15,7 @@ pub(crate) fn is_wpcom_challenge(body: &[u8]) -> bool {
/// shortcut: the API's default page of ten posts, as many as WordPress puts in a feed; a feed
/// set to carry more gets ten here.
pub(crate) async fn wpcom_posts(client: &reqwest::Client, host: &str) -> Result<Fetched> {
let api = format!("https://public-api.wordpress.com/wp/v2/sites/{host}/posts");
let api = format!("{}/wp/v2/sites/{host}/posts", crate::api_base("https://public-api.wordpress.com"));
let resp = client
.get(&api)
.timeout(fetch::FEED_TIMEOUT)
@@ -61,6 +61,19 @@ pub(crate) fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
#[cfg(test)]
mod tests {
#[tokio::test]
async fn a_browser_check_in_place_of_the_feed_is_answered_from_wordpress_coms_api() {
let client = reqwest::Client::new();
let (got, moved) =
crate::fetch(&client, &format!("{}/challenged/feed", crate::test_api::base()), None, None, None)
.await
.unwrap();
let crate::Fetched::Body { bytes, .. } = got else { panic!("a body") };
let feed = super::parse(&bytes).unwrap();
assert_eq!(feed.entries[0].guid, "https://blog.test/?p=1", "the post the site's own feed would have");
assert_eq!((feed.entries[0].title.as_deref(), moved), (Some("Hello"), None));
}
#[test]
fn reads_wordpress_posts_as_a_feed() {
// As WordPress.com's API sends them, trimmed (#317).

View File

@@ -12,6 +12,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Sharing an episode gives a link to its page on ipx-play, which anyone can open and play, set up in Admin, Directory, Sharing episodes; private and paid feeds still share their own address (#447).
- The iOS app's icon count stays right with the app closed: with `[push]` set to an APNs key, ipx sends each registered phone (`POST /api/push/devices`, removed with `DELETE`) its All Subscriptions unread count through Apple's push service, within a minute of a change (#433). The push also wakes the app for a moment, for its widget to show the same count (#428).
- A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412).
- `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389).
- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384).
- `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369).
- Traces for what had none: each command the worker runs, from the CLI, the socket or the page, with its job inside (#373); the daemon's start, from opening the database to serving (#374); a list's sync and an OPML upload, step by step (#372); and the tap or key in the page that set a trace's requests off, as its root span, from the tap to its last answer (#376).
### Changed
- The server's command is `ipxd`, not `ipx`: `ipxd daemon`, `ipxd status`, `ipxd user add` and the rest, in the container as `docker exec iPX ipxd ...`. `ipx` is now the reader in a terminal (#437).
- The log no longer has a line for every request that went well, over half its size: `/metrics` counts each request and its time by route and status as `ipx_http_request_duration_seconds`, a request that failed keeps its warning, and the admin page's log view still shows them all (#364).
- The log no longer has a line for every scan that finishes, a fifth of its lines; the metrics count scans, and the page's log view still shows them (#363).
### Fixed
- Subscribing to a feed someone here already reads puts its recent items to your Signals at once, rather than at the judge's next pass, up to five minutes later (#415).
- A download a restart stopped, a deploy's for one, is taken up again as the daemon starts, rather than waiting for its feed's next check, an hour or a day away (#410).
- A YouTube channel no longer misses most days' videos: YouTube answers some feed requests with a 404 or 500 it does not mean, and ipx now asks again, twice, a second apart, before calling the feed failing (#382), and then every hour for a day where it would have waited a day, since YouTube's failures run for hours; and its YouTube feeds are read one at a time, two seconds apart, since YouTube refuses many asked together (#386).
- Syncing an OPML or Patreon list reads who subscribes to it once, not once for every feed it lists: a list of 922 was thousands of the same queries a sync (#377).
- A feed you already have, added again by the same address over http rather than https or the other way round, is the one you have, not a second subscription with every item twice. Changing a feed's address to another feed's over the other scheme is refused the same way (#362).
- A podcast that was once in an OPML and is now a feed of its own is no longer hidden from All Subscriptions when that OPML is, so its unread items are listed there as the feed list counts them (#358).
### Security
- Ten wrong passwords from one address in a quarter hour hold that address off sign-in for the rest of it, answered `429` with how long to wait; before, a guesser could try as fast as the server checked. The address is the one a trusted proxy names last in `X-Forwarded-For`, or the peer's (#409).
## [1.5.1] - 2026-10-24
### Changed

View File

@@ -1,15 +1,22 @@
[package]
name = "ipx"
version = "1.5.1"
version = "1.5.2-dev"
edition.workspace = true
publish = false
# ipxd: `ipx` is the reader in a terminal (crates/tui), which more people type (#437). The
# code is the library, still `ipx`, so its log targets keep their names (src/lib.rs).
[[bin]]
name = "ipxd"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
ipx-feed.workspace = true
ipx-directory.workspace = true
ipx-play.workspace = true
ammonia.workspace = true
anyhow.workspace = true
argon2.workspace = true

View File

@@ -38,13 +38,14 @@ 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
let url = certs(team);
self.verify_with(team, aud, token, || fetch(client, &url)).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 {
match fetch(client, &certs(team)).await {
Ok(set) => self.store(set),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
}
@@ -111,10 +112,14 @@ impl Keys {
}
}
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
let url = format!("https://{team}/cdn-cgi/access/certs");
/// Where a team publishes its signing keys.
fn certs(team: &str) -> String {
format!("https://{team}/cdn-cgi/access/certs")
}
async fn fetch(client: &reqwest::Client, url: &str) -> Result<JwkSet> {
client
.get(&url)
.get(url)
.timeout(Duration::from_secs(10))
.send()
.await
@@ -126,25 +131,25 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header};
const TEAM: &str = "team.cloudflareaccess.com";
const AUD: &str = "aud-tag";
pub(crate) const TEAM: &str = "team.cloudflareaccess.com";
pub(crate) 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 {
pub(crate) 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) };
jsonwebtoken::encode(&h, &claims, &k).unwrap()
}
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
pub(crate) fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
let now = jsonwebtoken::get_current_timestamp() as i64;
serde_json::json!({
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
@@ -152,13 +157,45 @@ mod tests {
})
}
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
pub(crate) 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
}
/// Keys holding the test signer's, as if fetched from the team.
pub(crate) fn keys() -> Keys {
let keys = Keys::default();
keys.store(jwks());
keys
}
#[tokio::test]
async fn the_keys_are_read_from_the_teams_certs_and_a_failure_is_an_error() {
let app = axum::Router::new()
.route(
"/cdn-cgi/access/certs",
axum::routing::get(|| async { include_str!("../../../tests/data/access-test.jwks.json") }),
)
.route("/down", axum::routing::get(|| async { axum::http::StatusCode::SERVICE_UNAVAILABLE }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let client = reqwest::Client::new();
let set = fetch(&client, &format!("http://{at}/cdn-cgi/access/certs")).await.unwrap();
assert_eq!(set.keys.len(), jwks().keys.len());
let keys = Keys::default();
keys.store(set);
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "the fetched key verifies");
assert!(fetch(&client, &format!("http://{at}/down")).await.is_err(), "a 503 is not a key set");
assert!(fetch(&client, &format!("http://{at}/nothing")).await.is_err(), "nor a 404");
assert_eq!(certs(TEAM), "https://team.cloudflareaccess.com/cdn-cgi/access/certs");
}
#[tokio::test]
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
let keys = Keys::default();

View File

@@ -1,14 +1,60 @@
//! 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::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;
use std::time::{Duration, Instant};
/// Pictures in item text ipx has sent, which `/api/art` may then fetch and keep as it does
/// artwork, so the page can save them to read offline (#389): only an address some feed's text
/// named, as artwork is only one some feed names. The oldest are forgotten past the limit, and
/// all of them at a restart, until a list sends them again.
static IN_TEXT: LazyLock<Mutex<(HashSet<String>, VecDeque<String>)>> = LazyLock::new(Mutex::default);
const IN_TEXT_MAX: usize = 20_000;
/// Remembers the pictures in a piece of cleaned item text.
pub fn name_in_text(html: &str) {
let srcs = img_srcs(html);
if srcs.is_empty() {
return;
}
let mut known = IN_TEXT.lock().unwrap_or_else(|e| e.into_inner());
for src in srcs {
if known.0.insert(src.clone()) {
known.1.push_back(src);
}
}
while known.1.len() > IN_TEXT_MAX {
if let Some(old) = known.1.pop_front() {
known.0.remove(&old);
}
}
}
pub fn named_in_text(url: &str) -> bool {
IN_TEXT.lock().unwrap_or_else(|e| e.into_inner()).0.contains(url)
}
/// The web addresses of the `<img src>`s in HTML as ammonia writes it: attributes in double
/// quotes, `&`, `"` and no-break spaces escaped, and no `srcset`, which it does not let through.
fn img_srcs(html: &str) -> Vec<String> {
let mut out = Vec::new();
for (at, _) in html.match_indices("<img ") {
let tag = &html[at..html[at..].find('>').map_or(html.len(), |end| at + end)];
let Some(start) = tag.find(" src=\"").map(|i| i + 6) else { continue };
let Some(len) = tag[start..].find('"') else { continue };
let src = tag[start..start + len].replace("&quot;", "\"").replace("&nbsp;", "\u{a0}").replace("&amp;", "&");
if src.starts_with("https://") || src.starts_with("http://") {
out.push(src);
}
}
out
}
pub fn dir() -> PathBuf {
crate::config::data_dir().join("art")
}
@@ -107,6 +153,15 @@ pub fn trim(dir: &Path, limit: u64) -> usize {
#[cfg(test)]
mod tests {
#[test]
fn the_pictures_in_cleaned_text_are_named_and_nothing_else() {
let html = r#"<p>a</p><img src="https://img.example/a.jpg?w=1&amp;h=2" alt="x" referrerpolicy="no-referrer"><img alt="no src"><img src="data:image/png;base64,xx"><a href="https://img.example/b.jpg">b</a>"#;
assert_eq!(super::img_srcs(html), ["https://img.example/a.jpg?w=1&h=2"]);
super::name_in_text(html);
assert!(super::named_in_text("https://img.example/a.jpg?w=1&h=2"));
assert!(!super::named_in_text("https://img.example/b.jpg"));
}
use super::*;
#[test]

View File

@@ -44,7 +44,7 @@ pub(crate) async fn add(
}
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
/// included, so one show is never added twice: its id, and whether it was there. For `ipxd add`
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
/// feed first, to say its own way why an address is not one.
pub async fn add_to_catalogue(
@@ -151,7 +151,7 @@ pub(crate) async fn import(ctx: &Ctx, file: &Path) -> Result<()> {
.await?
.into_iter()
.find(|u| u.is_admin)
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipxd user add <name> --admin"))?;
let doc = feed::read_opml(&text).map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = lists::subscribe_opml(ctx, lists::opml_feeds(&doc)?, admin.id).await?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
@@ -168,7 +168,7 @@ pub(crate) async fn assemble_config(db: &db::Db, mut cfg: config::Config, path:
if config::Config::file_holds_stored(path) {
tracing::warn!(
"config.toml still lists feeds or server settings; they are ignored, since the \
database holds them now. Change them in the web UI, or with ipx add and rm."
database holds them now. Change them in the web UI, or with ipxd add and rm."
);
}
stored.apply(&mut cfg);
@@ -213,13 +213,13 @@ pub(crate) async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
pub(crate) async fn list(ctx: &Ctx) -> Result<()> {
let cfg = ctx.cfg();
if cfg.feeds.is_empty() {
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
println!("No feeds configured. Add one with `ipxd add <url>`, or in the web UI.");
return Ok(());
}
for (id, feed) in &cfg.feeds {
let s = ctx.db.feed_summary(id).await?;
// The id on a line of its own, labelled: first on the title's line, antirez.com's id
// "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82).
// "feed" read as a heading and 'ipxd fetch antirez' was tried instead (issue #82).
println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id));
println!(" id {id}");
println!(" url {}", feed.url);
@@ -406,7 +406,7 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Fe
if cfg.feeds.contains_key(&m.id) {
// Scanned from its config entry and still read. Dropped as derived, its stored
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
ctx.db.unmanage(&m.id).await?;
ctx.db.unmanage(&m.id, cfg.feeds[&m.id].group.as_deref()).await?;
} else if promote_kept(ctx, &m, parent).await? {
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
@@ -450,8 +450,9 @@ pub(crate) async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&confi
// kept show went to the directory (#332). A list no longer known may have been private.
private: base.is_none_or(|p| p.private),
});
let group = cfg.feeds[&m.id].group.clone();
ctx.store_cfg(cfg).await?;
ctx.db.unmanage(&m.id).await?;
ctx.db.unmanage(&m.id, group.as_deref()).await?;
ctx.db.set_orphaned(&m.id, false).await?;
for (user, kept) in subs {
if !kept {
@@ -476,6 +477,22 @@ pub(crate) async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
Ok(before.len() - ctx.db.managed_feeds().await?.len())
}
/// Puts each feed no longer derived in the group its catalogue entry says, and returns how many
/// moved. Made a feed of its own before #358, a show kept its list's group in the database, and
/// hiding the list from All Subscriptions hid it too.
pub(crate) async fn regroup_unmanaged(ctx: &Ctx) -> Result<usize> {
let cfg = ctx.cfg();
let mut moved = 0;
for (id, group) in ctx.db.unmanaged_in_groups().await? {
let Some(f) = cfg.feeds.get(&id) else { continue };
if f.group.as_deref() != Some(group.as_str()) {
ctx.db.unmanage(&id, f.group.as_deref()).await?;
moved += 1;
}
}
Ok(moved)
}
/// A feed that answered from a new address after permanent redirects moves there in the
/// catalogue, so it is read from there and no longer redirected every time. One an OPML lists
/// is left alone, since the OPML would only put the old address back; so is a move onto an
@@ -499,6 +516,58 @@ mod tests {
use super::*;
use crate::test_util::{feed, test_ctx};
#[tokio::test]
async fn a_feed_that_moved_for_good_takes_its_new_address_unless_another_feed_has_it() {
let mut cfg = config::Config::default();
for id in ["moving", "other"] {
cfg.feeds.insert(id.into(), config::Feed { url: format!("http://x/{id}.xml"), ..feed() });
}
let ctx = test_ctx(cfg).await;
ctx.db.store_config(&ctx.cfg()).await.unwrap();
ctx.db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1, 'dee', false); INSERT INTO subscriptions (user_id, feed_id) VALUES (1, 'moving');").await.unwrap();
follow_move(&ctx, "moving", "https://x/new.xml").await.unwrap();
assert_eq!(ctx.cfg().feeds["moving"].url, "https://x/new.xml");
let stored = ctx.db.stored_config().await.unwrap().unwrap().1;
assert_eq!(stored["moving"].url, "https://x/new.xml", "kept, not only in memory");
// The id stays, so whoever subscribed keeps it, with their read state.
assert_eq!(ctx.db.i64s_for_test("SELECT user_id FROM subscriptions WHERE feed_id = 'moving'").await, [1]);
// Onto an address another feed has: two would be the same feed under two ids.
follow_move(&ctx, "moving", "https://x/other.xml").await.unwrap();
assert_eq!(ctx.cfg().feeds["moving"].url, "https://x/new.xml", "left where it was");
follow_move(&ctx, "from-an-opml", "https://x/elsewhere.xml").await.unwrap();
assert!(!ctx.cfg().feeds.contains_key("from-an-opml"), "its OPML would only put it back");
}
#[tokio::test]
async fn ipx_add_and_rm_change_the_catalogue() {
let base = crate::test_util::empty_or_feed_server().await;
let ctx = test_ctx(config::Config::default()).await;
let url = format!("{base}/feed");
add(&ctx, &url, Some("Shows".into()), vec!["rust".into()], None, false).await.unwrap();
let (id, f) = ctx.cfg().feeds.iter().next().map(|(id, f)| (id.clone(), f.clone())).expect("added");
assert_eq!(id, "t", "named for its title, T");
assert_eq!(
(f.url.as_str(), f.folder.as_deref(), f.keywords.as_slice()),
(url.as_str(), Some("Shows"), &["rust".to_owned()][..])
);
assert!(!f.listed);
let again = add(&ctx, &url, None, vec![], None, false).await.unwrap_err();
assert!(format!("{again:#}").contains("already subscribed"), "{again:#}");
// Asking again to list it, or with a category, changes only that.
add(&ctx, &url, None, vec![], Some("News".into()), true).await.unwrap();
let f = &ctx.cfg().feeds[&id];
assert!(f.listed && f.category.as_deref() == Some("News") && f.folder.as_deref() == Some("Shows"));
assert_eq!(ctx.cfg().feeds.len(), 1);
rm(&ctx, &id).await.unwrap();
assert!(ctx.cfg().feeds.is_empty());
let stored = ctx.db.stored_config().await.unwrap().map(|s| s.1).unwrap_or_default();
assert!(stored.is_empty(), "gone from the database too");
}
#[tokio::test]
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
@@ -594,6 +663,55 @@ mod tests {
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
}
#[tokio::test]
async fn a_feed_added_again_over_the_other_scheme_is_the_one_already_there() {
// oldvcr's blog, subscribed over https and again over http: every post twice (#362).
let mut cfg = config::Config::default();
let mut f = feed();
f.url = "https://oldvcr.blogspot.com/feeds/posts/default".into();
cfg.feeds.insert("oldvcr".into(), f);
let ctx = test_ctx(cfg).await;
let got = add_to_catalogue(&ctx, "http://oldvcr.blogspot.com/feeds/posts/default", None, vec![]).await.unwrap();
assert_eq!(got, ("oldvcr".to_owned(), true));
assert_eq!(ctx.cfg().feeds.len(), 1);
}
#[tokio::test]
async fn a_show_made_a_feed_of_its_own_is_not_hidden_with_its_list() {
// Unsung: promoted out of a Hacker News OPML hidden from All Subscriptions, its unread
// counted in the feed list and missing from All Subscriptions (#358).
let mut cfg = config::Config::default();
cfg.feeds.insert("opml".into(), feed());
let ctx = test_ctx(cfg).await;
ctx.db
.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true);
INSERT INTO feeds (id, url) VALUES ('opml','u0');
INSERT INTO subscriptions (user_id, feed_id, kept) VALUES (1,'opml',false), (1,'show',true), (1,'old',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('show','1',1), ('old','2',2);",
)
.await
.unwrap();
ctx.db.upsert_managed("show", "http://x/s.xml", "Show", "opml").await.unwrap();
ctx.db.upsert_managed("old", "http://x/o.xml", "Old", "opml").await.unwrap();
ctx.db.set_hide_from_all(1, "opml", true).await.unwrap();
let unread = async || ctx.db.count_of(1, None, db::Filter::Unread, None).await.unwrap();
assert_eq!(unread().await, 0, "the list's shows are hidden with it");
let show = ctx.db.managed_feeds().await.unwrap().into_iter().find(|m| m.id == "show").unwrap();
assert!(promote_kept(&ctx, &show, None).await.unwrap());
assert_eq!(unread().await, 1, "a feed of its own is in no list");
// Promoted before the fix: unmanaged, still in the group, and in no group in config.
ctx.db.exec_for_test("UPDATE feeds SET managed = false WHERE id = 'old'").await.unwrap();
let mut cfg = ctx.edit_cfg().await;
cfg.feeds.insert("old".into(), feed());
ctx.store_cfg(cfg).await.unwrap();
assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 1);
assert_eq!(unread().await, 2);
assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 0, "nothing left to move");
}
#[tokio::test]
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
@@ -613,4 +731,38 @@ mod tests {
assert_eq!(managed, ["listed"], "a group still in config is left alone");
assert_eq!(ctx.db.feed_summary("promoted").await.unwrap().entries, 1, "its entries survive");
}
#[tokio::test]
async fn an_exported_opml_imports_as_the_admins_subscriptions() {
let file = std::env::temp_dir().join(format!("ipx-opml-test-{}.opml", std::process::id()));
let from = test_ctx(config::Config::default()).await;
list(&from).await.unwrap();
let mut cfg = from.edit_cfg().await;
for (id, url) in [("one", "http://x/one.xml"), ("two", "http://x/two.xml")] {
cfg.feeds
.insert(id.into(), crate::test_util::add_one_cfg(&mut config::Config::default(), url, None, vec![]));
}
from.store_cfg(cfg).await.unwrap();
list(&from).await.unwrap();
export(&from, &file).await.unwrap();
let xml = std::fs::read_to_string(&file).unwrap();
assert!(xml.contains("http://x/one.xml") && xml.contains("http://x/two.xml"), "{xml}");
let to = test_ctx(config::Config::default()).await;
let none = import(&to, &file).await.unwrap_err();
assert!(format!("{none:#}").contains("no admin account"), "the CLI subscribes the admin: {none:#}");
let admin = to.db.create_user("admin", None, true).await.unwrap();
import(&to, &file).await.unwrap();
let urls: std::collections::BTreeSet<String> = to.cfg().feeds.values().map(|f| f.url.clone()).collect();
assert_eq!(urls, ["http://x/one.xml".to_owned(), "http://x/two.xml".to_owned()].into());
for id in to.cfg().feeds.keys() {
assert_eq!(to.db.subscribers_of(id).await.unwrap(), [(admin, false)], "{id} is the admin's");
}
import(&to, &file).await.unwrap();
assert_eq!(to.cfg().feeds.len(), 2, "importing again adds nothing");
std::fs::write(&file, "not opml").unwrap();
assert!(format!("{:#}", import(&to, &file).await.unwrap_err()).contains("is not OPML"));
let _ = std::fs::remove_file(&file);
}
}

View File

@@ -10,7 +10,7 @@ use std::path::PathBuf;
use std::sync::Arc;
#[derive(Parser)]
#[command(name = "ipx", version, about = "A headless podcatcher")]
#[command(name = "ipxd", version, about = "A headless podcatcher")]
pub(crate) struct Cli {
/// Config file (default: $XDG_CONFIG_HOME/ipx/config.toml)
#[arg(long, global = true)]
@@ -81,7 +81,7 @@ pub(crate) enum Command {
#[derive(Subcommand)]
pub(crate) enum UserCmd {
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray`
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipxd user add ray`
Add {
name: String,
/// May manage other accounts, and is who the shared web token signs in as
@@ -104,14 +104,13 @@ pub(crate) enum UserCmd {
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
/// listing.
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
let read_password = || -> Result<String> {
use std::io::Read;
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::io::Read) -> Result<()> {
let mut read_password = || -> Result<String> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
stdin.read_to_string(&mut buf)?;
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
if pw.is_empty() {
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
anyhow::bail!("no password on stdin: try `echo -n secret | ipxd user ...`");
}
Ok(pw)
};
@@ -139,7 +138,7 @@ pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
UserCmd::List => {
let users = ctx.db.users().await?;
if users.is_empty() {
println!("no accounts yet: ipx user add <name>");
println!("no accounts yet: ipxd user add <name>");
}
for u in users {
let added = u
@@ -232,3 +231,50 @@ pub(crate) fn duration(secs: u64) -> String {
s => format!("{}d", s / 86_400),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn accounts_are_added_renamed_given_a_password_and_removed() {
let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await);
let user = |cmd, stdin: &'static str| user_cmd(&ctx, cmd, stdin.as_bytes());
let add = |name: &str, no_password| UserCmd::Add { name: name.into(), admin: false, no_password };
user(add(" Ray ", false), "first secret\n").await.unwrap();
let ray = ctx.db.user_by_name("ray").await.unwrap().expect("lower-cased and trimmed");
assert!(ray.is_admin, "the first account runs the place");
assert!(
crate::auth::check_password("first secret".into(), ray.pass_hash.clone()).await,
"the newline is not part of it"
);
user(add("sam", true), "").await.unwrap();
let sam = ctx.db.user_by_name("sam").await.unwrap().unwrap();
assert!(!sam.is_admin && sam.pass_hash.is_none(), "signs in through the proxy");
assert!(user(add("RAY", true), "").await.is_err(), "already exists");
assert!(user(add(" ", true), "").await.is_err(), "a name is required");
assert!(user(add("dee", false), "").await.is_err(), "no password on stdin");
user(UserCmd::List, "").await.unwrap();
// A new password signs every browser out.
let sid = crate::auth::new_session_token();
ctx.db.create_session(ray.id, &sid).await.unwrap();
user(UserCmd::Passwd { name: "ray".into() }, "second secret").await.unwrap();
let ray = ctx.db.user_by_name("ray").await.unwrap().unwrap();
assert!(crate::auth::check_password("second secret".into(), ray.pass_hash).await);
assert!(ctx.db.session_user(&sid, 86_400).await.unwrap().is_none(), "signed out");
assert!(user(UserCmd::Passwd { name: "nobody".into() }, "x").await.is_err());
let rename = |name: &str, new_name: &str| UserCmd::Rename { name: name.into(), new_name: new_name.into() };
assert!(user(rename("sam", "ray"), "").await.is_err(), "taken");
assert!(user(rename("sam", "a,b"), "").await.is_err(), "the proxy could never send it");
user(rename("sam", "Sam@X.test"), "").await.unwrap();
assert_eq!(ctx.db.user_by_name("sam@x.test").await.unwrap().map(|u| u.id), Some(sam.id), "same account");
user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.unwrap();
assert!(ctx.db.user_by_name("sam@x.test").await.unwrap().is_none());
assert!(user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.is_err(), "already gone");
}
}

View File

@@ -15,6 +15,8 @@ pub struct Config {
pub torrent: Torrent,
#[serde(default)]
pub web: Web,
#[serde(default)]
pub push: Push,
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
#[serde(default)]
pub feeds: BTreeMap<String, Feed>,
@@ -52,6 +54,12 @@ pub struct General {
pub directory_token: String,
/// Send the directory the public feeds people here subscribe to, and how many of them do.
pub directory_share: bool,
/// ipx-play, which keeps a shared episode and gives back a link to its page (#447), as
/// `https://play.ipxreader.app` or, on its own network, `http://ipx-play:8102`. Empty: Share
/// hands over the episode's own address.
pub play_url: String,
/// The token ipx-play gave this server. Never sent to the page.
pub play_token: String,
/// 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,
@@ -73,6 +81,33 @@ pub enum Organize {
Date,
}
/// Apple's push service (#433), which keeps the iOS app's icon count exact. Nothing is sent
/// unless the key, its id and the team are all set.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Push {
/// The APNs key Apple gives once as a `.p8` file (developer account, Keys).
pub key_file: PathBuf,
/// That key's id, ten characters, shown beside it.
pub key_id: String,
/// The Apple developer team the app is signed by, as `8743L8D729`.
pub team_id: String,
/// The app's bundle id, which Apple checks each device token against.
pub topic: String,
}
impl Default for Push {
fn default() -> Self {
Push { key_file: PathBuf::new(), key_id: String::new(), team_id: String::new(), topic: "net.sdf1.ipx".into() }
}
}
impl Push {
pub fn is_set(&self) -> bool {
!self.key_file.as_os_str().is_empty() && !self.key_id.is_empty() && !self.team_id.is_empty()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Torrent {
@@ -190,7 +225,7 @@ 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
/// Put in the Directory by an admin (`ipxd 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,
@@ -219,6 +254,8 @@ impl Default for General {
directory_url: String::new(),
directory_token: String::new(),
directory_share: false,
play_url: String::new(),
play_token: String::new(),
signals_on_server: false,
signal_days: 7,
fetch_internal: vec![],
@@ -359,6 +396,10 @@ pub struct Stored {
#[serde(default)]
pub directory_share: bool,
#[serde(default)]
pub play_url: String,
#[serde(default)]
pub play_token: String,
#[serde(default)]
pub signals_on_server: bool,
#[serde(default = "signal_days")]
pub signal_days: u64,
@@ -373,7 +414,7 @@ fn art_cache_mb() -> u64 {
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 11] = [
const STORED_KEYS: [&str; 13] = [
"schedule",
"max_total_gb",
"max_age_days",
@@ -383,6 +424,8 @@ const STORED_KEYS: [&str; 11] = [
"directory_url",
"directory_token",
"directory_share",
"play_url",
"play_token",
"signals_on_server",
"signal_days",
];
@@ -400,6 +443,8 @@ impl Stored {
directory_url: g.directory_url.clone(),
directory_token: g.directory_token.clone(),
directory_share: g.directory_share,
play_url: g.play_url.clone(),
play_token: g.play_token.clone(),
signals_on_server: g.signals_on_server,
signal_days: g.signal_days,
}
@@ -416,6 +461,8 @@ impl Stored {
g.directory_url = self.directory_url;
g.directory_token = self.directory_token;
g.directory_share = self.directory_share;
g.play_url = self.play_url;
g.play_token = self.play_token;
g.signals_on_server = self.signals_on_server;
g.signal_days = self.signal_days;
}

View File

@@ -48,7 +48,7 @@ pub struct Ctx {
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
/// another add spent a minute fetching was gone once that one stored (#153).
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
// ponytail: this process only. `ipxd add`, `rm` and `import` write from a process of their
// own, and a change in flight here can still store over theirs; take the copy from the
// database rather than memory if that ever happens.
pub cfg_write: tokio::sync::Mutex<()>,

View File

@@ -12,6 +12,7 @@ use std::time::{Duration, Instant};
use tokio::runtime::Handle;
use tokio::sync::{broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use tracing::Instrument;
/// The catalogue and server settings as the database now has them, in place of the copy in
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
@@ -23,7 +24,7 @@ pub(crate) async fn reload(ctx: &Ctx) -> Result<()> {
Ok(())
}
/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
/// The counts `ipxd status` prints, and /api/status serves. A running daemon's socket answers with
/// this directly rather than through the job queue.
pub(crate) async fn status(ctx: &Ctx) -> Event {
match tracing::Instrument::instrument(ctx.db.counts(), db::step("counts")).await {
@@ -49,54 +50,81 @@ pub(crate) async fn daemon(
config_path: PathBuf,
web_addr: Option<String>,
events: broadcast::Sender<Event>,
start: tracing::Span,
) -> Result<()> {
let socket = ctx.cfg().general.socket.clone();
if ipc::daemon_is_live(&socket).await {
anyhow::bail!("a daemon is already listening on {}", socket.display());
}
// A database with nobody in it cannot be signed into.
if ctx.db.users().await?.is_empty() {
let dir = ctx.config_path.parent().unwrap_or(Path::new("."));
let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
tracing::warn!(
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipx user passwd admin`"
);
}
// Unread lists start from here, and a reap may be a day off (#347). Slower without, never wrong.
if let Err(e) = ctx.db.advance_unread_from().await {
tracing::warn!(error = %format!("{e:#}"), "could not work out where unread items start");
}
if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) {
let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
match ctx.db.adopt_catalogue(admin.id, &catalogue).await {
Ok(0) => {}
Ok(n) => tracing::info!(user = %admin.name, feeds = n, "subscribed the first admin to the catalogue"),
Err(e) => tracing::error!(error = %e, "could not subscribe the first admin to the catalogue"),
// The rest of start-up, in the trace main began (#374): how long a deploy took to answer, and
// what held it.
let interrupted = async {
// A database with nobody in it cannot be signed into.
if ctx.db.users().instrument(db::step("users")).await?.is_empty() {
let dir = ctx.config_path.parent().unwrap_or(Path::new("."));
let (password, told) = auth::first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?;
ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), /* admin */ true).await?;
tracing::warn!(
"no accounts yet: created 'admin' {told}. Change it with `echo -n <password> | ipxd user passwd admin`"
);
}
}
match ctx.db.requeue_interrupted().await {
Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads"),
}
// Unread lists start from here, and a reap may be a day off (#347). Slower without, never wrong.
if let Err(e) = ctx.db.advance_unread_from().instrument(db::step("advance_unread_from")).await {
tracing::warn!(error = %format!("{e:#}"), "could not work out where unread items start");
}
match catalogue::retire_stranded(&ctx).await {
Ok(0) => {}
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
if let Some(admin) = ctx.db.users().await?.into_iter().find(|u| u.is_admin) {
let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
match ctx.db.adopt_catalogue(admin.id, &catalogue).instrument(db::step("adopt_catalogue")).await {
Ok(0) => {}
Ok(n) => tracing::info!(user = %admin.name, feeds = n, "subscribed the first admin to the catalogue"),
Err(e) => tracing::error!(error = %e, "could not subscribe the first admin to the catalogue"),
}
}
let interrupted = match ctx.db.requeue_interrupted().instrument(db::step("requeue_interrupted")).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads");
vec![]
}
};
match catalogue::retire_stranded(&ctx).instrument(db::step("retire_stranded")).await {
Ok(0) => {}
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
}
match catalogue::regroup_unmanaged(&ctx).instrument(db::step("regroup_unmanaged")).await {
Ok(0) => {}
Ok(n) => tracing::info!(feeds = n, "took feeds of their own out of their old list's group"),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "could not take feeds of their own out of their old list's group")
}
}
anyhow::Ok(interrupted)
}
.instrument(start.clone())
.await?;
// Room for any one request's commands: a web request waits inside itself for a free place,
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
// every other request that queues work waited behind them (#154).
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(1024);
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
// Taken again first thing: left for their feeds' next check, a download a deploy stopped
// waited an hour, or a day for a daily feed (#410).
if !interrupted.is_empty() {
tracing::info!(count = interrupted.len(), "taking up downloads interrupted by a restart");
}
for enclosure in interrupted {
if tx_cmd.try_send(Cmd::Download { enclosure }).is_err() {
break; // The queue is full; the rest wait for their feeds' next check, as before.
}
}
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).instrument(start.clone()).await?;
// status and reload are answered by the socket itself; everything else waits its turn in the
// queue.
let answer: ipc::StatusFn = {
@@ -116,7 +144,9 @@ pub(crate) async fn daemon(
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
// One command at a time: the queue is what keeps two scans from overlapping.
tracing::info!(feeds = catalogue::subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), "daemon started");
let feeds = catalogue::subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0);
start.in_scope(|| tracing::info!(feeds, "daemon started"));
drop(start);
// A signal has to be able to interrupt work in progress, not just the wait between
// jobs. Racing `shutdown()` in the outer select only cancels branch selection: once
@@ -131,6 +161,7 @@ pub(crate) async fn daemon(
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(signals::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(crate::push::run(ctx.clone(), rx_stop.clone()));
// The library's counts for /metrics. The Docker healthcheck asked for them every 30 s until
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
// out (#218).
@@ -184,7 +215,10 @@ pub(crate) async fn daemon(
"-> {}",
serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}"))
);
if !until_stopped(&ctx, &rx_stop, cli::run(&ctx, cmd)).await {
// A trace a command, its job inside it (#373): from the CLI or the page alike.
let kind = serde_json::to_value(&cmd).ok().and_then(|v| v["cmd"].as_str().map(str::to_owned));
let span = tracing::info_span!("command", cmd = kind.as_deref().unwrap_or("?"));
if !until_stopped(&ctx, &rx_stop, cli::run(&ctx, cmd).instrument(span)).await {
break;
}
}
@@ -277,7 +311,14 @@ pub(crate) async fn start_web(
let at = metrics::spawn(&cfg.web.metrics_bind, sources)?;
tracing::info!(%at, "serving metrics for Prometheus at /metrics");
}
let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), access, playing };
let state = web::WebState {
ctx: ctx.clone(),
cmds: cmds.clone(),
events: events.clone(),
access,
playing,
wrong: Default::default(),
};
Ok(Some(tokio::spawn(async move {
if let Err(e) = web::serve(state, &bind).await {
tracing::error!(error = %format!("{e:#}"), "web ui stopped");
@@ -301,7 +342,7 @@ pub(crate) async fn shutdown() {
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a
/// ceiling picks up within ten minutes what no command announces, such as `ipxd add` or a
/// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway.
pub(crate) async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
let next = async {
@@ -337,6 +378,16 @@ mod tests {
use super::*;
use crate::test_util::{feed, test_ctx};
#[tokio::test]
async fn the_daemon_sleeps_at_least_30_s_and_at_most_10_minutes() {
let ctx = test_ctx(config::Config::default()).await;
assert_eq!(until_next_scan(&ctx).await, Duration::from_secs(600), "nothing to check: the ceiling");
let mut cfg = config::Config::default();
cfg.feeds.insert("never-checked".into(), feed());
let ctx = test_ctx(cfg).await;
assert_eq!(until_next_scan(&ctx).await, Duration::from_secs(30), "due now: the floor, not a spin");
}
#[tokio::test]
async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
let with = |id: &str| {
@@ -347,7 +398,7 @@ mod tests {
let held = with("kept-in-memory");
let ctx = test_ctx(held.clone()).await;
ctx.db.store_config(&held).await.unwrap();
// `ipx add` and `ipx rm`, from a process of their own (#120).
// `ipxd add` and `ipxd rm`, from a process of their own (#120).
ctx.db.store_config(&with("added-by-the-cli")).await.unwrap();
reload(&ctx).await.unwrap();
let urls: Vec<String> = ctx.cfg().feeds.values().map(|f| f.url.clone()).collect();

View File

@@ -2,8 +2,8 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat.
use crate::entity::{
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings,
signal_verdicts, signals, subscriptions, users,
api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, push_devices, sessions,
settings, signal_verdicts, signals, subscriptions, users,
};
use anyhow::{Context, Result};
use sea_orm::sea_query::{Expr, Func};
@@ -43,7 +43,7 @@ impl Drop for Db {
///
/// Not SeaORM's schema sync, which is experimental and, despite its docs, drops a unique index
/// the entities do not describe: it dropped users_name_lower on every open, so every `ipx`
/// command took a write lock, and the healthcheck's `ipx status` timed out behind a busy daemon.
/// command took a write lock, and the healthcheck's `ipxd status` timed out behind a busy daemon.
/// On Postgres it would have failed outright, dropping that index as a constraint.
///
/// ponytail: tables only, no columns. A column added to an existing table needs its own ALTER
@@ -72,6 +72,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(deadair_saved::Entity),
schema.create_table_from_entity(signals::Entity),
schema.create_table_from_entity(signal_verdicts::Entity),
schema.create_table_from_entity(push_devices::Entity),
] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
}
@@ -97,7 +98,7 @@ async fn create_missing(orm: &DatabaseConnection) -> Result<()> {
}
// A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves
// as it was. Looked for first, and added only when it is missing: this runs on every open,
// the healthcheck's `ipx status` included, and an ALTER takes a lock that waits behind a
// the healthcheck's `ipxd status` included, and an ALTER takes a lock that waits behind a
// busy daemon, which is what made the healthcheck time out once before.
if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() {
// A feed is read whole only when it has changed since the last read, so each one's
@@ -271,11 +272,38 @@ async fn connect(location: &str, keep_open: bool) -> Result<DatabaseConnection>
// The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to
// open, its password exchange most of it, and a page load asked for more than were idle, so
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question.
// Not a command such as `ipxd status`, nor a test, which would hold twenty for one question.
if keep_open && is_postgres(location) {
opts.min_connections(20);
}
Database::connect(opts).await.with_context(|| format!("opening {}", redact(location)))
let mut db = Database::connect(opts).await.with_context(|| format!("opening {}", redact(location)))?;
// Each statement's time once it has a connection (#367), by its first word: the SQL itself
// would be a row a query.
db.set_metric_callback(|info| {
let first = info.statement.sql.split_whitespace().next().unwrap_or("").to_ascii_lowercase();
let kind =
if ["select", "insert", "update", "delete", "with"].contains(&first.as_str()) { &first } else { "other" };
crate::metrics::QUERIES.observe(&format!("kind=\"{kind}\""), info.elapsed.as_secs_f64());
LAST_QUERY.set(Some(info.elapsed));
});
Ok(db)
}
thread_local! {
/// The time of the statement this thread last ran, for `waited`. SeaORM calls the metric
/// callback in the same poll that hands the result back to `exec` and `rows`, with no await
/// between, so the value read there is that statement's own.
static LAST_QUERY: std::cell::Cell<Option<std::time::Duration>> = const { std::cell::Cell::new(None) };
}
/// Counts how long a statement waited for a connection: its whole call less its time on one
/// (#367). Only hand-written SQL, about half of ipx's queries, comes through here; SeaORM's own
/// queries are timed but their wait is not seen.
/// shortcut: half the queries, measured by difference; a wrapper on sqlx's pool would see all.
fn waited(started: std::time::Instant) {
if let Some(q) = LAST_QUERY.take() {
crate::metrics::DB_WAIT.observe("", started.elapsed().saturating_sub(q).as_secs_f64());
}
}
/// The URL `connect` hands sqlx for a location: a SQLite file, created if missing, or a Postgres
@@ -380,7 +408,7 @@ pub struct Managed {
pub group_id: String,
}
/// What `ipx list` shows next to each configured feed.
/// What `ipxd list` shows next to each configured feed.
#[derive(Debug, Default)]
pub struct FeedSummary {
pub title: Option<String>,
@@ -410,7 +438,7 @@ impl Db {
}
let orm = connect(location, keep_open).await?;
if !is_postgres(location) {
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a
// WAL, so the healthcheck's `ipxd status` reads while the daemon writes. It is a
// setting of the file, kept once made, and making it takes a lock that cannot wait out
// a busy daemon, so it is made only when the file is not already in WAL.
let mode =
@@ -1705,11 +1733,18 @@ impl Db {
/// Runs a statement; how many rows it changed.
async fn exec(&self, sql: &str, values: Vec<sea_orm::Value>) -> Result<u64> {
Ok(self.orm.execute_raw(self.stmt(sql, values)).await?.rows_affected())
let (stmt, started) = (self.stmt(sql, values), std::time::Instant::now());
// Only on success: a connection never had means no callback, and a stale time.
let n = self.orm.execute_raw(stmt).await?.rows_affected();
waited(started);
Ok(n)
}
async fn rows(&self, sql: &str, values: Vec<sea_orm::Value>) -> Result<Vec<sea_orm::QueryResult>> {
Ok(self.orm.query_all_raw(self.stmt(sql, values)).await?)
let (stmt, started) = (self.stmt(sql, values), std::time::Instant::now());
let rows = self.orm.query_all_raw(stmt).await?;
waited(started);
Ok(rows)
}
/// The first admin starts subscribed to the whole catalogue: whoever wrote config.toml meant
@@ -2254,6 +2289,51 @@ impl Db {
Ok(Some(User::from(user)))
}
/// Keeps a phone's push token for this person (#433). A token already kept for someone else
/// is theirs no longer: the phone has signed in as this person since.
pub async fn add_push_device(&self, user_id: i64, token: &str, sandbox: bool) -> Result<()> {
push_devices::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
push_devices::ActiveModel {
token: Set(token.to_owned()),
user_id: Set(user_id),
sandbox: Set(sandbox),
created: Set(now()),
badge: Set(None),
}
.insert(&self.orm)
.await?;
Ok(())
}
/// Signing out on that phone: someone else's token does nothing. Whether it was theirs.
pub async fn remove_push_device(&self, user_id: i64, token: &str) -> Result<bool> {
let gone = push_devices::Entity::delete_many()
.filter(push_devices::Column::Token.eq(token))
.filter(push_devices::Column::UserId.eq(user_id))
.exec(&self.orm)
.await?;
Ok(gone.rows_affected > 0)
}
/// One Apple says is no longer the app's, removed or reinstalled.
pub async fn forget_push_device(&self, token: &str) -> Result<()> {
push_devices::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
Ok(())
}
pub async fn push_devices(&self) -> Result<Vec<push_devices::Model>> {
Ok(push_devices::Entity::find().all(&self.orm).await?)
}
pub async fn set_push_badge(&self, token: &str, n: i64) -> Result<()> {
push_devices::Entity::update_many()
.col_expr(push_devices::Column::Badge, Expr::val(n))
.filter(push_devices::Column::Token.eq(token))
.exec(&self.orm)
.await?;
Ok(())
}
pub async fn delete_session(&self, token: &str) -> Result<()> {
sessions::Entity::delete_by_id(token.to_owned()).exec(&self.orm).await?;
Ok(())
@@ -2420,12 +2500,23 @@ impl Db {
Ok(())
}
/// Stops treating a feed as derived, because it now has its own config entry.
pub async fn unmanage(&self, id: &str) -> Result<()> {
self.exec("UPDATE feeds SET managed = false WHERE id = $1", vec![id.into()]).await?;
/// Stops treating a feed as derived, because it now has its own config entry, in `group` as
/// that entry says. Left in its list's group, a feed of its own was hidden with the list (#358).
pub async fn unmanage(&self, id: &str, group: Option<&str>) -> Result<()> {
self.exec("UPDATE feeds SET managed = false, group_id = $2 WHERE id = $1", vec![id.into(), group.into()])
.await?;
Ok(())
}
/// Feeds not derived any more that the database still puts in a group, with that group.
pub async fn unmanaged_in_groups(&self) -> Result<Vec<(String, String)>> {
self.rows("SELECT id, group_id FROM feeds WHERE NOT coalesce(managed, false) AND group_id IS NOT NULL", vec![])
.await?
.iter()
.map(|r| Ok((r.try_get("", "id")?, r.try_get("", "group_id")?)))
.collect()
}
/// Empties a feed of its items, leaving its files alone.
pub async fn clear_entries(&self, feed_id: &str) -> Result<()> {
entries::Entity::delete_many().filter(entries::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
@@ -2590,11 +2681,14 @@ impl Db {
/// Nothing can be in flight the moment the daemon starts, so any row still marked
/// `downloading` is a leftover from a restart or a crash. Left alone it would sit
/// there forever: the pending queue skips it and nothing else ever revisits it.
pub async fn requeue_interrupted(&self) -> Result<usize> {
Ok(self
.exec("UPDATE enclosures SET state = 'pending' WHERE state = 'downloading' AND path IS NULL", vec![])
.await? as usize)
/// there forever: the pending queue skips it and nothing else ever revisits it. Gives the
/// ones put back, for the daemon to take again at once rather than at their feed's next check.
pub async fn requeue_interrupted(&self) -> Result<Vec<i64>> {
const LEFT: &str = "state = 'downloading' AND path IS NULL";
let rows = self.rows(&format!("SELECT id FROM enclosures WHERE {LEFT} ORDER BY id"), vec![]).await?;
let ids = rows.iter().map(|r| r.try_get::<i64>("", "id")).collect::<Result<Vec<_>, _>>()?;
self.exec(&format!("UPDATE enclosures SET state = 'pending' WHERE {LEFT}"), vec![]).await?;
Ok(ids)
}
/// Puts an enclosure back in the queue so the next scan picks it up. This is how a
@@ -3136,6 +3230,24 @@ mod tests {
assert!(unread().await.is_empty());
}
#[tokio::test]
async fn a_phone_counts_for_whoever_last_signed_in_on_it() {
let db = Db::memory().await.unwrap();
let (me, you) =
(db.create_user("me", None, false).await.unwrap(), db.create_user("you", None, false).await.unwrap());
let phone = "ab".repeat(32);
db.add_push_device(me, &phone, false).await.unwrap();
db.set_push_badge(&phone, 4).await.unwrap();
db.add_push_device(you, &phone, true).await.unwrap();
let all = db.push_devices().await.unwrap();
assert_eq!(all.len(), 1, "one phone, one row (#433)");
assert_eq!((all[0].user_id, all[0].sandbox, all[0].badge), (you, true, None), "the new person's, sent afresh");
assert!(!db.remove_push_device(me, &phone).await.unwrap(), "not mine to remove any more");
assert!(db.remove_push_device(you, &phone).await.unwrap());
assert!(db.push_devices().await.unwrap().is_empty());
}
#[tokio::test]
async fn a_new_password_signs_the_account_out_everywhere_but_keeps_its_tokens() {
let db = Db::memory().await.unwrap();
@@ -4069,7 +4181,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(db.requeue_interrupted().await.unwrap(), 1, "only the in-flight, fileless one");
assert_eq!(db.requeue_interrupted().await.unwrap(), [1], "only the in-flight, fileless one");
let state = async |id: i64| db.strings_for_test(&format!("SELECT state FROM enclosures WHERE id = {id}")).await;
assert_eq!(state(1).await, ["pending"]);
assert_eq!(state(3).await, ["downloading"], "it has a file; leave it alone");

View File

@@ -457,4 +457,51 @@ mod tests {
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
#[tokio::test]
async fn a_download_is_sniffed_for_what_it_is_and_placed_without_clobbering() {
use axum::routing::get;
let app = axum::Router::new()
.route("/ep.mp3", get(|| async { "ID3 an episode" }))
.route("/wall.mp3", get(|| async { "\n <!DOCTYPE html><html>Sign in</html>" }))
.route("/show.torrent", get(|| async { "d8:announce35:udp://tracker.example:80/announcee" }))
.route("/gone.mp3", get(|| async { axum::http::StatusCode::NOT_FOUND }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let d = std::env::temp_dir().join(format!("ipx-download-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let mut cfg = Config::default();
cfg.general.download_dir = d.clone();
let feed = crate::test_util::feed();
let client = reqwest::Client::new();
let (c, g, f) = (&client, &cfg, &feed);
let get = |path: &str| {
let url = format!("http://{at}{path}");
async move { download(c, g, f, &url, |_, _| {}).await }
};
let mut seen = vec![];
let ep =
download(&client, &cfg, &feed, &format!("http://{at}/ep.mp3"), |done, _| seen.push(done)).await.unwrap();
assert!(matches!(ep.kind, Kind::File));
assert_eq!((ep.name.as_str(), ep.bytes), ("ep.mp3", 14));
assert_eq!(seen.last(), Some(&14), "progress reaches the whole file");
let first = place(&ep, &d.join("Show")).await.unwrap();
assert_eq!(first, d.join("Show/ep.mp3"));
assert_eq!(std::fs::read(&first).unwrap(), b"ID3 an episode");
// The same name again, from another enclosure: beside the first, not over it.
let again = get("/ep.mp3").await.unwrap();
assert_eq!(place(&again, &d.join("Show")).await.unwrap(), d.join("Show/ep-2.mp3"));
assert!(first.exists());
let wall = get("/wall.mp3").await.err().unwrap();
assert!(format!("{wall:#}").contains("HTML page"), "{wall:#}");
assert!(!d.join(".ipx-incomplete/wall.mp3").exists(), "a login page is not kept");
assert!(matches!(get("/show.torrent").await.unwrap().kind, Kind::Torrent));
assert!(format!("{:#}", get("/gone.mp3").await.err().unwrap()).contains("404"));
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@@ -386,6 +386,27 @@ pub mod sessions {
owned_by_user!();
}
/// A phone with the iOS app, signed in as someone, to send the icon's unread count to (#433).
pub mod push_devices {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "push_devices")]
pub struct Model {
/// The token Apple gave the app on that phone, in hex.
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub token: String,
pub user_id: i64,
/// From a debug build, which Apple's sandbox serves rather than production.
pub sandbox: bool,
pub created: i64,
/// The count last sent, so a minute in which it did not change sends nothing.
pub badge: Option<i64>,
}
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 {

View File

@@ -212,7 +212,7 @@ pub enum Command {
enclosure: i64,
},
Status,
/// Read the catalogue and server settings again from the database, which `ipx add`, `rm`
/// Read the catalogue and server settings again from the database, which `ipxd 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.
@@ -245,6 +245,7 @@ impl Emitter {
// 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());
crate::metrics::STATS.event(&e);
// 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"));
@@ -307,7 +308,9 @@ fn log_event(e: &Event, wire: bool) {
_ => (None, None),
};
let routine = match e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
// A scan's end: ipx_jobs_total, ipx_feed_fetches_total and ipx_last_scan_age_seconds hold it,
// and since scans became one or two feeds at a time it was a fifth of the log (#363).
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } | Event::ScanDone { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
@@ -326,7 +329,7 @@ fn log_event(e: &Event, wire: bool) {
)
};
}
// `ipx status`'s answer: a reply on the socket rather than work done, so it
// `ipxd 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}"));
@@ -353,7 +356,7 @@ pub async fn daemon_is_live(path: &Path) -> bool {
/// 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.
/// any `ipxd 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 = Arc<dyn Fn(Command) -> Pin<Box<dyn Future<Output = Event> + Send>> + Send + Sync>;

131
crates/ipx/src/lib.rs Normal file
View File

@@ -0,0 +1,131 @@
//! ipx, the daemon and its command line. The code is in the modules below; `main` starts it.
//! A library under the binary `ipxd` (src/main.rs) so that its modules, and so its log targets
//! (`ipx::scan`, `ipx::http`), the filters (`ipx=info`) and the dashboard's queries on them, kept
//! their names when the command was renamed (#437).
mod access;
mod art;
mod auth;
mod catalogue;
mod cli;
mod config;
mod ctx;
mod daemon;
mod db;
mod deadair;
mod directory;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
mod lists;
mod logbuf;
mod logging;
mod metrics;
mod play;
mod push;
mod retention;
mod scan;
mod signals;
#[cfg(test)]
mod test_util;
mod torrent;
mod web;
use crate::cli::{Cli, Command, UserCmd};
use crate::ctx::Ctx;
use crate::ipc::Command as Cmd;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::Instrument;
#[tokio::main]
pub async fn main() -> Result<()> {
let cli = Cli::parse();
// The daemon only: a trace for every `ipxd status` a script runs would bury the ones worth
// looking at.
let otel = match cli.command {
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None,
};
logging::init(otel.as_ref());
// For the spans the page reports of itself (#376), which ipx builds with the page's own ids.
if let Some(p) = &otel {
opentelemetry::global::set_tracer_provider(p.clone());
}
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?;
// The daemon holds its connections open; a one-off command opens what it uses.
let keep_open = matches!(cli.command, Command::Daemon { .. });
// The daemon's start-up is a trace (#374), from opening the database to serving; daemon()
// carries it on.
let start = if keep_open { tracing::info_span!("start") } else { tracing::Span::none() };
let db = async { db::Db::open(&db::location(), keep_open).instrument(db::step("open_database")).await }
.instrument(start.clone())
.await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
&& ipc::daemon_is_live(&cfg.general.socket).await
{
return ipc::proxy(&cfg.general.socket, cmd).await;
}
let socket = cfg.general.socket.clone();
let cfg =
async { catalogue::assemble_config(&db, cfg, &config_path).instrument(db::step("assemble_config")).await }
.instrument(start.clone())
.await?;
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
let result = match cli.command {
Command::List => catalogue::list(&ctx).await,
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events, start).await,
Command::Add { url, folder, keywords, category, list } => {
catalogue::add(&ctx, &url, folder, keywords, category, list).await
}
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
Command::Import { file } => catalogue::import(&ctx, &file).await,
Command::Export { file } => catalogue::export(&ctx, &file).await,
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
};
if result.is_ok()
&& changes_catalogue
&& ipc::daemon_is_live(&socket).await
&& let Err(e) = ipc::reload(&socket).await
{
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
}
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
}
result
}

View File

@@ -2,10 +2,11 @@
use crate::ctx::Ctx;
use crate::scan::Outcome;
use crate::{catalogue, config, feed};
use crate::{catalogue, config, db, feed};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tracing::Instrument;
/// The feeds an OPML document lists, up to as many as a subscribed list may bring in: each becomes
/// a feed in the catalogue, scanned for everyone, and one upload of tens of thousands held the
@@ -31,6 +32,7 @@ pub fn opml_feeds(doc: &opml::OPML) -> Result<Vec<(String, String)>> {
///
/// The caller parses the document and lists its feeds (`opml_feeds`), so each refuses a file in
/// its own terms, before anything is touched: a 400 from the web, a message from the CLI.
#[tracing::instrument(name = "opml_import", skip_all, fields(feeds = found.len(), user = user_id))]
pub async fn subscribe_opml(ctx: &Ctx, found: Vec<(String, String)>, user_id: i64) -> Result<(usize, usize)> {
let mut cfg = ctx.edit_cfg().await;
// By address, once: each feed looked through every subscription and the whole catalogue, and
@@ -110,6 +112,8 @@ pub(crate) const MAX_LISTED: usize = 2000;
/// the list is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it
/// is kept and flagged, because dropping it would orphan files on disk with nothing in
/// the UI to explain them.
/// A trace of its own steps (#372): one long `feed` span said nothing of where a slow sync went.
#[tracing::instrument(name = "list_sync", skip_all, fields(list = %parent_id, listed = listed.len()))]
pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(String, String)]) -> Result<Outcome> {
// Each is a feed scanned for everyone subscribed to the list, and the list is its author's to
// write: one of tens of thousands held the scan for hours (#337). davewiner's had 922.
@@ -119,74 +123,94 @@ pub(crate) async fn sync_group(ctx: &Arc<Ctx>, parent_id: &str, listed: &[(Strin
listed.len()
);
let cfg = ctx.cfg();
let existing = ctx.db.managed_feeds().await?;
let existing = ctx.db.managed_feeds().instrument(db::step("managed_feeds")).await?;
let mut added = vec![];
// Read once, not once a new feed: each read is the whole feeds table.
let known = ctx.db.feed_urls().await?;
let known = ctx.db.feed_urls().instrument(db::step("feed_urls")).await?;
let fixed: HashSet<&str> = cfg.feeds.keys().chain(existing.iter().map(|m| &m.id)).map(String::as_str).collect();
let known_ids: HashSet<&str> = known.iter().map(|(id, _)| id.as_str()).collect();
for (title, url) in listed {
// Already known, whether derived or promoted into the config.
if let Some(m) = existing.iter().find(|m| &m.url == url) {
ctx.db.upsert_managed(&m.id, url, title, parent_id).await?;
continue;
async {
for (title, url) in listed {
// Already known, whether derived or promoted into the config.
if let Some(m) = existing.iter().find(|m| &m.url == url) {
ctx.db.upsert_managed(&m.id, url, title, parent_id).await?;
continue;
}
// A Patreon show you added by hand may be spelled differently from the one listed.
if cfg.feeds.values().any(|f| feed::same_feed(&f.url, url)) {
continue;
}
// A removed feed keeps its rows, so its id is only free again for the same feed.
let its_own: Vec<&str> =
known.iter().filter(|(_, u)| feed::same_feed(u, url)).map(|(id, _)| id.as_str()).collect();
let id = config::unique_slug(title, |s| {
fixed.contains(s) || added.iter().any(|a| a == s) || (known_ids.contains(s) && !its_own.contains(&s))
});
ctx.db.upsert_managed(&id, url, title, parent_id).await?;
added.push(id);
}
// A Patreon show you added by hand may be spelled differently from the one listed.
if cfg.feeds.values().any(|f| feed::same_feed(&f.url, url)) {
continue;
}
// A removed feed keeps its rows, so its id is only free again for the same feed.
let its_own: Vec<&str> =
known.iter().filter(|(_, u)| feed::same_feed(u, url)).map(|(id, _)| id.as_str()).collect();
let id = config::unique_slug(title, |s| {
fixed.contains(s) || added.iter().any(|a| a == s) || (known_ids.contains(s) && !its_own.contains(&s))
});
ctx.db.upsert_managed(&id, url, title, parent_id).await?;
added.push(id);
anyhow::Ok(())
}
.instrument(db::step("upsert_listed"))
.await?;
// Whoever subscribes to the OPML subscribes to what it lists: that is what taking a
// subscription means. Their own feeds are untouched.
let managed = ctx.db.managed_feeds().await?;
let list_and_feeds = managed
.iter()
.filter(|m| m.group_id == parent_id)
.map(|m| m.id.clone())
.chain(std::iter::once(parent_id.to_string()));
for id in list_and_feeds {
// subscription means. Their own feeds are untouched. Who reads the list is asked once: asked
// for each listed feed, a list of 922 was thousands of the same queries a sync (#377).
async {
let mut readers = vec![];
for user in ctx.db.users().await? {
if ctx.db.subscription(user.id, parent_id).await?.is_some() {
ctx.db.subscribe(user.id, &id).await?;
readers.push(user.id);
}
}
let managed = ctx.db.managed_feeds().await?;
let list_and_feeds = managed
.iter()
.filter(|m| m.group_id == parent_id)
.map(|m| m.id.clone())
.chain(std::iter::once(parent_id.to_string()));
for id in list_and_feeds {
for &user in &readers {
ctx.db.subscribe(user, &id).await?;
}
}
anyhow::Ok(())
}
.instrument(db::step("subscribe_readers"))
.await?;
// Anything in this group the OPML no longer lists.
let mut removed = 0;
let mut kept = 0;
let parent = ctx.cfg().feeds.get(parent_id).cloned();
let in_group = existing.iter().filter(|m| m.group_id == parent_id);
for m in in_group {
if listed.iter().any(|(_, u)| u == &m.url) {
continue;
}
if catalogue::promote_kept(ctx, m, parent.as_ref()).await? {
kept += 1;
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
continue;
}
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
// Never orphan a downloaded file: keep the feed and say why in the UI.
ctx.db.set_orphaned(&m.id, true).await?;
kept += 1;
tracing::info!(feed = %m.id, "dropped from the OPML but has downloads; keeping it");
} else {
ctx.db.drop_managed(&m.id).await?;
removed += 1;
tracing::info!(feed = %m.id, "dropped from the OPML with nothing downloaded; removed");
async {
let in_group = existing.iter().filter(|m| m.group_id == parent_id);
for m in in_group {
if listed.iter().any(|(_, u)| u == &m.url) {
continue;
}
if catalogue::promote_kept(ctx, m, parent.as_ref()).await? {
kept += 1;
tracing::info!(feed = %m.id, "dropped from the OPML but someone kept it; a feed of its own now");
continue;
}
if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
// Never orphan a downloaded file: keep the feed and say why in the UI.
ctx.db.set_orphaned(&m.id, true).await?;
kept += 1;
tracing::info!(feed = %m.id, "dropped from the OPML but has downloads; keeping it");
} else {
ctx.db.drop_managed(&m.id).await?;
removed += 1;
tracing::info!(feed = %m.id, "dropped from the OPML with nothing downloaded; removed");
}
}
anyhow::Ok(())
}
.instrument(db::step("drop_unlisted"))
.await?;
Ok(Outcome::Opml { added, removed, kept, total: listed.len() })
}

View File

@@ -1,89 +1,120 @@
//! Log lines as JSON for Loki, carrying the trace and span they belong to.
use crate::logbuf;
use ipx_directory::otel::json_layer;
use std::io::IsTerminal;
use tracing::Subscriber;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::format::JsonFields;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
use tracing_subscriber::registry::LookupSpan;
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
/// the object it writes. A line outside any traced span, or with no trace exporter, is unchanged.
pub(crate) struct WithTrace<F>(F);
impl<S, N, F> FormatEvent<S, N> for WithTrace<F>
where
F: FormatEvent<S, N>,
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'w> FormatFields<'w> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut w: tracing_subscriber::fmt::format::Writer<'_>,
ev: &tracing::Event<'_>,
) -> std::fmt::Result {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let current = tracing::Span::current();
let sc = if current.is_none() { None } else { Some(current.context().span().span_context().clone()) };
let Some(sc) = sc.filter(|c| c.is_valid()) else {
return self.0.format_event(ctx, w, ev);
};
let mut line = String::new();
self.0.format_event(ctx, tracing_subscriber::fmt::format::Writer::new(&mut line), ev)?;
match line.trim_end().strip_suffix('}') {
Some(body) => writeln!(w, r#"{body},"trace_id":"{}","span_id":"{}"}}"#, sc.trace_id(), sc.span_id()),
None => w.write_str(&line),
}
}
}
/// Sets up logging: to stderr, as text or as JSON (`IPX_LOG_FORMAT`), into the ring the page's log
/// reads, and to the trace exporter when there is one.
pub(crate) fn init(otel: Option<&opentelemetry_sdk::trace::SdkTracerProvider>) {
use tracing_subscriber::util::SubscriberInitExt;
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
subscriber(json, otel, std::io::stderr).init();
}
/// The subscriber `init` installs, writing to `out`: the test's lines are production's.
fn subscriber<W>(
json: bool,
otel: Option<&opentelemetry_sdk::trace::SdkTracerProvider>,
out: W,
) -> impl tracing::Subscriber + Send + Sync
where
W: for<'w> tracing_subscriber::fmt::MakeWriter<'w> + Send + Sync + 'static,
{
// Everything goes to stderr as before, and is mirrored into a ring the UI can read.
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
// keeps debug as well, so the log view can show protocol traffic and routine
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
let stderr_filter = || EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into());
// One JSON object a line for Loki (#91), with each event's fields as its own; text
// otherwise, for someone reading a terminal.
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let ui_filter = EnvFilter::try_from_env("IPX_UI_LOG").unwrap_or_else(|_| "ipx=debug".into());
tracing_subscriber::registry()
.with((!json).then(|| {
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
// Colour for a terminal only: in docker logs and Loki the escapes are noise
// every query has to strip (#88).
.with_ansi(IsTerminal::is_terminal(&std::io::stderr()))
.with_filter(stderr_filter())
}))
.with(json.then(|| {
tracing_subscriber::fmt::layer()
.fmt_fields(JsonFields::new())
.event_format(WithTrace(
tracing_subscriber::fmt::format()
.json()
.flatten_event(true)
.with_current_span(true)
.with_span_list(false),
))
.with_writer(std::io::stderr)
.with_filter(stderr_filter())
}))
.with(logbuf::RingLayer.with_filter(ui_filter))
.with(otel.as_ref().map(|p| {
// Boxed rather than an Option of each: Option's Layer does not pass on_register_dispatch on,
// which the JSON layer finds each line's trace by (#408).
let stderr: Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
json_layer(out, true).with_filter(stderr_filter()).boxed()
} else {
tracing_subscriber::fmt::layer()
.with_writer(out)
// Colour for a terminal only: in docker logs and Loki the escapes are noise
// every query has to strip (#88).
.with_ansi(IsTerminal::is_terminal(&std::io::stderr()))
.with_filter(stderr_filter())
.boxed()
};
tracing_subscriber::registry().with(stderr).with(logbuf::RingLayer.with_filter(ui_filter)).with(otel.as_ref().map(
|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx"))
.with_filter(EnvFilter::new("ipx=info,ipx_feed=info"))
}))
.init();
},
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ipc::{Emitter, Event};
use std::sync::{Arc, Mutex};
/// What the layer wrote, kept for the test to read.
struct Lines(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Lines {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn a_scans_events_are_json_lines_with_the_fields_grafana_reads() {
let buf = Arc::new(Mutex::new(vec![]));
let b = buf.clone();
let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
let sub = subscriber(true, Some(&provider), move || Lines(b.clone()));
// Scoped, where the formatter once found no dispatcher and no line named its trace (#408).
tracing::subscriber::with_default(sub, || {
let out = Emitter::terminal();
out.emit(Event::FeedDone { feed: "log-test-f".into(), new: 2, downloaded: 1, failed: 0, torrents: 0 });
let scan = tracing::info_span!("scan");
let _in = scan.enter();
out.emit(Event::DownloadDone {
feed: "log-test-f".into(),
enclosure: 7,
url: "https://x.test/a.mp3".into(),
path: "/downloads/a.mp3".into(),
bytes: 1234,
});
out.emit(Event::FeedError { feed: "log-test-g".into(), msg: "HTTP 404 Not Found".into() });
});
let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
let lines: Vec<serde_json::Value> = text.lines().map(|l| serde_json::from_str(l).expect(l)).collect();
let find = |ev: &str| {
lines
.iter()
.find(|l| l["ev"] == ev && l["feed"].as_str().is_some_and(|f| f.starts_with("log-test-")))
.unwrap_or_else(|| panic!("no {ev} in {text}"))
};
let done = find("feed_done");
assert_eq!((done["feed"].as_str(), done["new"].as_u64()), (Some("log-test-f"), Some(2)), "{done}");
assert!(done.get("trace_id").is_none(), "outside a span there is no trace to name: {done}");
let saved = find("download_done");
assert_eq!(saved["bytes"].as_u64(), Some(1234), "{saved}");
let failed = find("feed_error");
assert_eq!((failed["feed"].as_str(), failed["msg"].as_str()), (Some("log-test-g"), Some("HTTP 404 Not Found")));
assert!(failed["error.type"].is_string(), "the failure's kind: {failed}");
for l in [saved, failed] {
let (t, s) = (l["trace_id"].as_str().unwrap_or(""), l["span_id"].as_str().unwrap_or(""));
assert!(t.len() == 32 && s.len() == 16, "inside a span a line names its trace: {l}");
}
}
}

View File

@@ -1,113 +1,5 @@
//! ipx, the daemon and its command line. The code is in the modules below; this only starts it.
//! `ipxd`, the daemon and its command line; everything is in the library (lib.rs).
mod access;
mod art;
mod auth;
mod catalogue;
mod cli;
mod config;
mod ctx;
mod daemon;
mod db;
mod deadair;
mod directory;
mod download;
mod entity;
// The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc;
mod lists;
mod logbuf;
mod logging;
mod metrics;
mod retention;
mod scan;
mod signals;
#[cfg(test)]
mod test_util;
mod torrent;
mod web;
use crate::cli::{Cli, Command, UserCmd};
use crate::ctx::Ctx;
use crate::ipc::Command as Cmd;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::broadcast;
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
// looking at.
let otel = match cli.command {
Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None,
};
logging::init(otel.as_ref());
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?;
// The daemon holds its connections open; a one-off command opens what it uses.
let keep_open = matches!(cli.command, Command::Daemon { .. });
let db = db::Db::open(&db::location(), keep_open).await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] }),
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
&& ipc::daemon_is_live(&cfg.general.socket).await
{
return ipc::proxy(&cfg.general.socket, cmd).await;
}
let socket = cfg.general.socket.clone();
let cfg = catalogue::assemble_config(&db, cfg, &config_path).await?;
let is_daemon = matches!(cli.command, Command::Daemon { .. });
// These write the catalogue from a process of their own, which a running daemon, holding its
// own copy, would write over at its next change (#120).
let changes_catalogue = matches!(
cli.command,
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
);
let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx::new(cfg, db, config_path.clone(), is_daemon, &events)?);
let result = match cli.command {
Command::List => catalogue::list(&ctx).await,
Command::Daemon { web } => daemon::daemon(ctx, config_path, web, events).await,
Command::Add { url, folder, keywords, category, list } => {
catalogue::add(&ctx, &url, folder, keywords, category, list).await
}
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
Command::User { cmd } => cli::user_cmd(&ctx, cmd).await,
Command::Import { file } => catalogue::import(&ctx, &file).await,
Command::Export { file } => catalogue::export(&ctx, &file).await,
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
};
if result.is_ok()
&& changes_catalogue
&& ipc::daemon_is_live(&socket).await
&& let Err(e) = ipc::reload(&socket).await
{
eprintln!("the running daemon did not take the change ({e:#}); restart it, or it may undo it");
}
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
}
result
fn main() -> anyhow::Result<()> {
ipx::main()
}

View File

@@ -7,6 +7,7 @@
//! the text format is a few lines, and a client library would be a dependency for them.
use axum::Router;
use ipx_directory::metrics::{Histograms, SECONDS, label, method};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::net::SocketAddr;
@@ -62,8 +63,23 @@ pub struct Stats {
job: std::sync::Mutex<Option<(&'static str, Instant)>>,
/// Jobs finished, by kind: how many, and their seconds all told.
jobs: std::sync::Mutex<BTreeMap<&'static str, (u64, f64)>>,
/// Feed failures by kind, `error.type` (#368).
feed_failures: std::sync::Mutex<BTreeMap<String, u64>>,
pub new_items: AtomicU64,
pub downloads_done: AtomicU64,
pub downloads_failed: AtomicU64,
pub deadair_saved_seconds: AtomicU64,
}
/// Requests answered, by method, route and status (#364).
pub static REQUESTS: Histograms = Histograms::new(SECONDS);
/// Each statement's time once it has a connection, by its first word (#367).
pub static QUERIES: Histograms = Histograms::new(SECONDS);
/// The wait for a connection, as hand-written SQL in db.rs sees it (#367).
pub static DB_WAIT: Histograms = Histograms::new(SECONDS);
/// The page's timings, by `ev`: a click to its list drawn, play to sound, a stall (#369).
pub static PAGE: Histograms = Histograms::new(&[0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0]);
impl Stats {
pub const fn new() -> Self {
Self {
@@ -89,6 +105,46 @@ impl Stats {
people: std::sync::Mutex::new(BTreeMap::new()),
job: std::sync::Mutex::new(None),
jobs: std::sync::Mutex::new(BTreeMap::new()),
feed_failures: std::sync::Mutex::new(BTreeMap::new()),
new_items: AtomicU64::new(0),
downloads_done: AtomicU64::new(0),
downloads_failed: AtomicU64::new(0),
deadair_saved_seconds: AtomicU64::new(0),
}
}
/// Counts a request answered. `route` is the pattern matched, "" for none, so a scanner's
/// made-up paths are one row.
pub fn request(&self, m: &str, route: &str, status: u16, secs: f64) {
REQUESTS.observe(&format!("method=\"{}\",route=\"{}\",status=\"{status}\"", method(m), label(route)), secs);
}
/// Counts what an event says happened (#368): one place, as every event passes `emit`.
pub fn event(&self, e: &crate::ipc::Event) {
use crate::ipc::Event;
match e {
Event::FeedDone { new, .. } => {
self.new_items.fetch_add(*new as u64, Relaxed);
}
Event::FeedError { msg, .. } => {
let kind = crate::feed::failure_kind(msg).0;
*self
.feed_failures
.lock()
.expect("a panic elsewhere poisoned this lock")
.entry(kind.to_string())
.or_default() += 1;
}
Event::DownloadDone { .. } => {
self.downloads_done.fetch_add(1, Relaxed);
}
Event::DownloadError { .. } => {
self.downloads_failed.fetch_add(1, Relaxed);
}
Event::DeadairDone { saved_s, .. } => {
self.deadair_saved_seconds.fetch_add((*saved_s).max(0) as u64, Relaxed);
}
_ => {}
}
}
@@ -102,6 +158,12 @@ impl Stats {
}
}
/// What `who` has been billed on `key`: items judged, Signals fit, input and output tokens.
#[cfg(test)]
pub fn billed(&self, who: &str, key: &'static str) -> [f64; 4] {
self.people.lock().unwrap().get(&(who.to_owned(), key)).copied().unwrap_or_default()
}
/// 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 {
@@ -308,6 +370,42 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
"Web requests being answered.",
&[("", st.http_in_flight.load(Relaxed) as f64)],
);
REQUESTS.render(
out,
"ipx_http_request_duration_seconds",
"Web requests answered and the seconds each took, by method, route and status.",
);
QUERIES.render(out, "ipx_db_query_seconds", "Statements' time once they had a connection, by their first word.");
DB_WAIT.render(out, "ipx_db_wait_seconds", "The wait for a database connection, as hand-written SQL saw it.");
PAGE.render(
out,
"ipx_page_seconds",
"What the page timed: a click to its list drawn, play to sound, a stall's wait.",
);
let failures = st.feed_failures.lock().expect("a panic elsewhere poisoned this lock").clone();
let labels: Vec<String> = failures.keys().map(|k| format!("error_type=\"{}\"", label(k))).collect();
counter(
out,
"ipx_feed_failures_total",
"Feeds that failed to fetch or parse, by kind: an HTTP status, dns, timeout, tls, parse...",
&labels.iter().zip(failures.values()).map(|(l, v)| (l.as_str(), *v as f64)).collect::<Vec<_>>(),
);
counter(out, "ipx_new_items_total", "New items scans found.", &[("", st.new_items.load(Relaxed) as f64)]);
counter(
out,
"ipx_downloads_total",
"Downloads finished, saved or failed.",
&[
("result=\"saved\"", st.downloads_done.load(Relaxed) as f64),
("result=\"failed\"", st.downloads_failed.load(Relaxed) as f64),
],
);
counter(
out,
"ipx_deadair_saved_seconds_total",
"Seconds of silence DeadAir found in the files it looked at.",
&[("", st.deadair_saved_seconds.load(Relaxed) as f64)],
);
gauge(
out,
"ipx_scan_feeds_fetching",
@@ -429,11 +527,6 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
}
}
/// 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);
@@ -457,6 +550,54 @@ fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str,
mod tests {
use super::*;
/// The names the dashboard and the alert rules query, read out of what generates them.
fn queried() -> std::collections::BTreeSet<String> {
let src = [include_str!("../../../grafana/dashboard.py"), include_str!("../../../grafana/alerts.py")].concat();
src.match_indices("ipx_")
.filter(|(i, _)| !src[..*i].ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
.map(|(i, _)| {
let rest = &src[i..];
let end = rest.find(|c: char| !(c.is_ascii_lowercase() || c == '_')).unwrap_or(rest.len());
(&rest[..end], rest[end..].starts_with("::"))
})
// A log target (ipx_directory::http) is not a metric; the coverage numbers are
// node-exporter's, written by scripts/coverage-metrics.ts.
.filter(|(n, target)| !target && !n.starts_with("ipx_coverage_"))
.map(|(n, _)| n.to_string())
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn every_metric_grafana_queries_is_served() {
let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await);
let s = Sources {
ctx,
cmds: tokio::sync::mpsc::channel(1).0,
events: tokio::sync::broadcast::channel(1).0,
playing: Default::default(),
runtime: Handle::current(),
started: Instant::now(),
};
// Written only once known (a count taken, a scan finished, a job under way): know them.
for v in [&STATS.feeds, &STATS.pending, &STATS.downloaded, &STATS.last_scan_done, &STATS.signals_last_pass] {
v.fetch_max(1, Relaxed);
}
let _job = STATS.job("scan");
let out = render(&s);
let served: std::collections::BTreeSet<&str> =
out.lines().filter_map(|l| l.strip_prefix("# TYPE ")).filter_map(|l| l.split(' ').next()).collect();
let names = queried();
assert!(names.len() > 30, "the dashboard's names were not found: {names:?}");
let missing: Vec<&String> = names
.iter()
.filter(|n| {
let base = ["_bucket", "_count", "_sum"].iter().find_map(|x| n.strip_suffix(x)).unwrap_or(n);
!served.contains(n.as_str()) && !served.contains(base)
})
.collect();
assert!(missing.is_empty(), "queried by grafana/ but not served by /metrics: {missing:?}");
}
#[test]
fn gauges_are_written_as_prometheus_reads_them() {
let mut out = String::new();
@@ -512,6 +653,28 @@ mod tests {
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 "));
for secs in [0.03, 0.04, 2.0] {
T.request("GET", "/api/feeds", 200, secs);
}
T.request("PROPFIND", "", 404, 0.001);
T.event(&crate::ipc::Event::FeedError { feed: "f".into(), msg: "connecting: timed out".into() });
T.event(&crate::ipc::Event::FeedDone { feed: "f".into(), new: 3, downloaded: 0, failed: 0, torrents: 0 });
let mut out = String::new();
work(&mut out, &T, 0, 0, 0);
let l = "method=\"GET\",route=\"/api/feeds\",status=\"200\"";
for line in [
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"0.025\"}} 0\n"),
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"0.05\"}} 2\n"),
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"2.5\"}} 3\n"),
format!("ipx_http_request_duration_seconds_bucket{{{l},le=\"+Inf\"}} 3\n"),
format!("ipx_http_request_duration_seconds_count{{{l}}} 3\n"),
"ipx_http_request_duration_seconds_count{method=\"other\",route=\"\",status=\"404\"} 1\n".into(),
"ipx_new_items_total 3\n".into(),
"# TYPE ipx_feed_failures_total counter\n".into(),
] {
assert!(out.contains(&line), "{line} in {out}");
}
}
#[test]

121
crates/ipx/src/play.rs Normal file
View File

@@ -0,0 +1,121 @@
//! Sharing an episode as a page (#447): ipx-play keeps the episode and hands back a short link,
//! `play.ipxreader.app/e/<id>`, which anyone can open, as Overcast's links are. Only an episode of
//! a public feed goes: the same rule as what the directory is sent, so a private or paid feed's
//! file address, which carries someone's access, never leaves this server.
use crate::ctx::Ctx;
use std::collections::HashMap;
use std::time::Duration;
/// Why there is no link, which the page answers by sharing the episode's own address as it did
/// before.
#[derive(Debug)]
pub enum NoLink {
/// No ipx-play address or token in Admin, Sharing.
NotSetUp,
/// A private or paid feed, or one in a private OPML.
Private,
/// Nothing to play: a post, or a feed's picture.
NoFile,
NotFound,
/// ipx-play did not answer, or refused.
Failed(String),
}
fn target(ctx: &Ctx) -> Option<(String, String)> {
let cfg = ctx.cfg();
let url = cfg.general.play_url.trim().trim_end_matches('/');
let token = cfg.general.play_token.trim();
(!url.is_empty() && !token.is_empty()).then(|| (url.to_owned(), token.to_owned()))
}
/// The file to share: the one asked for, or the first that is sound, since a page plays audio.
fn pick(encs: &[crate::db::EncRow], asked: Option<i64>) -> Option<&crate::db::EncRow> {
match asked {
Some(id) => encs.iter().find(|e| e.id == id),
None => encs.iter().find(|e| e.mime.as_deref().is_some_and(|m| m.starts_with("audio/"))),
}
}
#[tracing::instrument(name = "share_episode", skip(ctx))]
pub async fn share(
ctx: &Ctx,
user_id: i64,
feed_id: &str,
guid: &str,
enc: Option<i64>,
) -> Result<ipx_play::Shared, NoLink> {
let (base, token) = target(ctx).ok_or(NoLink::NotSetUp)?;
let failed = |e: anyhow::Error| NoLink::Failed(format!("{e:#}"));
let row = ctx.db.entry(user_id, feed_id, guid).await.map_err(failed)?.ok_or(NoLink::NotFound)?;
// Public by the directory's own rule, so the two cannot disagree on what is private.
let catalogue = crate::catalogue::subscriptions(ctx).await.map_err(failed)?;
let sub = catalogue.iter().find(|s| s.id == feed_id).ok_or(NoLink::NotFound)?;
let public = crate::directory::snapshot_entries(&catalogue, &HashMap::new());
if !public.iter().any(|p| p.url == sub.cfg.url) {
return Err(NoLink::Private);
}
let file = pick(&row.enclosures, enc).ok_or(NoLink::NoFile)?;
let feed = ctx.db.feed_summary(feed_id).await.map_err(failed)?;
let episode = ipx_play::Episode {
feed_url: sub.cfg.url.clone(),
guid: guid.to_owned(),
feed_title: feed.title,
feed_image: row.image.clone().or(feed.image),
title: row.title.clone(),
// As the feed sent it: ipx-play sanitizes it for its page.
description: row.description.clone(),
link: row.link.clone(),
published: row.published,
duration: row.duration,
audio_url: file.url.clone(),
mime: file.mime.clone(),
};
let mut req = ctx
.local_client
.post(format!("{base}/v1/shares"))
.bearer_auth(token)
.timeout(Duration::from_secs(15))
.json(&episode);
// Its spans join this request's trace.
if let Some(tp) = ipx_directory::otel::traceparent() {
req = req.header("traceparent", tp);
}
let resp = req.send().await.map_err(|e| NoLink::Failed(format!("reaching ipx-play: {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));
return Err(NoLink::Failed(format!("ipx-play answered {status}: {}", msg.unwrap_or_default())));
}
resp.json().await.map_err(|e| NoLink::Failed(format!("ipx-play's answer did not read: {e:#}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn enc(id: i64, mime: Option<&str>) -> crate::db::EncRow {
crate::db::EncRow {
id,
deadair: None,
deadair_pending: false,
feed_id: "f".into(),
guid: "g".into(),
url: format!("https://x/{id}"),
mime: mime.map(Into::into),
length: None,
path: None,
state: "done".into(),
last_error: None,
}
}
#[test]
fn the_file_shared_is_the_one_asked_for_or_the_first_sound() {
let encs = [enc(1, Some("image/jpeg")), enc(2, Some("video/mp4")), enc(3, Some("audio/mpeg"))];
assert_eq!(pick(&encs, None).map(|e| e.id), Some(3));
assert_eq!(pick(&encs, Some(2)).map(|e| e.id), Some(2));
assert!(pick(&encs, Some(9)).is_none());
assert!(pick(&encs[..2], None).is_none(), "a picture and a video are not a page's to play");
}
}

322
crates/ipx/src/push.rs Normal file
View File

@@ -0,0 +1,322 @@
//! The iOS app's icon count, through Apple's push service (#433).
//!
//! The app shows All Subscriptions' unread count on its icon. While it is open the page keeps it
//! right; while it is closed only iOS's background refresh could, which runs when iOS chooses and
//! for this app had been rarely. So ipx sends it: once a minute, to each phone whose person's count
//! has changed since the last one sent, a push carrying the badge and nothing else, which shows no
//! alert and makes no sound.
//!
//! The app registers its phone through the page (`POST /api/push/devices`), signed in as the page
//! is: the address the app's player uses lets through only the player's own paths.
use crate::ctx::Ctx;
use crate::entity::push_devices;
use anyhow::{Context, Result};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::Instrument;
/// How often counts are compared. A minute behind is near enough for a number on an icon.
// ponytail: one count query per person with a phone, every minute; fine for a household, move to
// sending from where read state and new items are written if the people with phones grow many.
const EVERY: Duration = Duration::from_secs(60);
/// Apple refuses a signing token older than an hour, and one made more often than every twenty
/// minutes; so each is used for forty.
const TOKEN_LIFE: Duration = Duration::from_secs(40 * 60);
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
let mut signer = Signer::default();
loop {
if ctx.cfg().push.is_set() {
let pass = pass(&ctx, &mut signer, None).instrument(tracing::info_span!("push"));
tokio::select! {
_ = stop.changed() => return,
done = pass => if let Err(e) = done {
tracing::warn!(error = %format!("{e:#}"), "push: the icon counts could not be sent");
},
}
}
tokio::select! {
_ = stop.changed() => return,
_ = tokio::time::sleep(EVERY) => {}
}
}
}
/// Sends each phone its person's count, if it changed. `apns` stands in for Apple's address in tests.
async fn pass(ctx: &Ctx, signer: &mut Signer, apns: Option<&str>) -> Result<()> {
let devices = ctx.db.push_devices().instrument(crate::db::step("push_devices")).await?;
let mut counts: HashMap<i64, i64> = HashMap::new();
for d in devices {
let n = match counts.get(&d.user_id) {
Some(n) => *n,
None => {
let n = ctx
.db
.count_of(d.user_id, None, crate::db::Filter::Unread, None)
.instrument(crate::db::step("unread_in_all"))
.await?;
counts.insert(d.user_id, n);
n
}
};
if d.badge == Some(n) {
continue;
}
match send(ctx, signer, &d, n, apns).await? {
Sent::Yes => ctx.db.set_push_badge(&d.token, n).await?,
Sent::Gone => {
tracing::info!(user = d.user_id, "push: a phone's app is gone, so its token is forgotten");
ctx.db.forget_push_device(&d.token).await?;
}
// Tried again next minute: the badge kept is still the old one.
Sent::NotNow => {}
}
}
Ok(())
}
enum Sent {
Yes,
/// Apple says the token is not the app's: uninstalled, or for another app.
Gone,
NotNow,
}
#[tracing::instrument(name = "push.send", skip_all, fields(user = d.user_id, badge = n, status))]
async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64, apns: Option<&str>) -> Result<Sent> {
let cfg = ctx.cfg();
let host = if d.sandbox { "https://api.sandbox.push.apple.com" } else { "https://api.push.apple.com" };
let r = ctx
.client
.post(format!("{}/3/device/{}", apns.unwrap_or(host), d.token))
.bearer_auth(signer.token(&cfg.push)?)
.header("apns-topic", &cfg.push.topic)
// A badge alone is still an alert to Apple: background pushes cannot set one.
.header("apns-push-type", "alert")
// Not urgent, so iOS may hold it to save the battery.
.header("apns-priority", "5")
// content-available wakes the app for a moment, which the badge alone does not, so its
// widget's count follows the icon's (#428). iOS rations those wakes; the badge is set
// whether or not one comes.
.json(&serde_json::json!({ "aps": { "badge": n, "content-available": 1 } }))
.send()
.await
.context("reaching Apple's push service")?;
let status = r.status().as_u16();
tracing::Span::current().record("status", status);
if status == 200 {
return Ok(Sent::Yes);
}
let reason = r.json::<serde_json::Value>().await.ok().and_then(|b| b["reason"].as_str().map(str::to_owned));
let reason = reason.unwrap_or_default();
Ok(match (status, reason.as_str()) {
(410, _) | (400, "BadDeviceToken" | "DeviceTokenNotForTopic") => Sent::Gone,
(403, "ExpiredProviderToken" | "InvalidProviderToken") => {
signer.forget();
tracing::warn!(status, reason, "push: Apple refused the signing token; check [push] in config.toml");
Sent::NotNow
}
_ => {
tracing::warn!(status, reason, "push: Apple did not take an icon count");
Sent::NotNow
}
})
}
/// The token each request is signed with: the `.p8` key's ES256 signature over the team and the
/// time, made again as Apple's limits ask.
#[derive(Default)]
struct Signer {
key: Option<EncodingKey>,
made: Option<(Instant, String)>,
}
impl Signer {
fn token(&mut self, cfg: &crate::config::Push) -> Result<String> {
if let Some((at, t)) = &self.made
&& at.elapsed() < TOKEN_LIFE
{
return Ok(t.clone());
}
if self.key.is_none() {
let pem = std::fs::read(&cfg.key_file)
.with_context(|| format!("reading the APNs key {}", cfg.key_file.display()))?;
self.key = Some(p8_key(&pem)?);
}
let token = sign(self.key.as_ref().context("loaded above")?, &cfg.key_id, &cfg.team_id, crate::db::now())?;
self.made = Some((Instant::now(), token.clone()));
Ok(token)
}
/// Made again, key and all, on the next send: the key file or its id may have changed.
fn forget(&mut self) {
*self = Signer::default();
}
}
/// The key in a `.p8` file: PKCS#8 in PEM, which this build of jsonwebtoken does not read, so
/// the base64 between its two lines is decoded here rather than a crate added for one file.
fn p8_key(pem: &[u8]) -> Result<EncodingKey> {
let text = std::str::from_utf8(pem).context("the APNs key is not a .p8 key")?;
let body: String = text.lines().filter(|l| !l.starts_with("-----")).collect::<String>();
let der = base64_decode(body.trim()).context("the APNs key is not a .p8 key")?;
Ok(EncodingKey::from_ec_der(&der))
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let value = |c: u8| match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
};
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0);
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
acc = (acc << 6) | u32::from(value(c)?);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
fn sign(key: &EncodingKey, key_id: &str, team_id: &str, now: i64) -> Result<String> {
let mut h = Header::new(Algorithm::ES256);
h.kid = Some(key_id.to_owned());
Ok(jsonwebtoken::encode(&h, &serde_json::json!({ "iss": team_id, "iat": now }), key)?)
}
/// A device token as the app sends it: Apple's bytes in hex. Checked before it goes into a URL.
pub fn valid_token(t: &str) -> bool {
(64..=200).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{DecodingKey, Validation};
// A P-256 key made for this test alone, in the PKCS#8 form Apple's .p8 files are.
const KEY: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9x/aoouMrdbCsEAr
hLPm+4G3w8qNIHc9V7CMphVZL6WhRANCAATTwL52tAoDzAH+RcFS4cd9azD7xvGT
MnDzi5bfjGbabd5hc7sDHiTremhTWJzPs1TdGgtqd9RDURqCHr8jfP+B
-----END PRIVATE KEY-----";
// Its public half, as the point a JWK names.
const PUB_X: &str = "08C-drQKA8wB_kXBUuHHfWsw-8bxkzJw84uW34xm2m0";
const PUB_Y: &str = "3mFzuwMeJOt6aFNYnM-zVN0aC2p31ENRGoIevyN8_4E";
#[test]
fn the_signing_token_is_what_apple_reads() {
let key = p8_key(KEY.as_bytes()).unwrap();
let t = sign(&key, "ABC123DEFG", "8743L8D729", 1_800_000_000).unwrap();
let h = jsonwebtoken::decode_header(&t).unwrap();
assert_eq!(h.alg, Algorithm::ES256);
assert_eq!(h.kid.as_deref(), Some("ABC123DEFG"));
let mut v = Validation::new(Algorithm::ES256);
v.validate_exp = false;
v.required_spec_claims.clear();
let c =
jsonwebtoken::decode::<serde_json::Value>(&t, &DecodingKey::from_ec_components(PUB_X, PUB_Y).unwrap(), &v)
.unwrap()
.claims;
assert_eq!(c, serde_json::json!({ "iss": "8743L8D729", "iat": 1_800_000_000 }));
}
#[test]
fn base64_as_pem_has_it() {
assert_eq!(base64_decode("aXB4").unwrap(), b"ipx");
assert_eq!(base64_decode("aXB4IQ==").unwrap(), b"ipx!");
assert!(base64_decode("a!b").is_none());
}
#[test]
fn only_hex_of_a_tokens_length_is_a_device() {
assert!(valid_token(&"ab12".repeat(16)));
assert!(!valid_token("ab12"));
assert!(!valid_token(&format!("{}/../x", "a".repeat(64))));
assert!(!valid_token(&"g".repeat(64)));
}
/// A stand-in for Apple's push service: each device token's first two characters say how it
/// answers. Every request is recorded as (token, badge, signed).
async fn apns() -> (String, Arc<std::sync::Mutex<Vec<(String, i64, bool)>>>) {
use axum::http::{HeaderMap, StatusCode};
let seen: Arc<std::sync::Mutex<Vec<(String, i64, bool)>>> = Default::default();
let log = seen.clone();
let app = axum::Router::new().route(
"/3/device/{token}",
axum::routing::post(
move |axum::extract::Path(token): axum::extract::Path<String>,
headers: HeaderMap,
axum::Json(body): axum::Json<serde_json::Value>| {
let signed = headers.get("authorization").is_some_and(|v| v.as_bytes().starts_with(b"Bearer e"))
&& headers.get("apns-topic").is_some_and(|v| v == "net.sdf1.ipx");
log.lock().unwrap().push((token.clone(), body["aps"]["badge"].as_i64().unwrap(), signed));
let (status, reason) = match &token[..2] {
"aa" => (StatusCode::OK, ""),
"bb" => (StatusCode::GONE, "Unregistered"),
"cc" => (StatusCode::BAD_REQUEST, "BadDeviceToken"),
"dd" => (StatusCode::FORBIDDEN, "ExpiredProviderToken"),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError"),
};
async move { (status, axum::Json(serde_json::json!({ "reason": reason }))) }
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
(format!("http://{at}"), seen)
}
#[tokio::test]
async fn a_pass_sends_changed_counts_and_forgets_phones_apple_says_are_gone() {
let key = std::env::temp_dir().join(format!("ipx-push-test-{}.p8", std::process::id()));
std::fs::write(&key, KEY).unwrap();
let cfg = crate::config::Config {
push: crate::config::Push {
key_file: key.clone(),
key_id: "ABC123DEFG".into(),
team_id: "8743L8D729".into(),
..Default::default()
},
..Default::default()
};
let ctx = crate::test_util::test_ctx(cfg).await;
let me = ctx.db.create_user("me", None, false).await.unwrap();
for t in ["aa", "bb", "cc", "dd", "ee"] {
ctx.db.add_push_device(me, &t.repeat(32), false).await.unwrap();
}
let (at, seen) = apns().await;
let mut signer = Signer::default();
pass(&ctx, &mut signer, Some(&at)).await.unwrap();
let first = seen.lock().unwrap().clone();
assert_eq!(first.len(), 5, "every phone gets its first count");
assert!(first.iter().all(|(_, badge, signed)| *badge == 0 && *signed), "{first:?}");
let left: std::collections::BTreeMap<String, Option<i64>> =
ctx.db.push_devices().await.unwrap().into_iter().map(|d| (d.token[..2].to_owned(), d.badge)).collect();
assert_eq!(
left,
[("aa".into(), Some(0)), ("dd".into(), None), ("ee".into(), None)].into(),
"a 410 or a bad token forgets the phone; a refusal keeps the old badge to try again"
);
seen.lock().unwrap().clear();
pass(&ctx, &mut signer, Some(&at)).await.unwrap();
let mut again: Vec<String> = seen.lock().unwrap().iter().map(|(t, ..)| t[..2].to_owned()).collect();
again.sort();
assert_eq!(again, ["dd", "ee"], "a count Apple took is not sent again until it changes");
let _ = std::fs::remove_file(key);
}
}

View File

@@ -267,6 +267,142 @@ mod tests {
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
}
/// A scratch downloads folder of its own, so tests running at once never share files.
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ipx-reap-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn file(dir: &Path, name: &str, len: usize) -> String {
let p = dir.join(name);
std::fs::write(&p, vec![0u8; len]).unwrap();
p.display().to_string()
}
async fn state(db: &Db, id: i64) -> (String, Option<String>) {
let s = db
.strings_for_test(&format!("SELECT state || '|' || coalesce(path, '') FROM enclosures WHERE id = {id}"))
.await;
let (state, path) = s[0].split_once('|').unwrap();
(state.into(), Some(path.to_string()).filter(|p| !p.is_empty()))
}
#[tokio::test]
async fn the_age_pass_deletes_old_files_and_a_dry_run_only_counts_them() {
let dir = scratch("age");
let (old, new) = (file(&dir, "old.mp3", 100), file(&dir, "new.mp3", 200));
let (day, now) = (86_400, db::now());
let db = Db::memory().await.unwrap();
db.exec_for_test(&format!(
"INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'old', {old_seen}), ('f', 'new', {now}), ('f', 'stale', {old_seen}), ('f', 'held', {old_seen});
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'old', 'u1', '{old}', 100, 'done', {old_at}),
(2, 'f', 'new', 'u2', '{new}', 200, 'done', {new_at}),
(3, 'f', 'held', 'u3', NULL, 0, 'held', {old_at});",
old_seen = now - 40 * day,
old_at = now - 10 * day,
new_at = now - day,
))
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = dir.clone();
cfg.general.max_age_days = 7;
let dry = run(&cfg, &db, true).await.unwrap();
assert_eq!(dry.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1], "a held item has no file to take");
assert_eq!((dry.bytes_freed, dry.entries_pruned), (100, 0), "counted, and nothing pruned");
assert!(Path::new(&old).exists(), "a dry run deletes nothing");
assert_eq!(state(&db, 1).await.0, "done");
let r = run(&cfg, &db, false).await.unwrap();
assert_eq!(r.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
assert_eq!(r.bytes_freed, 100);
assert!(!Path::new(&old).exists() && Path::new(&new).exists(), "only the old file goes");
assert_eq!(state(&db, 1).await, ("reaped".into(), None), "the row stays, so it is not fetched again");
assert_eq!(state(&db, 2).await, ("done".into(), Some(new)));
assert_eq!(state(&db, 3).await.0, "held");
// The reaped one's entry and the one that never had a file; not held, whose row has no
// path, nor new.
assert_eq!(r.entries_pruned, 3, "old, stale and held are past the cutoff and have no file");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn over_quota_the_oldest_go_and_the_age_pass_is_not_counted_twice() {
let dir = scratch("quota");
let paths: Vec<String> = (1..=4).map(|i| file(&dir, &format!("{i}.mp3"), 10 * i)).collect();
let (day, now) = (86_400, db::now());
let db = Db::memory().await.unwrap();
// Recorded sizes, not the files' own: a dry run counts these. 1 is past the age limit.
db.exec_for_test(&format!(
"INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'a', 'u1', '{}', 2000000, 'done', {}),
(2, 'f', 'b', 'u2', '{}', 1000000, 'done', {}),
(3, 'f', 'c', 'u3', '{}', 1000000, 'done', {}),
(4, 'f', 'd', 'u4', '{}', 1000000, 'done', {});",
paths[0],
now - 40 * day,
paths[1],
now - 5 * day,
paths[2],
now - 3 * day,
paths[3],
now - day,
))
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = dir.clone();
cfg.general.max_age_days = 30;
// 53.7 MB less the 50 MB pad leaves about 1.26 MB. Without 1, the other three are 3 MB:
// two must go. Counting 1 again would make it 5 MB and take all three.
cfg.general.max_total_gb = 0.05;
let dry = run(&cfg, &db, true).await.unwrap();
assert_eq!(dry.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
assert_eq!(dry.over_quota.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2, 3], "oldest first");
assert_eq!(dry.bytes_freed, 4_000_000);
assert!(paths.iter().all(|p| Path::new(p).exists()), "a dry run deletes nothing");
let r = run(&cfg, &db, false).await.unwrap();
assert_eq!(r.over_quota.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2, 3]);
assert_eq!(r.bytes_freed, 10 + 20 + 30, "what was on disk, not what was recorded");
assert_eq!(paths.iter().map(|p| Path::new(p).exists()).collect::<Vec<_>>(), [false, false, false, true]);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_file_already_gone_is_reaped_and_one_that_will_not_delete_is_left() {
let dir = scratch("stuck");
let now = db::now();
let db = Db::memory().await.unwrap();
// procfs refuses an unlink even from root, and the file is there, so it is a candidate.
db.exec_for_test(&format!(
"INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'gone', 'u1', '{}', 100, 'done', 1),
(2, 'f', 'stuck', 'u2', '/proc/version', 100, 'done', 1);",
dir.join("gone.mp3").display(),
))
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = dir.clone();
cfg.general.max_age_days = 1;
assert!(now > 86_400);
let r = run(&cfg, &db, false).await.unwrap();
assert_eq!(r.reconciled, 1, "the missing file is noticed");
assert_eq!(state(&db, 1).await, ("reaped".into(), None));
assert_eq!(r.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2]);
assert_eq!(r.bytes_freed, 0, "nothing was freed");
assert_eq!(state(&db, 2).await, ("done".into(), Some("/proc/version".into())), "still claims its file");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();

View File

@@ -171,7 +171,13 @@ pub(crate) async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope
pub fn due_at(cfg: &config::Config, feed: &config::Feed, state: &db::HttpState, subscribed: bool) -> Option<i64> {
let last = state.last_checked?;
let floor = if subscribed { 0 } else { 86_400 };
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, state.same_since, last).max(floor) as i64)
let wait = due_after(cfg, feed, state.ttl_mins, state.error_since, state.same_since, last).max(floor);
// YouTube fails about 70% of reads from Tower, in streaks longer than fetch's retries; read
// once a day, its 39 catalogue channels missed 5 days in 7 (#386). Hourly for the first day of
// a run of failures finds a moment it answers, and a channel gone for good costs 24 requests.
let youtube_again =
state.error_since.is_some_and(|since| last - since < 86_400) && feed::is_youtube_feed(&feed.url);
Some(last + if youtube_again { wait.min(3600) } else { wait } as i64)
}
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
@@ -711,6 +717,9 @@ pub(crate) async fn fetch_one(
let mut last_pct = -1i64;
let name = download::filename_for(url, None);
let cfg = ctx.cfg();
// Said while it runs, as a torrent's is: a stop mid-file leaves it 'downloading', which the
// next start takes up at once (#410). Every way out records 'done' or 'error'.
ctx.db.mark_enclosure(url, "downloading", None).await?;
let got = download::download(&ctx.client, &cfg, feed_cfg, url, |done, total| {
if let Some(t) = total.filter(|t| *t > 0) {
let pct = (done * 100 / t) as i64;
@@ -907,6 +916,20 @@ mod tests {
assert_eq!(at(Some(1000), false), Some(1000 + 86_400)); // listed, nobody subscribed
}
#[test]
fn a_failing_youtube_feed_is_asked_again_hourly_for_a_day() {
let cfg = config::Config::default();
let yt = config::Feed { url: "https://www.youtube.com/feeds/videos.xml?channel_id=UCx".into(), ..feed() };
let at = |f: &config::Feed, since: Option<i64>, last: i64| {
let state = db::HttpState { last_checked: Some(last), error_since: since, ..Default::default() };
due_at(&cfg, f, &state, false).map(|t| t - last)
};
assert_eq!(at(&yt, None, 1000), Some(86_400), "well, it waits its day like any listed feed");
assert_eq!(at(&yt, Some(1000), 1000 + 7200), Some(3600), "failing two hours: in an hour");
assert_eq!(at(&yt, Some(1000), 1000 + 90_000), Some(86_400), "failing over a day: back off");
assert_eq!(at(&feed(), Some(1000), 1000 + 7200), Some(86_400), "another host's failure is not hurried");
}
#[test]
fn a_feed_long_the_same_is_checked_less_and_a_schedule_set_wins() {
let (hour, day) = (3600u64, 86_400u64);
@@ -1040,6 +1063,39 @@ mod tests {
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
}
#[tokio::test]
async fn a_torrent_enclosure_goes_to_the_torrent_worker_not_the_file_downloader() {
let body = b"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid>\
<enclosure url=\"http://127.0.0.1:9/ep.torrent\" type=\"application/x-bittorrent\" length=\"10\"/></item></channel></rss>";
let scan = |ctx: Arc<Ctx>| async move {
let got = feed::Fetched::Body { bytes: body.to_vec(), etag: None, last_modified: None };
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
match scan_one(&ctx, "t", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), None)))
.await
.unwrap()
{
Outcome::Feed(s) => (s.downloaded, s.torrents),
_ => panic!("a feed"),
}
};
let state = |ctx: Arc<Ctx>| async move { ctx.db.strings_for_test("SELECT state FROM enclosures").await };
// Detached, as the daemon runs them: no slot free, so it waits its turn as 'downloading'.
let mut ctx = test_ctx(config::Config::default()).await;
ctx.detach_torrents = true;
ctx.torrent_slots = Arc::new(tokio::sync::Semaphore::new(0));
let ctx = Arc::new(ctx);
assert_eq!(scan(ctx.clone()).await, (0, 1), "counted as a torrent, not a download");
assert_eq!(state(ctx).await, ["downloading"], "so the next scan does not queue it again");
// Turned off, it is skipped and still counted.
let mut cfg = config::Config::default();
cfg.torrent.enabled = false;
let ctx = Arc::new(test_ctx(cfg).await);
assert_eq!(scan(ctx.clone()).await, (0, 1));
assert_eq!(state(ctx).await, ["skipped"]);
}
#[tokio::test]
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
let base = empty_or_feed_server().await;

View File

@@ -405,6 +405,9 @@ pub async fn check_key(ctx: &Ctx, who: &str, key: &str) -> Result<()> {
/// 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> {
#[cfg(test)]
let url = tests::jev();
#[cfg(not(test))]
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() {
@@ -535,8 +538,172 @@ fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
/// Calls the stand-in has had, by the key they came with.
static CALLS: Mutex<BTreeMap<String, usize>> = Mutex::new(BTreeMap::new());
/// A stand-in for TypeSafe's Jev, as the browser suite's fixture server is: on a thread and
/// runtime of its own, so it outlives any one test's. It answers by the key: `refused-` as a
/// key it does not know, `slow-` as over its limits, `malformed-` as a request it cannot read,
/// `offlist-` with an icon it was not offered; anything else judges an item to fit when its
/// text says "fits", and picks the icon labelled robot.
pub(crate) fn jev() -> String {
static AT: OnceLock<String> = OnceLock::new();
AT.get_or_init(|| {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.set_nonblocking(true).unwrap();
let at = l.local_addr().unwrap();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async move {
let app = axum::Router::new().route("/", axum::routing::post(stand_in));
axum::serve(tokio::net::TcpListener::from_std(l).unwrap(), app).await.unwrap();
});
});
format!("http://{at}/")
})
.clone()
}
async fn stand_in(
headers: axum::http::HeaderMap,
axum::Json(body): axum::Json<serde_json::Value>,
) -> axum::response::Response {
use axum::response::IntoResponse;
let key =
headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").trim_start_matches("Bearer ");
*CALLS.lock().unwrap().entry(key.to_owned()).or_default() += 1;
let refuse = |status: u16, detail: &str| {
(
axum::http::StatusCode::from_u16(status).unwrap(),
[("retry-after", "1")],
format!(r#"{{"detail":"{detail}"}}"#),
)
.into_response()
};
if key.starts_with("refused-") {
return refuse(401, "invalid api key");
} else if key.starts_with("slow-") {
return refuse(429, "rate limited");
} else if key.starts_with("malformed-") {
return refuse(422, "criteria should be an object");
}
let fits = body["state"].as_str().is_some_and(|s| s.contains("fits"));
let mut answers = serde_json::Map::new();
for (id, q) in body["questions"].as_object().unwrap() {
let a = if q["type"] == "choice" {
let robot = q["criteria"].as_object().unwrap().iter().find(|(_, l)| l.as_str() == Some("robot"));
let pick = if key.starts_with("offlist-") { "solid/nope" } else { robot.map_or("", |(k, _)| k) };
serde_json::json!({ "choice": pick })
} else {
serde_json::json!({ "noul": if fits { 0.9 } else { 0.1 } })
};
answers.insert(id.clone(), a);
}
axum::Json(serde_json::json!({ "answers": answers, "usage": { "input_tokens": 100, "output_tokens": 3 } }))
.into_response()
}
fn calls_with(key: &str) -> usize {
CALLS.lock().unwrap().get(key).copied().unwrap_or(0)
}
/// Someone with a key, a Signal and a feed of their own holding these items; their Signal's id.
async fn person(ctx: &Ctx, name: &str, key: &str, items: &[(&str, &str)]) -> i64 {
let id = ctx.db.create_user(name, None, false).await.unwrap();
ctx.db.set_typesafe_key(id, Some(key)).await.unwrap();
let feed = format!("feed-{name}");
let mut sql = format!("INSERT INTO subscriptions (user_id, feed_id) VALUES ({id}, '{feed}');");
for (guid, title) in items {
sql += &format!(
"INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('{feed}', '{guid}', '{title}', {});",
db::now()
);
}
ctx.db.exec_for_test(&sql).await.unwrap();
ctx.db.add_signal(id, "Rust", &["rust".into()], None).await.unwrap()
}
async fn verdicts(ctx: &Ctx, signal: i64) -> Vec<String> {
ctx.db
.strings_for_test(&format!(
"SELECT guid || CASE WHEN hit THEN ' fits' ELSE ' not' END FROM signal_verdicts
WHERE signal_id = {signal} ORDER BY guid"
))
.await
}
#[tokio::test]
async fn a_pass_judges_each_new_item_once_keeps_its_verdicts_and_bills_its_owner() {
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
let signal = person(&ctx, "sig-ann", "good-ann", &[("a", "this one fits"), ("b", "about gardening")]).await;
assert!(!pass(&ctx).await.unwrap(), "two items is not a full batch");
assert_eq!(verdicts(&ctx, signal).await, ["a fits", "b not"]);
assert_eq!(calls_with("good-ann"), 2, "a call an item");
assert!(!pass(&ctx).await.unwrap());
assert_eq!(calls_with("good-ann"), 2, "nothing is asked twice");
// Items judged, Signals fit, input and output tokens, on the person's own key (#278).
assert_eq!(STATS.billed("sig-ann", "own"), [2.0, 1.0, 200.0, 6.0]);
assert_eq!(STATS.billed("sig-ann", "server"), [0.0; 4]);
}
#[tokio::test]
async fn a_refused_key_stops_its_owners_judging_and_nobody_elses() {
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
let ann = person(&ctx, "sig-ann2", "good-ann2", &[("a", "fits")]).await;
let bob = person(&ctx, "sig-bob", "refused-bob", &[("b", "fits")]).await;
let refused_before = STATS.jev_calls[1].load(Relaxed);
assert!(pass(&ctx).await.unwrap(), "goes round again for whoever is left");
assert_eq!(verdicts(&ctx, ann).await, ["a fits"]);
assert!(verdicts(&ctx, bob).await.is_empty(), "left unjudged, not judged a miss");
let bob_id = ctx.db.user_by_name("sig-bob").await.unwrap().unwrap().id;
assert!(ctx.signals_refused.lock().unwrap().contains(&bob_id));
assert!(STATS.jev_calls[1].load(Relaxed) > refused_before, "counted as key_refused");
assert!(!pass(&ctx).await.unwrap());
assert_eq!(calls_with("refused-bob"), 1, "not sent again until a new key is saved");
}
#[tokio::test]
async fn over_typesafes_limits_the_pass_stops_and_the_next_call_waits_its_turn() {
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
let signal = person(&ctx, "sig-cy", "slow-cy", &[("c", "fits")]).await;
let limited_before = STATS.jev_calls[3].load(Relaxed);
assert!(!pass(&ctx).await.unwrap(), "stops rather than going straight round");
assert!(verdicts(&ctx, signal).await.is_empty());
assert!(STATS.jev_calls[3].load(Relaxed) > limited_before, "counted as rate_limited");
// Retry-After said a second: the next call to Jev, anyone's, waits for it.
let started = std::time::Instant::now();
check_key(&ctx, "sig-cy", "good-cy").await.unwrap();
assert!(started.elapsed() >= Duration::from_millis(900), "{:?}", started.elapsed());
}
#[tokio::test]
async fn a_request_typesafe_cannot_read_stops_the_pass_and_is_counted() {
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
let signal = person(&ctx, "sig-dee", "malformed-dee", &[("d", "fits"), ("e", "fits")]).await;
let malformed_before = STATS.jev_calls[2].load(Relaxed);
assert!(!pass(&ctx).await.unwrap(), "every other item's would fail the same way (#257)");
assert!(verdicts(&ctx, signal).await.is_empty());
assert!(STATS.jev_calls[2].load(Relaxed) > malformed_before);
}
#[tokio::test]
async fn a_key_is_checked_and_an_icon_is_one_jev_was_offered() {
let ctx = crate::test_util::test_ctx(crate::config::Config::default()).await;
check_key(&ctx, "sig-eve", "good-eve").await.unwrap();
assert!(refuses_key(&check_key(&ctx, "sig-eve", "refused-eve").await.unwrap_err()));
assert_eq!(STATS.billed("sig-eve", "own")[2], 100.0, "a check costs tokens too");
let offered = [("solid/robot".to_owned(), "robot".to_owned()), ("solid/leaf".to_owned(), "leaf".to_owned())];
let words = ["ai".to_owned()];
let icon = pick_icon(&ctx, "sig-eve", "good-eve", "AI", &words, None, &offered).await.unwrap();
assert_eq!(icon, "solid/robot");
assert!(pick_icon(&ctx, "sig-eve", "offlist-eve", "AI", &words, None, &offered).await.is_err(), "not offered");
assert_eq!(STATS.billed("sig-eve", "server")[2], 200.0, "icons are on the server's key");
}
#[test]
fn a_noul_of_a_half_or_more_is_a_fit() {

View File

@@ -8,7 +8,7 @@ use std::path::PathBuf;
use std::sync::Arc;
pub(crate) fn feed() -> config::Feed {
// Whatever `ipx add` would write, which is the shape every code path sees.
// Whatever `ipxd add` would write, which is the shape every code path sees.
let mut cfg = config::Config::default();
add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![])

View File

@@ -29,6 +29,9 @@ impl Torrents {
let opts = SessionOptions {
listen: Some(ListenerOptions { listen_addr, ..Default::default() }),
client_name_and_version: Some(concat!("ipx ", env!("CARGO_PKG_VERSION")).into()),
// A test's swarm is two sessions and a tracker on 127.0.0.1, not the public DHT or the LAN.
dht: if cfg!(test) { None } else { Some(Default::default()) },
disable_local_service_discovery: cfg!(test),
..Default::default()
};
@@ -184,6 +187,136 @@ pub fn ratio(uploaded: u64, total: u64) -> f64 {
mod tests {
use super::*;
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
/// A swarm on 127.0.0.1: a seeding session holding `file`, and a web server answering for the
/// .torrent (`/ep.torrent`) and as its tracker, naming the seed as the one peer. The server's
/// address, and the seeding session, which stops seeding when dropped.
async fn swarm(file: &Path) -> (SocketAddr, Arc<Session>) {
let web = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = web.local_addr().unwrap();
let seed_port = free_port();
let made = librqbit::create_torrent(
file,
librqbit::CreateTorrentOptions {
name: None,
trackers: vec![format!("http://{at}/announce")],
piece_length: Some(16 * 1024),
},
&librqbit::spawn_utils::BlockingSpawner::new(2),
)
.await
.unwrap();
let torrent = made.as_bytes().unwrap();
// Compact peers: the address's four bytes, then the port's two.
let mut peers = b"d8:intervali60e5:peers6:".to_vec();
peers.extend_from_slice(&[127, 0, 0, 1]);
peers.extend_from_slice(&seed_port.to_be_bytes());
peers.push(b'e');
let app = axum::Router::new()
.route("/ep.torrent", axum::routing::get(move || std::future::ready(torrent.clone())))
.route("/announce", axum::routing::get(move || std::future::ready(peers.clone())));
tokio::spawn(async move { axum::serve(web, app).await });
let seed = Session::new_with_opts(
file.parent().unwrap().to_owned(),
SessionOptions {
dht: None,
disable_local_service_discovery: true,
listen: Some(ListenerOptions {
listen_addr: format!("127.0.0.1:{seed_port}").parse().unwrap(),
..Default::default()
}),
..Default::default()
},
)
.await
.unwrap();
seed.add_torrent(
AddTorrent::from_bytes(made.as_bytes().unwrap()),
Some(AddTorrentOptions {
output_folder: Some(file.parent().unwrap().display().to_string()),
overwrite: true,
..Default::default()
}),
)
.await
.unwrap();
(at, seed)
}
#[tokio::test(flavor = "multi_thread")]
async fn a_detached_torrent_lands_in_its_own_folder_is_recorded_and_frees_its_slot() {
let root = std::env::temp_dir().join(format!("ipx-torrent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (seeded, downloads) = (root.join("seed"), root.join("downloads"));
std::fs::create_dir_all(&seeded).unwrap();
let episode: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
std::fs::write(seeded.join("episode.mp3"), &episode).unwrap();
let (at, _seed) = swarm(&seeded.join("episode.mp3")).await;
let mut cfg = Config::default();
cfg.general.download_dir = downloads.clone();
cfg.torrent.enabled = true;
let port = free_port();
cfg.torrent.port_range = format!("{port}-{port}");
cfg.torrent.seed_ratio = 0.0;
let mut ctx = crate::test_util::test_ctx(cfg.clone()).await;
ctx.detach_torrents = true;
let ctx = Arc::new(ctx);
let url = format!("http://{at}/ep.torrent");
ctx.db
.exec_for_test(&format!(
"INSERT INTO enclosures (id, feed_id, guid, url, state) VALUES (9, 'f', 'g', '{url}', 'downloading')"
))
.await
.unwrap();
crate::scan::spawn_torrent(&ctx, "f".into(), 9, url.clone(), downloads.join("Show"));
let row = tokio::time::timeout(Duration::from_secs(90), async {
loop {
let row = ctx.db.enclosure(9).await.unwrap().unwrap();
if row.state != "downloading" {
return row;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
})
.await
.expect("the torrent finished within 90 s");
let folder = own_folder(&downloads.join("Show"), 9);
assert_eq!((row.state.as_str(), row.last_error.as_deref()), ("done", None));
assert_eq!(row.path.as_deref(), Some(folder.display().to_string().as_str()), "the folder is ours (#318)");
assert_eq!(std::fs::read(folder.join("episode.mp3")).unwrap(), episode);
// The slot is let go once the row is written; give the task its moment.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(ctx.torrent_slots.available_permits(), 2, "the slot is free again");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_torrent_file_from_an_internal_address_is_refused_unless_allowed() {
let app = axum::Router::new().route("/ep.torrent", axum::routing::get(|| async { "d4:infode" }));
let web = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = web.local_addr().unwrap();
tokio::spawn(async move { axum::serve(web, app).await });
let guarded = |allowed: &[&str]| {
let allowed =
crate::feed::guard::Allowed::parse(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap();
reqwest::Client::builder().connector_layer(crate::feed::guard::Guard::new(allowed)).build().unwrap()
};
let url = format!("http://{at}/ep.torrent");
let err = torrent_file(&guarded(&[]), &url).await.unwrap_err();
assert!(format!("{err:#}").contains("fetching the .torrent"), "{err:#}");
assert_eq!(
torrent_file(&guarded(&["127.0.0.1"]), &url).await.unwrap(),
b"d4:infode",
"fetch_internal allows it"
);
}
#[test]
fn ratio_handles_the_empty_torrent() {
assert_eq!(ratio(0, 100), 0.0);

View File

@@ -44,6 +44,8 @@ pub struct WebState {
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
pub playing: crate::deadair::Playing,
/// Wrong passwords by address, for `login` to refuse a guesser (#409).
pub wrong: Wrong,
}
pub fn router(state: WebState) -> Router {
@@ -51,6 +53,8 @@ pub fn router(state: WebState) -> Router {
.route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token))
.route("/api/push/devices", post(add_push_device))
.route("/api/push/devices/{token}", delete(remove_push_device))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
@@ -63,6 +67,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/entries/{feed_id}/{guid}/share", post(share_entry))
.route("/api/feeds/{id}/read-all", post(read_all))
.route("/api/read-all", post(read_all_mine))
.route("/api/feeds/{id}/download-latest", post(download_latest))
@@ -301,13 +306,58 @@ struct Credentials {
password: String,
}
async fn login(State(state): State<WebState>, Json(body): Json<Credentials>) -> Result<Response, ApiError> {
/// Wrong passwords one address may send in `WRONG_WINDOW` (#409). Enough for someone who has
/// forgotten which password it was; a guesser gets ten a quarter hour instead of as many as the
/// server can check. Counted by address, not name: by name, anyone could lock anyone out.
const WRONG_PER_ADDRESS: u32 = 10;
const WRONG_WINDOW: Duration = Duration::from_secs(15 * 60);
pub type Wrong = Arc<std::sync::Mutex<HashMap<String, (Instant, u32)>>>;
/// Who is asking: the peer, or, when the peer is a trusted proxy, the address it put last in
/// `X-Forwarded-For`. Behind Caddy every peer is Caddy, which sets that header to the client's
/// address ({client_ip}, Cloudflare's `Cf-Connecting-IP` for the tunnel's requests).
fn client_addr(cfg: &crate::config::Config, peer: String, headers: &HeaderMap) -> String {
if !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return peer;
}
let forwarded = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok());
forwarded.and_then(|f| f.rsplit(',').next()).map(str::trim).filter(|a| !a.is_empty()).map_or(peer, str::to_owned)
}
/// How long `from` has still to wait, if it has used up its wrong passwords.
fn wrong_wait(wrong: &HashMap<String, (Instant, u32)>, from: &str, now: Instant) -> Option<Duration> {
let (start, n) = wrong.get(from)?;
let gone = now.duration_since(*start);
(*n >= WRONG_PER_ADDRESS && gone < WRONG_WINDOW).then(|| WRONG_WINDOW - gone)
}
/// Counts one against `from`'s window, which starts at its first wrong password in it.
fn count_wrong(wrong: &mut HashMap<String, (Instant, u32)>, from: &str, now: Instant) {
// Windows past their end are forgotten, or a flood from many addresses would be kept forever.
wrong.retain(|_, (start, _)| now.duration_since(*start) < WRONG_WINDOW);
wrong.entry(from.to_owned()).or_insert((now, 0)).1 += 1;
}
async fn login(
State(state): State<WebState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let from = client_addr(&state.ctx.cfg(), peer.ip().to_string(), &headers);
let wait = wrong_wait(&state.wrong.lock().expect("a panic elsewhere poisoned this lock"), &from, Instant::now());
if let Some(wait) = wait {
tracing::warn!(%from, "sign-in refused: too many wrong passwords");
let minutes = wait.as_secs() / 60 + 1;
return Err(ApiError::too_many(format!("too many wrong passwords; try again in {minutes} minutes")));
}
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name).await?;
// The same answer either way, in the same time: whether a name exists is not something to leak.
let ok = crate::auth::check_password(body.password, user.as_ref().and_then(|u| u.pass_hash.clone())).await;
if !ok {
tracing::warn!(user = %name, "failed sign-in");
count_wrong(&mut state.wrong.lock().expect("a panic elsewhere poisoned this lock"), &from, Instant::now());
tracing::warn!(user = %name, %from, "failed sign-in");
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
}
@@ -622,7 +672,7 @@ fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
}
/// Demoting or removing this account would leave nobody able to manage anyone, and the only
/// way back would be `ipx user` on the box.
/// way back would be `ipxd user` on the box.
fn last_admin(users: &[crate::db::User], id: i64) -> bool {
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
admins == [id]
@@ -665,6 +715,39 @@ async fn add_token(
Ok(Json(serde_json::json!({ "id": id, "name": name, "token": token })))
}
#[derive(Deserialize)]
struct PushDevice {
token: String,
#[serde(default)]
sandbox: bool,
}
/// The iOS app's phone, to be sent this person's unread count for its icon (#433). Sent by the
/// page on the app's behalf, as the person signed in there.
async fn add_push_device(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<PushDevice>,
) -> Result<StatusCode, ApiError> {
let token = body.token.to_ascii_lowercase();
if !crate::push::valid_token(&token) {
return Err(ApiError::bad_request("that is not a device token from Apple"));
}
state.ctx.db.add_push_device(user.id, &token, body.sandbox).await?;
tracing::info!(user = %user.name, sandbox = body.sandbox, "push: a phone was registered");
Ok(StatusCode::NO_CONTENT)
}
/// Signing out in the app: that phone's icon is no longer this person's to count.
async fn remove_push_device(
State(state): State<WebState>,
user: crate::db::User,
Path(token): Path<String>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.remove_push_device(user.id, &token.to_ascii_lowercase()).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn remove_token(
State(state): State<WebState>,
user: crate::db::User,
@@ -716,7 +799,7 @@ async fn add_user(
if state.ctx.db.user_by_name(&name).await?.is_some() {
return Err(ApiError::bad_request(format!("{name} already exists")));
}
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
// No password is someone the proxy signs in, as with `ipxd user add --no-password`.
let hash = if body.password.is_empty() {
None
} else {
@@ -1238,7 +1321,7 @@ fn check_url(url: &str, id: &str, feeds: &BTreeMap<String, crate::config::Feed>)
Ok(u) => return Err(format!("{:?} is not an http(s) URL", u.scheme())),
Err(e) => return Err(format!("that is not a valid URL: {e}")),
}
if let Some((other, _)) = feeds.iter().find(|(k, f)| k.as_str() != id && f.url == url) {
if let Some((other, _)) = feeds.iter().find(|(k, f)| k.as_str() != id && crate::feed::same_feed(&f.url, url)) {
return Err(format!("{other:?} is already subscribed to that URL"));
}
Ok(url.to_owned())
@@ -1293,6 +1376,336 @@ impl IntoResponse for ApiError {
mod tests {
use super::*;
use crate::access::tests as access;
use tower::ServiceExt;
/// The router as the daemon serves it, on a scratch database, with the test signer's keys.
async fn app(cfg: crate::config::Config) -> (Router, Arc<Ctx>) {
let ctx = Arc::new(crate::test_util::test_ctx(cfg).await);
let state = WebState {
ctx: ctx.clone(),
cmds: mpsc::channel(8).0,
events: broadcast::channel(8).0,
access: Arc::new(access::keys()),
playing: Default::default(),
wrong: Default::default(),
};
(router(state), ctx)
}
/// A request from `from`, as the listener would see its peer; the status, Set-Cookie and body.
async fn ask(
app: &Router,
method: &str,
uri: &str,
from: &str,
headers: &[(&str, &str)],
body: Option<serde_json::Value>,
) -> (u16, String, String) {
let mut req = axum::http::Request::builder().method(method).uri(uri);
for (k, v) in headers {
req = req.header(*k, *v);
}
let req = match body {
Some(b) => req.header("content-type", "application/json").body(axum::body::Body::from(b.to_string())),
None => req.body(axum::body::Body::empty()),
};
let mut req = req.unwrap();
req.extensions_mut().insert(ConnectInfo(SocketAddr::new(from.parse().unwrap(), 40000)));
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status().as_u16();
let cookie = resp.headers().get(header::SET_COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, cookie, String::from_utf8_lossy(&body).into_owned())
}
const CADDY: &str = "192.168.64.10";
fn proxied() -> crate::config::Config {
let mut cfg = crate::config::Config::default();
cfg.web.trusted_header = "Cf-Access-Authenticated-User-Email".into();
cfg.web.trusted_proxies = vec![CADDY.into()];
cfg.web.auto_create_users = true;
cfg
}
#[tokio::test]
async fn a_name_is_taken_from_the_proxys_header_only_from_the_proxy() {
let (app, _) = app(proxied()).await;
let who = [("Cf-Access-Authenticated-User-Email", "Rays@SDF1.net")];
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &who, None).await;
assert_eq!(s, 200, "{me}");
let me: serde_json::Value = serde_json::from_str(&me).unwrap();
assert_eq!((me["name"].as_str(), me["admin"].as_bool()), (Some("rays@sdf1.net"), Some(true)), "made, first");
// Anyone else able to reach the port could send the header and be whoever they liked.
assert_eq!(ask(&app, "GET", "/api/me", "192.168.64.1", &who, None).await.0, 401, "cloudflared itself");
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &who, None).await.0, 401);
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &[], None).await.0, 401, "from the proxy, vouching for nobody");
}
#[tokio::test]
async fn with_access_set_the_name_is_the_signed_tokens_and_nothing_else() {
let mut cfg = proxied();
cfg.web.access_team = access::TEAM.into();
cfg.web.access_aud = access::AUD.into();
let (app, _) = app(cfg).await;
let rs = jsonwebtoken::Algorithm::RS256;
let good = access::token(access::SIGNER, rs, access::claims(access::AUD, 300));
fn with(t: &str) -> [(&str, &str); 2] {
[("Cf-Access-Authenticated-User-Email", "someone-else@x.test"), ("Cf-Access-Jwt-Assertion", t)]
}
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &with(&good), None).await;
assert_eq!(s, 200, "{me}");
assert!(me.contains(r#""name":"rays@sdf1.net""#), "the token's email, not the header's: {me}");
let other_app = access::token(access::SIGNER, rs, access::claims("another-app", 300));
let expired = access::token(access::SIGNER, rs, access::claims(access::AUD, -3600));
for (t, why) in
[(other_app.as_str(), "another application's"), (expired.as_str(), "expired"), ("x.y.z", "garbage")]
{
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &with(t), None).await.0, 401, "{why}");
}
let header_only = [("Cf-Access-Authenticated-User-Email", "rays@sdf1.net")];
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &header_only, None).await.0, 401, "no token");
let token_only = [("Cf-Access-Jwt-Assertion", good.as_str())];
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &token_only, None).await.0, 401, "no header to switch it on");
assert_eq!(
ask(&app, "GET", "/api/me", "10.0.0.5", &with(&good), None).await.0,
401,
"a good token, not via Caddy"
);
}
#[tokio::test]
async fn a_wrong_password_is_refused_and_the_right_one_after_it_signs_in() {
let (app, ctx) = app(crate::config::Config::default()).await;
let hash = crate::auth::hash_password("correct horse").unwrap();
ctx.db.create_user("dee", Some(&hash), false).await.unwrap();
let login = |pw: &str| serde_json::json!({ "name": "Dee", "password": pw });
for _ in 0..3 {
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("wrong"))).await.0, 401);
}
let (s, _, _) = ask(
&app,
"POST",
"/api/login",
"10.0.0.5",
&[],
Some(serde_json::json!({ "name": "nobody", "password": "x" })),
)
.await;
assert_eq!(s, 401, "an unknown name has the same answer");
let (s, cookie, _) = ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("correct horse"))).await;
assert_eq!(s, 200);
let session = cookie.split(';').next().unwrap();
assert!(session.starts_with("ipx_session=") && cookie.contains("HttpOnly"), "{cookie}");
let (s, _, me) = ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", session)], None).await;
assert!(s == 200 && me.contains(r#""name":"dee""#), "{s} {me}");
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", "ipx_session=forged")], None).await.0, 401);
}
#[tokio::test]
async fn ten_wrong_passwords_from_one_address_hold_it_off_and_no_other() {
let mut cfg = crate::config::Config::default();
cfg.web.trusted_proxies = vec![CADDY.into()];
let (app, ctx) = app(cfg).await;
let hash = crate::auth::hash_password("correct horse").unwrap();
ctx.db.create_user("dee", Some(&hash), false).await.unwrap();
let login = |pw: &str| Some(serde_json::json!({ "name": "dee", "password": pw }));
let via_caddy = |client| [("x-forwarded-for", client)];
for _ in 0..10 {
let (s, _, _) = ask(&app, "POST", "/api/login", CADDY, &via_caddy("203.0.113.7"), login("wrong")).await;
assert_eq!(s, 401);
}
let (s, _, body) =
ask(&app, "POST", "/api/login", CADDY, &via_caddy("203.0.113.7"), login("correct horse")).await;
assert!(s == 429 && body.contains("try again in 15 minutes"), "{s} {body}");
// Someone else behind the same Caddy is not held off with them, nor is Dee from elsewhere.
let (s, _, _) =
ask(&app, "POST", "/api/login", CADDY, &via_caddy("198.51.100.2"), login("correct horse")).await;
assert_eq!(s, 200);
// Only Caddy's word is taken for the address: anyone else sending the header is counted as
// themselves, and cannot step out of a window by naming a new address each time.
for n in 0..10 {
let hdr = [("x-forwarded-for", format!("192.0.2.{n}"))];
let hdr: Vec<(&str, &str)> = hdr.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.9", &hdr, login("wrong")).await.0, 401);
}
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.9", &[], login("correct horse")).await.0, 429);
}
#[tokio::test]
async fn a_place_saved_in_deadairs_clock_is_read_in_it_without_the_playlist() {
let (app, ctx) = app(crate::config::Config::default()).await;
let db = &ctx.db;
let u = db.create_user("dee", None, false).await.unwrap();
db.subscribe(u, "f").await.unwrap();
for sql in [
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'h', 1, 600)",
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
VALUES (8, 'f', 'h', 'http://x/b.mp3', '/x/b.mp3', 'done', 100)",
] {
db.sql(sql).await.unwrap();
}
// Two minutes cut, both before 3:20 of the shortened version.
let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []}}"#;
db.store_plans(7, plans).await.unwrap();
db.store_plans(8, plans).await.unwrap();
db.set_deadair(u, "f", Some(true)).await.unwrap();
db.create_session(u, "s1").await.unwrap();
let ctx = &ctx;
let place = |guid: &'static str| async move {
let order = crate::db::order_sql("published", "desc");
let rows = ctx.db.entries_in(u, Some("f"), crate::db::Filter::All, None, 0, 10, &order).await.unwrap();
rows.into_iter().find(|r| r.guid == guid).unwrap().position
};
let save = |guid: &str, body: serde_json::Value| {
let uri = format!("/api/entries/f/{guid}/position");
let app = app.clone();
async move { ask(&app, "POST", &uri, "10.0.0.5", &[("cookie", "ipx_session=s1")], Some(body)).await.0 }
};
// Naming another item's file is taken as no file: the place stays as sent.
assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 8 })).await, 204);
assert_eq!(place("g").await, 200);
// From a copy kept on the device, no playlist asked for: 3:20 of it is 5:20 of the original.
assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 7 })).await, 204);
assert_eq!(place("g").await, 320);
// The original's own clock, from a player that says nothing of DeadAir, is left as it is.
assert_eq!(save("h", serde_json::json!({ "secs": 200 })).await, 204);
assert_eq!(place("h").await, 200);
}
#[test]
fn a_window_of_wrong_passwords_ends_and_is_forgotten() {
let mut wrong = HashMap::new();
let t0 = Instant::now();
for _ in 0..WRONG_PER_ADDRESS {
count_wrong(&mut wrong, "a", t0);
}
assert!(wrong_wait(&wrong, "a", t0).is_some());
assert_eq!(wrong_wait(&wrong, "a", t0 + WRONG_WINDOW), None);
count_wrong(&mut wrong, "b", t0 + WRONG_WINDOW);
assert!(!wrong.contains_key("a"), "a past window is dropped");
}
#[tokio::test]
async fn a_signal_is_made_changed_listed_and_removed_by_its_owner_alone() {
let (app, ctx) = app(proxied()).await;
// As the proxy signs each in; the name is all that changes.
let call = |who: &'static str, m: &'static str, uri: String, b: Option<serde_json::Value>| {
let app = app.clone();
async move { ask(&app, m, &uri, CADDY, &[("Cf-Access-Authenticated-User-Email", who)], b).await }
};
let (dee, sam) = ("dee@x.test", "sam@x.test");
let json = |b: &str| serde_json::from_str::<serde_json::Value>(b).unwrap();
let make = serde_json::json!({ "name": "Rust", "keywords": ["rust", " Rust ", "cargo"], "icon": "solid/gear" });
let (s, _, b) = call(dee, "POST", "/api/signals".into(), Some(make)).await;
assert_eq!(s, 200, "{b}");
let id = json(&b)["id"].as_i64().unwrap();
for (bad, why) in [
(serde_json::json!({ "name": "", "keywords": ["x"] }), "no name"),
(serde_json::json!({ "name": "X", "keywords": [] }), "no words"),
(serde_json::json!({ "name": "X", "keywords": ["x"], "icon": "solid/../x" }), "an icon that is not a name"),
(
serde_json::json!({ "name": "X", "keywords": ["x"], "description": "d".repeat(501) }),
"a long description",
),
] {
assert_eq!(call(dee, "POST", "/api/signals".into(), Some(bad)).await.0, 400, "{why}");
}
let (_, _, list) = call(dee, "GET", "/api/signals".into(), None).await;
let list = json(&list);
assert_eq!(list.as_array().unwrap().len(), 1);
assert_eq!(list[0]["keywords"], serde_json::json!(["rust", "cargo"]), "trimmed, once each");
assert_eq!(list[0]["icon"], "solid/gear");
let change =
serde_json::json!({ "name": "Rust lang", "keywords": ["rustlang"], "description": "the language" });
assert_eq!(call(dee, "PATCH", format!("/api/signals/{id}"), Some(change)).await.0, 204);
let row =
ctx.db.signal(ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap().id, id).await.unwrap().unwrap();
assert_eq!(
(row.name.as_str(), row.keywords.as_slice(), row.description.as_deref()),
("Rust lang", &["rustlang".to_owned()][..], Some("the language"))
);
assert_eq!(
call(dee, "PATCH", format!("/api/signals/{id}"), Some(serde_json::json!({ "icon": null }))).await.0,
204
);
assert!(
ctx.db.signal(row_owner(&ctx, "dee@x.test").await, id).await.unwrap().unwrap().icon.is_none(),
"cleared"
);
// What it picked out is listed under it.
let owner = row_owner(&ctx, "dee@x.test").await;
ctx.db
.exec_for_test(&format!(
"INSERT INTO subscriptions (user_id, feed_id) VALUES ({owner}, 'f');
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('f', 'g1', 'Rust 2.0', 1), ('f', 'g2', 'Gardening', 1);"
))
.await
.unwrap();
ctx.db.record_verdicts("f", "g1", &[(id, true)]).await.unwrap();
ctx.db.record_verdicts("f", "g2", &[(id, false)]).await.unwrap();
let (s, _, page) = call(dee, "GET", format!("/api/signals/{id}/entries"), None).await;
let page = json(&page);
assert_eq!((s, page["total"].as_i64()), (200, Some(1)), "{page}");
assert_eq!(page["entries"][0]["guid"], "g1");
// Someone else's Signal is not there for them, in any way.
assert_eq!(call(sam, "GET", format!("/api/signals/{id}/entries"), None).await.0, 404);
assert_eq!(
call(sam, "PATCH", format!("/api/signals/{id}"), Some(serde_json::json!({ "name": "Mine" }))).await.0,
404
);
assert_eq!(call(sam, "DELETE", format!("/api/signals/{id}"), None).await.0, 404);
let (_, _, theirs) = call(sam, "GET", "/api/signals".into(), None).await;
assert_eq!(json(&theirs), serde_json::json!([]));
assert_eq!(call(dee, "DELETE", format!("/api/signals/{id}"), None).await.0, 204);
assert_eq!(call(dee, "DELETE", format!("/api/signals/{id}"), None).await.0, 404, "already gone");
assert!(
ctx.db
.strings_for_test(&format!("SELECT guid FROM signal_verdicts WHERE signal_id = {id}"))
.await
.is_empty(),
"its verdicts with it"
);
}
async fn row_owner(ctx: &Ctx, name: &str) -> i64 {
ctx.db.user_by_name(name).await.unwrap().unwrap().id
}
#[tokio::test]
async fn the_block_list_and_the_typesafe_key_are_checked_before_they_are_saved() {
let (app, ctx) = app(proxied()).await;
let who = [("Cf-Access-Authenticated-User-Email", "dee@x.test")];
let patch = |b: serde_json::Value| ask(&app, "PATCH", "/api/me", CADDY, &who, Some(b));
let many: Vec<String> = (0..501).map(|i| format!("w{i}")).collect();
assert_eq!(patch(serde_json::json!({ "blocked": many })).await.0, 400, "501 words");
assert_eq!(patch(serde_json::json!({ "blocked": ["x".repeat(201)] })).await.0, 400, "a 201-character word");
assert_eq!(patch(serde_json::json!({ "blocked": [" Spoiler ", "spoiler", "", "ads"] })).await.0, 204);
let me = ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap();
assert_eq!(ctx.db.blocklist(me.id, "").await.unwrap(), ["Spoiler", "ads"], "trimmed, once each");
// Refused here, before TypeSafe is asked anything.
for bad in ["has a space", "tab\there", &"k".repeat(501)] {
assert_eq!(patch(serde_json::json!({ "typesafe_key": bad })).await.0, 400, "{bad}");
}
assert!(!ctx.db.typesafe_key_set(me.id).await.unwrap());
assert_eq!(patch(serde_json::json!({ "typesafe_key": null })).await.0, 204, "forgetting needs no check");
assert_eq!(patch(serde_json::json!({ "typesafe_key": " " })).await.0, 204, "nor does an empty one");
}
#[test]
fn icon_picks_are_counted_by_the_hour_and_the_person() {
let mut picks = std::collections::HashMap::new();
@@ -1410,10 +1823,38 @@ mod tests {
deadair: None,
ms: Some(5),
filter: Some("unread".into()),
trace: None,
span: None,
start: None,
state: Some("3/50 of 120".into()),
};
for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] {
for ev in
["page_error", "rejection", "player_error", "play_start", "stall", "list_shown", "swipe", "scroll_stuck"]
{
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
}
// A tap's span takes a trace's ids and a time the server can believe (#376).
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let tap = |trace: &str, start: u64| ClientEvent {
trace: Some(trace.into()),
span: Some("00f067aa0ba902b7".into()),
start: Some(start),
msg: Some("pointerdown".into()),
..e("tap")
};
let good = "4bf92f3577b34da6a3ce929d0e0e4736";
assert_eq!(client_event(u.clone(), Json(tap(good, now - 2000))).await, StatusCode::NO_CONTENT);
assert_eq!(client_event(u.clone(), Json(tap("not hex", now))).await, StatusCode::BAD_REQUEST);
assert_eq!(client_event(u.clone(), Json(tap(&"0".repeat(32), now))).await, StatusCode::BAD_REQUEST);
assert_eq!(
client_event(u.clone(), Json(tap(good, now - 7_200_000))).await,
StatusCode::BAD_REQUEST,
"hours ago"
);
assert_eq!(
client_event(u.clone(), Json(ClientEvent { trace: None, ..tap(good, now) })).await,
StatusCode::BAD_REQUEST
);
assert_eq!(client_event(u, Json(e("anything"))).await, StatusCode::BAD_REQUEST);
}
@@ -1637,6 +2078,43 @@ async fn one_entry(
Ok(Json(row))
}
#[derive(Deserialize)]
struct ShareBody {
/// The file to share; the first sound file when left out.
enc: Option<i64>,
}
/// A link to the episode's page on ipx-play (#447), or none, with why, when the page should
/// share the episode's own address as before: a private feed, a post, no ipx-play set up.
#[derive(Serialize)]
struct ShareLink {
url: Option<String>,
why: Option<&'static str>,
}
async fn share_entry(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
Json(body): Json<ShareBody>,
) -> Result<Json<ShareLink>, ApiError> {
subscribed(&state, &user, &feed_id).await?;
use crate::play::NoLink;
let why = match crate::play::share(&state.ctx, user.id, &feed_id, &guid, body.enc).await {
Ok(s) => return Ok(Json(ShareLink { url: Some(s.url), why: None })),
Err(NoLink::NotFound) => return Err(ApiError::not_found("no such entry")),
Err(NoLink::NotSetUp) => "not_set_up",
Err(NoLink::Private) => "private",
Err(NoLink::NoFile) => "no_file",
Err(NoLink::Failed(e)) => {
// The person still gets the episode's own address; this is for whoever runs the server.
tracing::warn!(error = %e, "sharing an episode through ipx-play failed");
"failed"
}
};
Ok(Json(ShareLink { url: None, why: Some(why) }))
}
/// An item's text as the page may show it: the feed's HTML cleaned, and its Markdown, where it
/// sent one, made HTML and cleaned the same way (#285).
fn clean_row(sanitizer: &mut ammonia::Builder<'static>, row: &mut crate::db::EntryRow) {
@@ -1669,7 +2147,9 @@ fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
None => ammonia::UrlRelative::PassThrough,
});
sanitizer.clean(html).to_string()
let clean = sanitizer.clean(html).to_string();
crate::art::name_in_text(&clean);
clean
}
#[derive(Deserialize)]
@@ -1720,6 +2200,9 @@ async fn add_feed(
/// `None` scans whatever is due, which a feed never checked always is. The add has
/// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) {
// A feed the server already has brings no new items to wake the Signals judge, and its recent
// ones waited up to five minutes for the next pass to reach the new subscriber's Signals (#415).
state.ctx.signals.notify_one();
let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
@@ -1884,7 +2367,7 @@ async fn patch_feed(
let found =
subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
cfg.feeds.insert(id.clone(), found.cfg.clone());
state.ctx.db.unmanage(&id).await?;
state.ctx.db.unmanage(&id, found.cfg.group.as_deref()).await?;
}
let checked = match &body.url {
@@ -2271,7 +2754,8 @@ struct ArtQuery {
u: String,
}
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
/// Artwork, from iPX: the page asks here for every image a feed or item names (and, to keep for
/// offline, the pictures in item text, #389), and the first
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
/// server. Only an address some feed or item names as its artwork, and only an image up to
@@ -2291,9 +2775,10 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
crate::metrics::STATS.art_hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return art_response(kind, body);
}
// Only what a feed here or the directory names: anything else would make ipx fetch any
// Only what a feed here or the directory names: anything else would make ipxd fetch any
// address it is given. The Directory shows podcasts nobody here subscribes to (#273).
if !crate::directory::listed_art(&q.u)
&& !crate::art::named_in_text(&q.u)
&& !state.ctx.db.names_image(&q.u).instrument(db::step("names_image")).await.unwrap_or(false)
{
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
@@ -2420,6 +2905,10 @@ struct Position {
secs: i64,
/// The length the player measured, for an episode whose feed gives none.
duration: Option<i64>,
/// The file, when the player was playing it without its dead air: its places are in that
/// clock. The playlist tells the server so too, but a copy kept on the device (#412) plays
/// without one, and the server forgets what it was told when it restarts.
deadair: Option<i64>,
}
async fn set_position(
@@ -2428,6 +2917,17 @@ async fn set_position(
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
let key = (user.id, feed_id.clone(), guid.clone());
if let Some(id) = body.deadair
&& !state.playing.lock().expect("a panic elsewhere poisoned this lock").contains_key(&key)
&& let Some(enc) = state.ctx.db.enclosure(id).await?
// Only this item's file: another's plan would put the place somewhere else.
&& enc.feed_id == feed_id
&& enc.guid == guid
&& let Some(plan) = crate::deadair::plan_for(&state.ctx, user.id, &feed_id, id).await?
{
state.playing.lock().expect("a panic elsewhere poisoned this lock").insert(key, Arc::new(plan));
}
let (secs, duration) =
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
@@ -2546,7 +3046,7 @@ async fn import_opml(
Ok(Json(serde_json::json!({ "added": added, "already": already })))
}
/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's
/// The numbers `ipxd status` prints, and the version, for a dashboard such as Homepage's
/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token.
async fn status(State(state): State<WebState>) -> Response {
match crate::daemon::status(&state.ctx).await {
@@ -2576,6 +3076,9 @@ struct Settings {
/// Whether a token is set: the token itself never leaves the server.
directory_token_set: bool,
directory_share: bool,
play_url: String,
/// Whether a token is set, as for the directory's.
play_token_set: bool,
signals_on_server: bool,
signal_days: u64,
/// Whether the server has a TypeSafe key (`TYPESAFE_KEY`) for `signals_on_server` to use.
@@ -2596,6 +3099,8 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
directory_url: cfg.general.directory_url.clone(),
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
directory_share: cfg.general.directory_share,
play_url: cfg.general.play_url.clone(),
play_token_set: !cfg.general.play_token.trim().is_empty(),
signals_on_server: cfg.general.signals_on_server,
signal_days: cfg.general.signal_days,
typesafe_server_key: crate::signals::typesafe_key().is_some(),
@@ -2615,6 +3120,10 @@ struct SettingsPatch {
/// Replaces the token; empty clears it.
directory_token: Option<String>,
directory_share: Option<bool>,
/// Empty for none.
play_url: Option<String>,
/// Replaces the token; empty clears it.
play_token: Option<String>,
signals_on_server: Option<bool>,
/// 1 to 365.
signal_days: Option<u64>,
@@ -2665,6 +3174,16 @@ async fn patch_settings(
if let Some(v) = body.directory_token {
cfg.general.directory_token = v.trim().to_owned();
}
if let Some(v) = body.play_url {
let v = v.trim().trim_end_matches('/').to_owned();
if !v.is_empty() && !url::Url::parse(&v).is_ok_and(|u| matches!(u.scheme(), "http" | "https")) {
return Err(ApiError::bad_request(format!("{v:?} is not a web address; try https://play.ipxreader.app")));
}
cfg.general.play_url = v;
}
if let Some(v) = body.play_token {
cfg.general.play_token = v.trim().to_owned();
}
if let Some(v) = body.directory_share {
cfg.general.directory_share = v;
}
@@ -2754,15 +3273,31 @@ struct ClientEvent {
ms: Option<u64>,
/// The list's filter, for `list_shown`: all, unread, flagged.
filter: Option<String>,
/// For `tap`: the trace the page began and its root span, hex, and when it began, Unix ms.
trace: Option<String>,
span: Option<String>,
start: Option<u64>,
/// For `swipe` and `scroll_stuck` (#384): where the reader stood, and what could hold it.
state: Option<String>,
}
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
// From the browser: only the kinds the page sends, and nothing long enough to fill the log.
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter));
let (msg, src, feed, filter, state) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter), cut(&e.state));
let (ev, line, enclosure, deadair, ms, user) =
(e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
match ev {
// Sent to Tempo, not the log: a line a tap would be most of it.
"tap" => {
let ok = match (&e.trace, &e.span, e.start, ms) {
(Some(t), Some(s), Some(start), Some(ms)) => {
page_span(t, s, start, ms, msg.as_deref().unwrap_or(""), user)
}
_ => false,
};
return if ok { StatusCode::NO_CONTENT } else { StatusCode::BAD_REQUEST };
}
"page_error" | "rejection" | "player_error" => tracing::warn!(
target: "ipx::client", ev, msg, src, line, feed, enclosure, deadair, user,
"{user}'s page: {ev}: {}", msg.as_deref().unwrap_or("")
@@ -2776,11 +3311,54 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
target: "ipx::client", ev, ms, feed, filter, user,
"{user}'s page: list_shown {} ms", ms.unwrap_or(0)
),
// A swipe in the reader and what came of it, so one that did nothing is seen (#384): a
// swipe sends no request when it only bounces, and #383's stopped at the 50th unseen.
"swipe" => tracing::info!(
target: "ipx::client", ev, msg, ms, feed, filter, state, user,
"{user}'s page: swipe {} at {}", msg.as_deref().unwrap_or(""), state.as_deref().unwrap_or("")
),
// A drag up or down on an item with room to scroll that did not move it (#384).
"scroll_stuck" => tracing::warn!(
target: "ipx::client", ev, feed, state, user,
"{user}'s page: scroll_stuck: {}", state.as_deref().unwrap_or("")
),
_ => return StatusCode::BAD_REQUEST,
}
// The timings in /metrics as well, so weeks of them are percentiles without reading each line
// (#369). Capped at a minute: the clock is the browser's.
if let (Some(ms), "play_start" | "stall" | "list_shown") = (ms, ev) {
crate::metrics::PAGE.observe(&format!("ev=\"{ev}\""), ms.min(60_000) as f64 / 1000.0);
}
StatusCode::NO_CONTENT
}
/// The root span of a trace the page began with a tap or a key (#376), with the page's own ids, so
/// the requests it sent, which name it as their parent, hang from it in Tempo: how long from the
/// tap to its last answer. The browser's clock, so only a span begun in the last hour and at most
/// a minute long. False for ids that are not a trace's.
fn page_span(trace: &str, span: &str, start_ms: u64, ms: u64, what: &str, user: &str) -> bool {
use opentelemetry::trace::{Span as _, SpanId, SpanKind, TraceId, Tracer as _};
let (Ok(t), Ok(s)) = (TraceId::from_hex(trace), SpanId::from_hex(span)) else { return false };
if trace.len() != 32 || span.len() != 16 || t == TraceId::INVALID || s == SpanId::INVALID || ms > 60_000 {
return false;
}
let start = std::time::UNIX_EPOCH + std::time::Duration::from_millis(start_ms);
let now = std::time::SystemTime::now();
if now.duration_since(start).map_or(true, |ago| ago > std::time::Duration::from_secs(3600)) {
return false;
}
let tracer = opentelemetry::global::tracer("ipx");
let b = tracer
.span_builder(if what == "keydown" { "key" } else { "tap" })
.with_kind(SpanKind::Client)
.with_start_time(start)
.with_attributes([opentelemetry::KeyValue::new("user", user.to_owned())]);
// Built with the page's ids, which its requests already name as their parent.
let mut span = ipx_directory::otel::with_ids(t, s, || tracer.build_with_context(b, &opentelemetry::Context::new()));
span.end_with_timestamp(start + std::time::Duration::from_millis(ms));
true
}
/// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing
/// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its
/// own, and nothing grouped. A route layer, because only one runs after routing. Renamed on the
@@ -2859,10 +3437,17 @@ async fn access_log(req: Request, next: Next) -> Response {
// The same as fields, for the JSON log (#91). Unrouted, a request has no route.
let route = resp.extensions().get::<MatchedPath>().map(|r| r.as_str().to_owned());
let method = method.as_str();
// The event stream's time is its first byte, not the minutes it stays open.
if path != "/api/events" {
let secs = started.elapsed().as_secs_f64();
crate::metrics::STATS.request(method, route.as_deref().unwrap_or(""), status, secs);
}
// Inside the request's span, so the line carries its trace id and leads to its trace.
let _in = span.enter();
if resp.status().is_success() || resp.status().is_redirection() {
tracing::info!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
// Debug: the metric above counts it, Caddy's access log and the span have it too,
// and it was over half the log (#364). The page's log view keeps debug.
tracing::debug!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
} else {
tracing::warn!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
}

236
crates/ipx/tests/daemon.rs Normal file
View File

@@ -0,0 +1,236 @@
//! The daemon as production runs it: the built binary, a scratch config and SQLite database, a
//! feed served from this process, and SIGTERM sent as Docker sends it (#400). A process of its
//! own, because a signal sent to the test binary would reach every test in it.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
const EPISODE_BYTES: u64 = 50_000_000;
/// Times the episode has been asked for.
static EPISODE_GETS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn episode_gets() -> usize {
EPISODE_GETS.load(std::sync::atomic::Ordering::SeqCst)
}
/// A feed of one episode, and the episode, sent 4 KB every 50 ms: a download that takes minutes.
fn serve_feed() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
for sock in l.incoming().flatten() {
std::thread::spawn(move || answer(sock, port));
}
});
port
}
fn answer(mut sock: TcpStream, port: u16) {
let mut line = String::new();
if BufReader::new(&sock).read_line(&mut line).is_err() {
return;
}
if line.contains("/ep.mp3") {
EPISODE_GETS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: audio/mpeg\r\nContent-Length: {EPISODE_BYTES}\r\n\r\n");
// An MP3's frame sync first, so the file is taken for audio.
let mut chunk = vec![0u8; 4096];
chunk[..2].copy_from_slice(&[0xFF, 0xFB]);
if sock.write_all(head.as_bytes()).is_err() {
return;
}
while sock.write_all(&chunk).is_ok() {
chunk[..2].fill(0);
std::thread::sleep(Duration::from_millis(50));
}
return;
}
let body = format!(
r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Slow</title><item><title>One</title><guid>g1</guid><enclosure url="http://127.0.0.1:{port}/ep.mp3" type="audio/mpeg" length="{EPISODE_BYTES}"/></item></channel></rss>"#
);
let _ = sock.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()).as_bytes());
}
struct Scratch {
root: PathBuf,
web: u16,
}
impl Scratch {
fn new(feed_port: u16) -> Self {
let root = std::env::temp_dir().join(format!("ipx-daemon-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("data")).unwrap();
let web = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
std::fs::write(
root.join("config.toml"),
format!(
r#"[general]
download_dir = "{root}/downloads"
socket = "{root}/ipx.sock"
schedule = "every 60m"
fetch_internal = ["127.0.0.1"]
[torrent]
enabled = false
[web]
enabled = true
bind = "127.0.0.1:{web}"
token = ""
[feeds.slow]
url = "http://127.0.0.1:{feed_port}/feed.xml"
"#,
root = root.display()
),
)
.unwrap();
Self { root, web }
}
fn ipx(&self, args: &[&str]) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_ipxd"));
c.args(args)
.env("IPX_CONFIG", self.root.join("config.toml"))
.env("IPX_DATA_DIR", self.root.join("data"))
.env("IPX_ADMIN_PASSWORD", "a test password")
.env("IPX_LOG", "ipx=info")
.stdin(Stdio::null());
// Nothing of the shell's: no Postgres, no traces, no JSON log, no TypeSafe key.
for v in
["IPX_DATABASE_URL", "OTEL_EXPORTER_OTLP_ENDPOINT", "IPX_LOG_FORMAT", "TYPESAFE_KEY", "IPX_TYPESAFE_URL"]
{
c.env_remove(v);
}
c
}
fn run(&self, args: &[&str]) -> Output {
self.ipx(args).output().unwrap()
}
/// A daemon, its log going to `log`.
fn daemon(&self, log: &str) -> Daemon {
let log = std::fs::File::create(self.root.join(log)).unwrap();
Daemon(self.ipx(&["daemon"]).stdout(Stdio::null()).stderr(log).spawn().unwrap())
}
fn log(&self, name: &str) -> String {
std::fs::read_to_string(self.root.join(name)).unwrap_or_default()
}
fn socket(&self) -> PathBuf {
self.root.join("ipx.sock")
}
/// Bytes of the download under way, in the downloads folder's incomplete files.
fn downloading(&self) -> u64 {
let dir = self.root.join("downloads/.ipx-incomplete");
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
}
}
fn until(what: &str, limit: Duration, mut ok: impl FnMut() -> bool) {
let started = Instant::now();
while !ok() {
assert!(started.elapsed() < limit, "{what}: not after {limit:?}");
std::thread::sleep(Duration::from_millis(100));
}
}
fn text(o: &Output) -> String {
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
}
/// SIGTERM, as `docker stop` sends; how long it took to exit.
/// A daemon this test started, killed when dropped: a failed assertion left two running for an
/// hour, holding their scratch sockets and ports.
struct Daemon(Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn terminate(Daemon(child): &mut Daemon) -> Duration {
let started = Instant::now();
assert!(Command::new("kill").args(["-TERM", &child.id().to_string()]).status().unwrap().success());
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success(), "the daemon exited {status}");
return started.elapsed();
}
if started.elapsed() > Duration::from_secs(10) {
let _ = child.kill();
panic!("the daemon ignored SIGTERM for 10 s");
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn get(port: u16, path: &str) -> String {
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
write!(s, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").unwrap();
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
}
fn token_in(config: &Path) -> String {
let text = std::fs::read_to_string(config).unwrap();
let line = text.lines().find(|l| l.trim_start().starts_with("token")).expect("a token line");
line.split('"').nth(1).unwrap_or("").to_owned()
}
#[test]
fn the_daemon_starts_answers_refuses_a_second_and_stops_on_sigterm_mid_download() {
let s = Scratch::new(serve_feed());
let mut first = s.daemon("first.log");
until("the control socket", Duration::from_secs(60), || s.socket().exists() && s.run(&["status"]).status.success());
// Through the socket: the daemon's own answer, in words.
let status = s.run(&["status"]);
assert!(text(&status).contains("1 feeds,"), "{}", text(&status));
let local = s.run(&["--local", "status"]);
assert!(local.status.success() && text(&local).contains("1 feeds,"), "{}", text(&local));
// A first start with no token writes one, and it signs in.
let token = token_in(&s.root.join("config.toml"));
assert_eq!(token.len(), 64, "a session-strength token");
assert!(!s.log("first.log").contains(&token), "the log says where the token is, never what it is");
let me = get(s.web, &format!("/api/me?token={token}"));
assert!(me.starts_with("HTTP/1.1 200"), "{me}");
assert!(get(s.web, "/api/me").starts_with("HTTP/1.1 401"));
// Two daemons on one socket would fight over the database.
let second = s.ipx(&["daemon"]).output().unwrap();
assert!(!second.status.success());
assert!(text(&second).contains("a daemon is already listening"), "{}", text(&second));
assert!(s.run(&["status"]).status.success(), "the first is still serving");
// The first pass starts at once and takes the episode, which takes minutes. SIGTERM stops it.
until("the download to start", Duration::from_secs(60), || s.downloading() > 0);
let took = terminate(&mut first);
assert!(took < Duration::from_secs(5), "stopped in {took:?}");
assert!(!s.socket().exists(), "the socket is removed");
assert!(s.log("first.log").contains("abandoning the job in progress"), "{}", s.log("first.log"));
// The next start takes the abandoned download up again at once, not at its feed's next check,
// which is an hour away (#410).
assert_eq!(episode_gets(), 1);
let mut again = s.daemon("again.log");
until("the episode asked for again", Duration::from_secs(30), || episode_gets() == 2);
assert!(s.log("again.log").contains("taking up downloads interrupted by a restart"), "{}", s.log("again.log"));
terminate(&mut again);
// No daemon: the command does the work itself.
let alone = s.run(&["status"]);
assert!(alone.status.success() && text(&alone).contains("1 feeds,"), "{}", text(&alone));
let _ = std::fs::remove_dir_all(&s.root);
}

18
crates/play/CHANGELOG.md Normal file
View File

@@ -0,0 +1,18 @@
# Changelog: ipx-play
The pages shared episodes open, at `play.ipxreader.app` (docs/play.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/play/Cargo.toml`, and a release is tagged `play-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]
### Added
- A page for each shared episode, at `/e/<id>`: its show, title, date, length and notes, a player that plays the publisher's own file from the time it was shared at (`?t=754` or `?t=12:34`), buttons to subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, the feed's address, and a preview where the link is pasted (#447).
- `POST /v1/shares`, for an ipx server with a token from `ipx-play token <name>`: an episode in, its short link out, the same link however often and from whichever server it is shared. An address that looks like a private or paid feed's is refused (#447).
[unreleased]: https://git.sdf1.net/rays/ipx/commits/main/crates/play

39
crates/play/Cargo.toml Normal file
View File

@@ -0,0 +1,39 @@
[package]
name = "ipx-play"
version = "0.1.0-dev"
edition.workspace = true
publish = false
[[bin]]
name = "ipx-play"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
ammonia.workspace = true
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
clap.workspace = true
# Its tracing setup and token helpers, shared rather than copied: the same traceparent and JSON
# log lines as ipx and the directory.
ipx-directory.workspace = true
ipx-feed.workspace = true
opentelemetry.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
tracing.workspace = true
tracing-opentelemetry.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
[dev-dependencies]
tower.workspace = true

115
crates/play/src/config.rs Normal file
View File

@@ -0,0 +1,115 @@
//! `play.toml`: where ipx-play listens, its database, its public address, and the ipx servers
//! that may make a link. Tokens are changed only here, as the directory's are.
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
/// Where the API and the episode pages listen.
#[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,
/// The address people open, which the links ipx is handed begin with.
#[serde(default = "default_public_url")]
pub public_url: String,
#[serde(default)]
pub tokens: Vec<TokenCfg>,
}
fn default_bind() -> String {
"0.0.0.0:8102".into()
}
fn default_database_url_env() -> String {
"PLAY_DATABASE_URL".into()
}
fn default_public_url() -> String {
"https://play.ipxreader.app".into()
}
/// An ipx server allowed to make links: its name, for the log and the shares it made, and only
/// its token's hash.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TokenCfg {
pub name: String,
/// What `ipx-play token new` prints.
pub token_sha256: String,
}
/// A token as the server checks it.
#[derive(Debug, Clone)]
pub struct Token {
pub name: String,
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 u = url::Url::parse(&cfg.public_url).context("public_url is not an address")?;
if !matches!(u.scheme(), "http" | "https") {
bail!("public_url is not an http(s) address");
}
Ok(cfg)
}
/// Refuses an empty or repeated name, and a hash that is not one or is listed twice: which
/// server made a link would be ambiguous.
pub fn tokens(&self) -> Result<Vec<Token>> {
let (mut names, mut hashes) = (HashSet::new(), HashSet::new());
self.tokens
.iter()
.map(|t| {
if t.name.trim().is_empty() || !names.insert(t.name.as_str()) {
bail!("token {:?}: each token needs a name of its own", t.name);
}
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 !hashes.insert(sha256.clone()) {
bail!("token {:?} is the same token as another", t.name);
}
Ok(Token { name: t.name.clone(), sha256 })
})
.collect()
}
/// The public address without a trailing slash, for links.
#[must_use]
pub fn base(&self) -> &str {
self.public_url.trim_end_matches('/')
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_config_reads_with_its_defaults_and_refuses_ambiguous_tokens() {
let h = "a".repeat(64);
let cfg = Config::parse(&format!("[[tokens]]\nname = \"tower\"\ntoken_sha256 = \"{h}\"")).unwrap();
assert_eq!((cfg.bind.as_str(), cfg.base()), ("0.0.0.0:8102", "https://play.ipxreader.app"));
assert_eq!(cfg.tokens().unwrap()[0].name, "tower");
let two = format!(
"[[tokens]]\nname = \"a\"\ntoken_sha256 = \"{h}\"\n[[tokens]]\nname = \"b\"\ntoken_sha256 = \"{h}\""
);
assert!(Config::parse(&two).unwrap().tokens().is_err(), "one token under two names");
assert!(Config::parse("[[tokens]]\nname = \"a\"\ntoken_sha256 = \"xyz\"").unwrap().tokens().is_err());
assert!(Config::parse("public_url = \"ftp://x\"").is_err());
}
}

316
crates/play/src/lib.rs Normal file
View File

@@ -0,0 +1,316 @@
//! `ipx-play` (#447): the page a shared episode opens, at `play.ipxreader.app/e/<id>`, as
//! Overcast's links do. When someone shares an episode, their ipx server sends it here with its
//! token and gets back a short link; anyone with the link sees the episode, its show and its
//! notes, and plays it from the publisher's own file, from the time it was shared at if it was.
//!
//! Only public episodes come here: ipx sends none from a private or paid feed, and this refuses
//! an address that looks like one (`ipx_feed::url_looks_private`), which would hand out the
//! sharer's subscription. The files stay the publisher's; this keeps what an episode is and where
//! its file is, nothing more.
pub mod config;
mod page;
pub mod store;
use axum::extract::{Path, Query, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Instant;
use tracing::Instrument;
/// An episode as ipx sends it to be shared.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Episode {
pub feed_url: String,
pub guid: String,
pub feed_title: Option<String>,
pub feed_image: Option<String>,
pub title: Option<String>,
/// The feed's own HTML for it, sanitized here when the page is drawn.
pub description: Option<String>,
pub link: Option<String>,
/// Unix seconds.
pub published: Option<i64>,
/// Seconds.
pub duration: Option<i64>,
/// The publisher's file, which the page plays.
pub audio_url: String,
pub mime: Option<String>,
}
/// What ipx gets back: the link to hand on, to which it adds `?t=` for a time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shared {
pub id: String,
pub url: String,
}
/// An episode kept, by its id.
#[derive(Debug, Clone)]
pub struct Share {
pub id: String,
pub episode: Episode,
}
/// Longer than any real one, short enough that nobody keeps a book here.
const MAX_TEXT: usize = 100_000;
const MAX_SHORT: usize = 2_000;
impl Episode {
/// What makes an episode unfit to keep, in words for whoever sent it; None when it is fine.
/// Over-long text is cut to fit rather than refused: a show's notes can be long.
fn check(&mut self) -> Option<&'static str> {
let web = |u: &str| url::Url::parse(u).is_ok_and(|u| matches!(u.scheme(), "http" | "https"));
if !web(&self.feed_url) || !web(&self.audio_url) {
return Some("the feed and its file need http(s) addresses");
}
if ipx_feed::url_looks_private(&self.feed_url) || ipx_feed::url_looks_private(&self.audio_url) {
return Some("that looks like a private or paid feed, whose addresses carry someone's access");
}
if self.guid.trim().is_empty() || self.guid.len() > MAX_SHORT {
return Some("an episode needs its guid");
}
if self.feed_image.as_deref().is_some_and(|u| !web(u)) {
self.feed_image = None;
}
if self.link.as_deref().is_some_and(|u| !web(u)) {
self.link = None;
}
for (s, max) in
[(&mut self.title, MAX_SHORT), (&mut self.feed_title, MAX_SHORT), (&mut self.description, MAX_TEXT)]
{
if let Some(t) = s
&& t.len() > max
{
let mut end = max;
while !t.is_char_boundary(end) {
end -= 1;
}
t.truncate(end);
}
}
None
}
}
/// What every request shares.
pub struct Play {
pub store: store::Store,
pub tokens: Vec<config::Token>,
pub base: String,
}
type St = Arc<Play>;
pub fn router(play: Play) -> Router {
let st = Arc::new(play);
let api = Router::new()
.route("/v1/shares", post(share))
.layer(axum::middleware::from_fn_with_state(st.clone(), authenticate));
Router::new()
.route("/healthz", get(healthz))
.route("/e/{id}", get(episode_page))
.route("/play.css", get(|| async { page::asset("text/css; charset=utf-8", include_bytes!("play.css")) }))
.route("/logo.svg", get(|| async { page::asset("image/svg+xml", include_bytes!("../../../web/logo.svg")) }))
.route("/favicon.png", get(|| async { page::asset("image/png", include_bytes!("../../../web/favicon.png")) }))
.merge(api)
.fallback(|| async { page::not_found() })
.layer(axum::middleware::from_fn(access_log))
.with_state(st)
}
/// Which server made the request, for the log and the share, set by `authenticate`.
#[derive(Clone)]
struct Who(String);
async fn authenticate(State(st): State<St>, 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 who = presented.and_then(|t| {
let h = ipx_directory::config::hash(t);
st.tokens.iter().find(|k| ipx_directory::config::same_secret(&k.sha256, &h)).map(|k| k.name.clone())
});
let Some(who) = who else {
return error(StatusCode::UNAUTHORIZED, "a valid token is needed: Authorization: Bearer <token>");
};
req.extensions_mut().insert(Who(who.clone()));
let mut resp = next.run(req).await;
resp.extensions_mut().insert(Who(who));
resp
}
fn error(status: StatusCode, msg: &str) -> Response {
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
#[tracing::instrument(name = "share", skip_all, fields(server = %who.0))]
async fn share(State(st): State<St>, Extension(who): Extension<Who>, Json(mut e): Json<Episode>) -> Response {
if let Some(why) = e.check() {
return error(StatusCode::UNPROCESSABLE_ENTITY, why);
}
match st.store.put(&who.0, &e).await {
Ok(id) => {
tracing::info!(server = %who.0, id = %id, "an episode was shared");
Json(Shared { url: format!("{}/e/{id}", st.base), id }).into_response()
}
Err(err) => {
tracing::error!(error = %format!("{err:#}"), "keeping a share failed");
error(StatusCode::INTERNAL_SERVER_ERROR, "it could not be kept; ipx-play's log says why")
}
}
}
#[derive(Deserialize)]
struct At {
/// Where to start: seconds, or as written, `12:34` or `1:02:03`.
t: Option<String>,
}
async fn episode_page(State(st): State<St>, Path(id): Path<String>, Query(at): Query<At>) -> Response {
match st.store.get(&id).instrument(tracing::info_span!("step", of = "get_share")).await {
Ok(Some(s)) => page::episode(&s, at.t.as_deref().and_then(page::parse_time), &st.base),
Ok(None) => page::not_found(),
Err(err) => {
tracing::error!(error = %format!("{err:#}"), "reading a share failed");
page::failed()
}
}
}
async fn healthz(State(st): State<St>) -> Response {
match st.store.ping().await {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => (StatusCode::SERVICE_UNAVAILABLE, format!("database: {e:#}")).into_response(),
}
}
/// A span a request, joined to the caller's trace when it sent a traceparent, as ipx does on its
/// calls here; and a line, with the server's name and never its token.
async fn access_log(req: Request, next: Next) -> Response {
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
let start = Instant::now();
let span = if path == "/healthz" {
tracing::Span::none()
} else {
tracing::info_span!(
"http",
otel.name = %format!("{method} {}", if path.starts_with("/e/") { "/e/{id}" } else { path.as_str() }),
http.request.method = %method,
url.path = %path,
http.response.status_code = tracing::field::Empty,
)
};
if let Some(parent) = ipx_directory::otel::remote_parent(req.headers(), None) {
use tracing_opentelemetry::OpenTelemetrySpanExt;
let _ = span.set_parent(parent);
}
let resp = next.run(req).instrument(span.clone()).await;
let status = resp.status().as_u16();
span.record("http.response.status_code", status);
if path != "/healthz" {
let _in = span.enter();
let who = resp.extensions().get::<Who>().map_or("", |w| w.0.as_str()).to_owned();
tracing::info!(
target: "ipx_play::http",
method = %method, path = %path, status, duration_ms = start.elapsed().as_millis() as u64, token = %who,
"{method} {path} -> {status}"
);
}
resp
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use tower::ServiceExt;
const TOKEN: &str = "ipxd_test";
async fn app() -> Router {
router(Play {
store: store::Store::scratch().await,
tokens: vec![config::Token { name: "tower".into(), sha256: ipx_directory::config::hash(TOKEN) }],
base: "https://play.test".into(),
})
}
fn episode() -> Episode {
Episode {
feed_url: "https://example.com/feed.xml".into(),
guid: "ep-1".into(),
feed_title: Some("A Show".into()),
feed_image: Some("https://example.com/art.jpg".into()),
title: Some("Episode <One>".into()),
description: Some("<p>Notes</p><script>alert(1)</script>".into()),
link: Some("javascript:alert(1)".into()),
published: Some(1_800_000_000),
duration: Some(3600),
audio_url: "https://example.com/ep1.mp3".into(),
mime: Some("audio/mpeg".into()),
}
}
async fn post(app: &Router, token: Option<&str>, e: &Episode) -> (StatusCode, serde_json::Value) {
let mut req = axum::http::Request::post("/v1/shares").header("content-type", "application/json");
if let Some(t) = token {
req = req.header("authorization", format!("Bearer {t}"));
}
let resp = app.clone().oneshot(req.body(Body::from(serde_json::to_vec(e).unwrap())).unwrap()).await.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice(&body).unwrap_or_default())
}
async fn get(app: &Router, path: &str) -> (StatusCode, String) {
let resp = app.clone().oneshot(axum::http::Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
let status = resp.status();
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, String::from_utf8_lossy(&body).into_owned())
}
#[tokio::test]
async fn a_server_with_a_token_makes_a_link_and_anyone_opens_it() {
let app = app().await;
assert_eq!(post(&app, None, &episode()).await.0, StatusCode::UNAUTHORIZED);
assert_eq!(post(&app, Some("wrong"), &episode()).await.0, StatusCode::UNAUTHORIZED);
let (status, body) = post(&app, Some(TOKEN), &episode()).await;
assert_eq!(status, StatusCode::OK);
let url = body["url"].as_str().unwrap();
assert!(url.starts_with("https://play.test/e/"), "{url}");
let path = url.trim_start_matches("https://play.test");
let (status, html) = get(&app, &format!("{path}?t=12:34")).await;
assert_eq!(status, StatusCode::OK);
assert!(html.contains("Episode &lt;One&gt;"), "the title, escaped");
assert!(html.contains("ep1.mp3#t=754"), "the player starts at the time shared");
assert!(html.contains("Starts at 12:34"));
assert!(html.contains(r#"property="og:title""#), "a link preview");
assert!(!html.contains("<script>alert"), "the notes are sanitized");
assert!(!html.contains("javascript:"), "a link that is not a web address is dropped");
assert_eq!(get(&app, "/e/nothere").await.0, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_private_feed_is_refused() {
let app = app().await;
let mut e = episode();
e.feed_url = "https://www.patreon.com/rss/show?auth=secret".into();
assert_eq!(post(&app, Some(TOKEN), &e).await.0, StatusCode::UNPROCESSABLE_ENTITY);
let mut e = episode();
e.audio_url = "file:///etc/passwd".into();
assert_eq!(post(&app, Some(TOKEN), &e).await.0, StatusCode::UNPROCESSABLE_ENTITY);
}
}

117
crates/play/src/main.rs Normal file
View File

@@ -0,0 +1,117 @@
//! `ipx-play`: serve the shared-episode pages, make a token for an ipx server, or check that a
//! running one answers.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ipx_play::{Play, config, store::Store};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::signal::unix::SignalKind;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
// mimalloc comes with ipx-directory, whose library sets it as the allocator.
#[derive(Parser)]
#[command(version, about = "The pages ipx's shared episodes open")]
struct Cli {
/// The config file. Otherwise PLAY_CONFIG, or else /config/play.toml.
#[arg(long)]
config: Option<PathBuf>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Serve the pages and the API (the default).
Serve,
/// Make a token for an ipx server, and print the lines that let it in.
Token {
/// Who it is for, as the log will name them.
name: String,
},
/// Check that ipx-play on this machine answers and can read its database.
Health,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let path = cli
.config
.or_else(|| std::env::var_os("PLAY_CONFIG").map(Into::into))
.unwrap_or_else(|| "/config/play.toml".into());
match cli.cmd.unwrap_or(Cmd::Serve) {
Cmd::Serve => serve(&path).await,
Cmd::Token { name } => {
let token = ipx_directory::config::new_token();
println!("A token for {name}, to enter in its Admin, Sharing; it is not kept anywhere:\n\n {token}\n");
println!("To let it in, add this to {} and restart ipx-play:\n", path.display());
println!("[[tokens]]\nname = {name:?}\ntoken_sha256 = {:?}", ipx_directory::config::hash(&token));
Ok(())
}
Cmd::Health => {
let cfg = config::Config::load(&path)?;
let port = cfg.bind.rsplit(':').next().unwrap_or("8102");
let resp = reqwest::Client::new()
.get(format!("http://127.0.0.1:{port}/healthz"))
.timeout(Duration::from_secs(4))
.send()
.await
.context("ipx-play does not answer")?;
anyhow::ensure!(resp.status().is_success(), "ipx-play answers {}", resp.status());
Ok(())
}
}
}
async fn serve(path: &Path) -> Result<()> {
// One JSON object a line for Loki when PLAY_LOG_FORMAT=json, as ipx and the directory do.
let json = std::env::var("PLAY_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let filter = || EnvFilter::try_from_env("PLAY_LOG").unwrap_or_else(|_| "ipx_play=info".into());
// Traces to Tempo when OTEL_EXPORTER_OTLP_ENDPOINT is set, joining ipx's.
let otel = ipx_directory::otel::provider("ipx-play")?;
let stderr: Box<dyn tracing_subscriber::Layer<tracing_subscriber::Registry> + Send + Sync> = if json {
ipx_directory::otel::json_layer(std::io::stderr, false).with_filter(filter()).boxed()
} else {
tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter()).boxed()
};
tracing_subscriber::registry()
.with(stderr)
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx-play"))
.with_filter(EnvFilter::new("ipx_play=info"))
}))
.init();
let cfg = config::Config::load(path)?;
// ponytail: tokens are read at start; a new server's token takes a restart. Reload the file
// on a timer, as the directory does, if servers come and go often.
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: ipx-play needs a database URL", cfg.database_url_env))?;
let store = Store::open(&url, true).await.context("opening the database")?;
let listener = TcpListener::bind(&cfg.bind).await.with_context(|| format!("listening on {}", cfg.bind))?;
tracing::info!(version = env!("CARGO_PKG_VERSION"), bind = %cfg.bind, tokens = tokens.len(), "ipx-play started");
let app = ipx_play::router(Play { store, tokens, base: cfg.base().to_owned() });
axum::serve(listener, app)
.with_graceful_shutdown(async {
let mut term = tokio::signal::unix::signal(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(())
}

235
crates/play/src/page.rs Normal file
View File

@@ -0,0 +1,235 @@
//! The episode page: what it is, a player that starts at the time shared, its notes, and how to
//! subscribe to the show in whatever app the reader has. No script: the start time is the media
//! fragment `#t=`, which every browser's player honours.
use crate::Share;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use std::fmt::Write as _;
/// Nothing runs on the page, nothing loads but the art and the file, and no other site may frame
/// it. Images and audio from anywhere: they are the publisher's.
const CSP: &str = "default-src 'none'; img-src https: http: data:; media-src https: http:; style-src 'self'; \
base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
pub fn asset(content_type: &'static str, body: &'static [u8]) -> Response {
([(header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, "public, max-age=86400")], body).into_response()
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
/// `?t=`: seconds (`754`), or as a clock reads (`12:34`, `1:02:03`). None for anything else.
#[must_use]
pub fn parse_time(t: &str) -> Option<u64> {
let parts: Vec<&str> = t.trim().split(':').collect();
if parts.is_empty()
|| parts.len() > 3
|| parts.iter().any(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
parts.iter().try_fold(0u64, |n, p| n.checked_mul(60)?.checked_add(p.parse().ok()?)).filter(|&s| s > 0)
}
/// `12:34`, or `1:02:03` past an hour, as the player shows it.
#[must_use]
pub fn clock(secs: u64) -> String {
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
/// `1 hr 2 min`, `45 min`.
fn length(secs: i64) -> String {
let m = (secs + 30) / 60;
match (m / 60, m % 60) {
(0, m) => format!("{} min", m.max(1)),
(h, 0) => format!("{h} hr"),
(h, m) => format!("{h} hr {m} min"),
}
}
/// The notes as words, for a link preview: their tags gone, cut at a word near `max` characters.
fn plain(html: &str, max: usize) -> String {
// A space where each tag was, or two paragraphs read as one word ("Jerrytalk"). A preview
// can afford the odd space before a bold word.
let text = ammonia::Builder::empty().clean(&html.replace('<', " <")).to_string();
let text = text
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&");
let words = text.split_whitespace().collect::<Vec<_>>().join(" ");
if words.chars().count() <= max {
return words;
}
let cut: String = words.chars().take(max).collect();
format!("{}…", cut.rsplit_once(' ').map_or(cut.as_str(), |(a, _)| a))
}
/// Where a podcast app takes a feed to subscribe to, by the address schemes each one answers.
/// On a phone without the app the link does nothing, as Overcast's own page's do.
fn apps(feed: &str) -> Vec<(&'static str, String)> {
let bare = feed.split_once("://").map_or(feed, |(_, rest)| rest);
let enc = percent_encoding::utf8_percent_encode(feed, percent_encoding::NON_ALPHANUMERIC).to_string();
vec![
("Apple Podcasts", format!("podcast://{bare}")),
("Overcast", format!("overcast://x-callback-url/add?url={enc}")),
("Pocket Casts", format!("pktc://subscribe/{bare}")),
("Castro", format!("castros://subscribe/{bare}")),
]
}
fn html(status: StatusCode, body: String) -> Response {
let mut resp = (status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response();
let h = resp.headers_mut();
h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
resp
}
fn frame(title: &str, head: &str, body: &str) -> String {
format!(
"<!doctype html><html lang=en><head><meta charset=utf-8>\
<meta name=viewport content=\"width=device-width, initial-scale=1\">\
<meta name=color-scheme content=\"light dark\"><title>{}</title>\
<link rel=icon href=/favicon.png><link rel=stylesheet href=/play.css>{head}</head>\
<body><main><a class=brand href=\"https://ipxreader.app\"><img src=/logo.svg alt=\"\" width=28 height=28>iPX</a>\
{body}<footer>Shared from <a href=\"https://ipxreader.app\">iPX</a>, a podcast and feed reader you run yourself.</footer>\
</main></body></html>",
esc(title)
)
}
pub fn not_found() -> Response {
html(
StatusCode::NOT_FOUND,
frame("Not here", "", "<h1>Not here</h1><p class=meta>That link is not one iPX made, or it was mistyped.</p>"),
)
}
pub fn failed() -> Response {
html(
StatusCode::SERVICE_UNAVAILABLE,
frame("Try again", "", "<h1>Try again</h1><p class=meta>The episode could not be read just now.</p>"),
)
}
pub fn episode(s: &Share, at: Option<u64>, base: &str) -> Response {
let e = &s.episode;
let show = e.feed_title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("A podcast");
let title = e.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or("Untitled episode");
let notes = e.description.as_deref().map(ammonia::clean).filter(|d| !d.trim().is_empty());
let summary = e.description.as_deref().map(|d| plain(d, 200)).filter(|d| !d.is_empty());
let url = format!("{base}/e/{}", s.id);
let mut head = String::new();
let _ = write!(
head,
"<meta property=\"og:type\" content=\"website\"><meta property=\"og:site_name\" content=\"iPX\">\
<meta property=\"og:title\" content=\"{}\"><meta property=\"og:url\" content=\"{}\">\
<meta property=\"og:audio\" content=\"{}\"><meta name=\"twitter:card\" content=\"summary\">",
esc(title),
esc(&url),
esc(&e.audio_url)
);
let desc = match &summary {
Some(sum) => format!("{show}: {sum}"),
None => show.to_owned(),
};
let _ = write!(
head,
"<meta property=\"og:description\" content=\"{0}\"><meta name=description content=\"{0}\">",
esc(&desc)
);
if let Some(img) = &e.feed_image {
let _ = write!(head, "<meta property=\"og:image\" content=\"{}\">", esc(img));
}
if let Some(m) = &e.mime {
let _ = write!(head, "<meta property=\"og:audio:type\" content=\"{}\">", esc(m));
}
let mut b = String::new();
b.push_str("<article class=ep><div class=head>");
match &e.feed_image {
Some(img) => {
let _ = write!(b, "<img class=art src=\"{}\" alt=\"\" width=160 height=160>", esc(img));
}
None => {
let initials: String = show.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
let _ = write!(b, "<div class=\"art tile\" aria-hidden=true>{}</div>", esc(&initials.to_uppercase()));
}
}
let mut meta = Vec::new();
if let Some(p) = e.published.and_then(|p| chrono::DateTime::from_timestamp(p, 0)) {
meta.push(p.format("%B %-d, %Y").to_string());
}
if let Some(d) = e.duration.filter(|d| *d > 0) {
meta.push(length(d));
}
let _ = write!(
b,
"<div><p class=show>{}</p><h1>{}</h1><p class=meta>{}</p></div></div>",
esc(show),
esc(title),
esc(&meta.join(" · "))
);
// The file as the publisher serves it: a show that inserts ads by listener can move the time
// by the length of an ad, as Overcast's links do.
let src = match at {
Some(t) => format!("{}#t={t}", e.audio_url),
None => e.audio_url.clone(),
};
if let Some(t) = at {
let _ = write!(b, "<p class=at>Starts at {}</p>", clock(t));
}
let _ = write!(b, "<audio controls preload=metadata src=\"{}\"></audio>", esc(&src));
if let Some(link) = &e.link {
let _ = write!(
b,
"<p class=more><a href=\"{}\" rel=\"noopener nofollow\">The episode's own page</a></p>",
esc(link)
);
}
if let Some(n) = notes {
let _ = write!(b, "<section class=notes><h2>Notes</h2>{n}</section>");
}
let _ = write!(b, "<section class=sub><h2>Subscribe to {}</h2><p class=apps>", esc(show));
for (name, href) in apps(&e.feed_url) {
let _ = write!(b, "<a class=app href=\"{}\">{name}</a>", esc(&href));
}
let _ = write!(
b,
"</p><label for=feed>Or paste the feed's address into any podcast app</label>\
<input id=feed readonly value=\"{}\" spellcheck=false></section></article>",
esc(&e.feed_url)
);
html(StatusCode::OK, frame(&format!("{title} · {show}"), &head, &b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_time_reads_as_seconds_or_a_clock() {
assert_eq!(parse_time("754"), Some(754));
assert_eq!(parse_time("12:34"), Some(754));
assert_eq!(parse_time("1:02:03"), Some(3723));
for bad in ["", "0", "-5", "1:2:3:4", "12:", "abc", "99999999999999999999999"] {
assert_eq!(parse_time(bad), None, "{bad:?}");
}
assert_eq!((clock(754), clock(3723)), ("12:34".to_owned(), "1:02:03".to_owned()));
assert_eq!((length(2700), length(3600), length(3720)), ("45 min".into(), "1 hr".into(), "1 hr 2 min".into()));
}
#[test]
fn a_preview_has_the_notes_as_words() {
assert_eq!(plain("<p>Tom &amp; Jerry</p><p>talk</p>", 200), "Tom & Jerry talk");
assert_eq!(plain("one two three four", 9), "one two…");
}
}

70
crates/play/src/play.css Normal file
View File

@@ -0,0 +1,70 @@
:root {
--bg: #f6f7f9;
--card: #ffffff;
--ink: #111827;
--muted: #5b6472;
--line: #e2e6ec;
--accent: #e8590c;
--tile: #dbe3ee;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1424;
--card: #111d31;
--ink: #e8edf4;
--muted: #9aa7b8;
--line: #22324a;
--accent: #ff7a2f;
--tile: #1d2c44;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 680px; margin: 0 auto; padding: 20px 16px 48px; }
a { color: var(--accent); }
.brand {
display: inline-flex; align-items: center; gap: 8px;
color: var(--ink); font-weight: 700; text-decoration: none; margin-bottom: 20px;
}
.ep { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 20px; }
.head { display: flex; gap: 18px; align-items: center; }
.art { width: 120px; height: 120px; border-radius: 12px; object-fit: cover; flex: none; }
.tile {
display: grid; place-items: center; background: var(--tile);
font-size: 36px; font-weight: 700; color: var(--muted);
}
.show { margin: 0; color: var(--accent); font-weight: 600; font-size: 15px; }
h1 { margin: 2px 0 4px; font-size: 22px; line-height: 1.25; overflow-wrap: anywhere; }
h2 { font-size: 15px; margin: 0 0 8px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.meta { margin: 0; color: var(--muted); font-size: 14px; }
.at {
display: inline-block; margin: 18px 0 0; padding: 4px 10px; border-radius: 999px;
background: var(--tile); font-size: 14px; font-weight: 600;
}
audio { display: block; width: 100%; margin-top: 14px; }
.more { margin: 12px 0 0; font-size: 15px; }
.notes, .sub { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
.notes { overflow-wrap: anywhere; }
.notes img { max-width: 100%; height: auto; }
.apps { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 14px; }
.app {
padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line);
color: var(--ink); text-decoration: none; font-size: 15px; font-weight: 600;
}
.app:hover { border-color: var(--accent); }
label { display: block; color: var(--muted); font-size: 14px; margin-bottom: 6px; }
input {
width: 100%; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--bg); color: var(--ink); font: 14px ui-monospace, Menlo, monospace;
}
footer { margin-top: 24px; color: var(--muted); font-size: 14px; text-align: center; }
@media (max-width: 480px) {
.head { flex-direction: column; align-items: flex-start; }
.art { width: 100%; height: auto; aspect-ratio: 1; max-width: 240px; }
}

200
crates/play/src/store.rs Normal file
View File

@@ -0,0 +1,200 @@
//! The episodes people have shared. SQL written by hand, as the directory's is, so it runs on
//! SQLite (tests) and Postgres (production) both.
use crate::{Episode, Share};
use anyhow::Result;
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, QueryResult, Statement, Value};
use std::time::SystemTime;
const SCHEMA: &[&str] = &["CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
feed_url TEXT NOT NULL,
guid TEXT NOT NULL,
feed_title TEXT,
feed_image TEXT,
title TEXT,
description TEXT,
link TEXT,
published BIGINT,
duration BIGINT,
audio_url TEXT NOT NULL,
mime TEXT,
instance TEXT NOT NULL,
created BIGINT NOT NULL,
updated BIGINT NOT NULL
)"];
pub struct Store {
db: DatabaseConnection,
}
#[must_use]
pub fn now() -> i64 {
SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
}
/// An episode's id: ten letters and digits from the SHA-256 of its feed's address (written as
/// the directory writes it) and its guid, so sharing it again, from any server, gives the same
/// link, and nothing needs looking up to make one. 50 bits: a collision is not expected before
/// some billions of episodes.
#[must_use]
pub fn share_id(feed_url: &str, guid: &str) -> String {
use sha2::Digest;
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
let feed = ipx_directory::store::normalise(feed_url).unwrap_or_else(|| feed_url.trim().to_owned());
let digest = sha2::Sha256::digest(format!("{feed}\n{guid}").as_bytes());
let bits = digest[..8].iter().fold(0u64, |n, b| (n << 8) | u64::from(*b));
(0..10).map(|i| ALPHABET[((bits >> (59 - 5 * i)) & 31) as usize] as char).collect()
}
impl Store {
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=") {
// A notice for every CREATE ... IF NOT EXISTS on something there, otherwise.
let sep = if url.contains('?') { '&' } else { '?' };
url = format!("{url}{sep}options=-c%20client_min_messages%3Dwarning");
}
let mut opts = ConnectOptions::new(url.clone());
opts.sqlx_logging(false);
// An in-memory SQLite database is one per connection.
if url.contains(":memory:") {
opts.max_connections(1);
}
// Kept open: a Postgres connection takes 80-330 ms to open here (#241).
if keep_open && url.starts_with("postgres") {
opts.max_connections(4).min_connections(2);
}
let db = Database::connect(opts).await?;
for sql in SCHEMA {
db.execute_unprepared(sql).await?;
}
Ok(Self { db })
}
#[cfg(test)]
pub async fn scratch() -> Self {
Self::open("sqlite::memory:", false).await.unwrap()
}
fn stmt(&self, sql: &str, values: Vec<Value>) -> Statement {
Statement::from_sql_and_values(self.db.get_database_backend(), sql, values)
}
async fn rows(&self, sql: &str, values: Vec<Value>) -> Result<Vec<QueryResult>> {
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
}
pub async fn ping(&self) -> Result<()> {
self.rows("SELECT 1", vec![]).await?;
Ok(())
}
/// Keeps an episode, or what it says now if it was shared before, and gives its id. The
/// server that first shared it stays its sharer.
pub async fn put(&self, instance: &str, e: &Episode) -> Result<String> {
let id = share_id(&e.feed_url, &e.guid);
let t = now();
self.db
.execute_raw(self.stmt(
"INSERT INTO shares (id, feed_url, guid, feed_title, feed_image, title, description, link,
published, duration, audio_url, mime, instance, created, updated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
ON CONFLICT (id) DO UPDATE SET feed_title = excluded.feed_title, feed_image = excluded.feed_image,
title = excluded.title, description = excluded.description, link = excluded.link,
published = excluded.published, duration = excluded.duration, audio_url = excluded.audio_url,
mime = excluded.mime, updated = excluded.updated",
vec![
id.clone().into(),
e.feed_url.clone().into(),
e.guid.clone().into(),
e.feed_title.clone().into(),
e.feed_image.clone().into(),
e.title.clone().into(),
e.description.clone().into(),
e.link.clone().into(),
e.published.into(),
e.duration.into(),
e.audio_url.clone().into(),
e.mime.clone().into(),
instance.into(),
t.into(),
],
))
.await?;
Ok(id)
}
pub async fn get(&self, id: &str) -> Result<Option<Share>> {
let Some(r) = self
.rows(
"SELECT feed_url, guid, feed_title, feed_image, title, description, link, published, duration,
audio_url, mime FROM shares WHERE id = $1",
vec![id.into()],
)
.await?
.into_iter()
.next()
else {
return Ok(None);
};
Ok(Some(Share {
id: id.to_owned(),
episode: Episode {
feed_url: r.try_get("", "feed_url")?,
guid: r.try_get("", "guid")?,
feed_title: r.try_get("", "feed_title")?,
feed_image: r.try_get("", "feed_image")?,
title: r.try_get("", "title")?,
description: r.try_get("", "description")?,
link: r.try_get("", "link")?,
published: r.try_get("", "published")?,
duration: r.try_get("", "duration")?,
audio_url: r.try_get("", "audio_url")?,
mime: r.try_get("", "mime")?,
},
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn episode() -> Episode {
Episode {
feed_url: "https://example.com/feed.xml".into(),
guid: "ep-1".into(),
feed_title: Some("A Show".into()),
feed_image: Some("https://example.com/art.jpg".into()),
title: Some("Episode One".into()),
description: Some("<p>Notes</p>".into()),
link: None,
published: Some(1_800_000_000),
duration: Some(3600),
audio_url: "https://example.com/ep1.mp3".into(),
mime: Some("audio/mpeg".into()),
}
}
#[test]
fn an_episode_has_one_short_id_whoever_shares_it() {
let a = share_id("https://example.com/feed.xml", "ep-1");
assert_eq!(a.len(), 10);
assert!(a.bytes().all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)));
assert_eq!(a, share_id("HTTPS://Example.com/feed.xml", "ep-1"), "the address as every server writes it");
assert_ne!(a, share_id("https://example.com/feed.xml", "ep-2"));
}
#[tokio::test]
async fn sharing_again_keeps_the_link_and_takes_what_the_episode_says_now() {
let s = Store::scratch().await;
let mut e = episode();
let id = s.put("tower", &e).await.unwrap();
e.title = Some("Episode One, fixed".into());
assert_eq!(s.put("elsewhere", &e).await.unwrap(), id);
let got = s.get(&id).await.unwrap().unwrap();
assert_eq!(got.episode, e);
assert!(s.get("nothere").await.unwrap().is_none());
}
}

17
crates/tui/CHANGELOG.md Normal file
View File

@@ -0,0 +1,17 @@
# Changelog: ipx-tui
The reader in a terminal (README.md). ipx's changelog is [crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md).
Its version is in `crates/tui/Cargo.toml`.
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]
### Added
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
- `p` plays the item's file in mpv (#439).
- Moving onto an item with `j` or `k` marks it read, as on the page (#441).
- Feeds are listed as on the page: a subscribed OPML is a folder `Space` opens, and pinned feeds are at the top; `P` pins and unpins (#440).

21
crates/tui/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "ipx-tui"
version = "0.1.0-dev"
edition.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "ipx"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["env"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true
serde_json.workspace = true

32
crates/tui/README.md Normal file
View File

@@ -0,0 +1,32 @@
# ipx-tui
The `ipx` command. The server, which had that name, is `ipxd`.
iPX's reader in a terminal: your feeds on the left, a feed's items above, the item's text below.
It talks to iPX's API as the page does, as you, with an API token made in the page's Settings.
```sh
cargo build --release -p ipx-tui
IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
```
`--url` and `--token` do the same as the two variables.
| key | does |
|---|---|
| `j` `k`, arrows | move, or scroll the item's text |
| `Enter`, `l`, `Tab` | open the feed or the item |
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
| `Space`, `PgUp` `PgDn` | page through the item's text |
| `Space` | on the feeds, open or close a folder: a subscribed OPML, its feeds inside |
| `P` | pin the feed or folder to the top of the list, or unpin it |
| `p` | play the item's file in [mpv](https://mpv.io), in a window of its own |
| `m` | mark the item read or unread |
| `f` | flag or unflag it |
| `u` | unread items only, or every item (unread only to start) |
| `r` | reload |
| `q` | quit |
An item is marked read when you move onto it or open it, as on the page. It shows a feed's newest
200 items. Playing needs `mpv` on the path (`brew install mpv` on a Mac),
and where you stop is not saved to iPX as it is on the page.

641
crates/tui/src/main.rs Normal file
View File

@@ -0,0 +1,641 @@
//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the
//! same API the page uses, as the person whose API token it is given.
use anyhow::{Context, Result, bail};
use clap::Parser;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use reqwest::Url;
use serde::Deserialize;
use std::collections::HashSet;
use std::time::Duration;
#[derive(Parser)]
#[command(name = "ipx", version, about = "Read iPX in a terminal")]
struct Args {
/// Where iPX is, as a browser reaches it.
#[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")]
url: Url,
/// An API token, made in the page's Settings.
#[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)]
token: String,
}
/// The part of web.rs's FeedRow this needs.
#[derive(Deserialize)]
struct Feed {
id: String,
url: String,
title: Option<String>,
unread: i64,
/// The OPML it came from.
group: Option<String>,
hide_from_all: bool,
pinned: bool,
}
/// The feed list as the page draws it (feeds.ts renderFeeds), as indexes into `feeds` and
/// whether each is inside a folder: pinned rows first, a subscribed OPML a folder with its feeds
/// under it when open (those with unread first), and a pinned feed lifted out of its folder.
fn order(feeds: &[Feed], open: &HashSet<String>) -> Vec<(usize, bool)> {
let listed = |id: &str| feeds.iter().any(|f| f.id == id);
let mut tops: Vec<usize> =
(0..feeds.len()).filter(|&i| feeds[i].pinned || !feeds[i].group.as_deref().is_some_and(listed)).collect();
tops.sort_by_key(|&i| !feeds[i].pinned);
let mut rows = vec![];
for t in tops {
rows.push((t, false));
if open.contains(&feeds[t].id) {
let mut inside: Vec<usize> = (0..feeds.len())
.filter(|&i| !feeds[i].pinned && feeds[i].group.as_deref() == Some(feeds[t].id.as_str()))
.collect();
inside.sort_by_key(|&i| feeds[i].unread == 0);
rows.extend(inside.into_iter().map(|i| (i, true)));
}
}
rows
}
/// The part of db.rs's EntryRow this needs.
#[derive(Deserialize)]
struct Entry {
guid: String,
feed_id: String,
title: Option<String>,
link: Option<String>,
published: Option<i64>,
description: Option<String>,
read: bool,
flagged: bool,
enclosures: Vec<Enclosure>,
}
/// The part of db.rs's EncRow this needs: its id is its address, `/media/{id}`, which is there
/// only once it is downloaded (`path`); one skipped or still to come answers 404.
#[derive(Deserialize)]
struct Enclosure {
id: i64,
path: Option<String>,
}
/// What `p` plays: the item's first file iPX has.
fn playable(e: &Entry) -> Option<&Enclosure> {
e.enclosures.iter().find(|x| x.path.is_some())
}
#[derive(Deserialize)]
struct EntryPage {
total: i64,
entries: Vec<Entry>,
}
struct Api {
http: reqwest::blocking::Client,
base: Url,
token: String,
}
impl Api {
/// The API's address for these path segments, each escaped: a guid is often a URL itself.
fn url(&self, segments: &[&str]) -> Url {
let mut url = self.base.clone();
url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments);
url
}
fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result<reqwest::blocking::Response> {
let res = req.bearer_auth(&self.token).send()?;
if !res.status().is_success() {
bail!("{} {}", res.status(), res.text().unwrap_or_default());
}
Ok(res)
}
/// Plays a file in mpv, in a window of its own so the terminal stays the reader's, with the
/// token in a header as every other request has it.
// shortcut: the token is on mpv's command line, which `ps` shows anyone on this machine;
// pass it through a private mpv config file if the reader is used on a shared one.
fn play(&self, enc: &Enclosure) -> Result<()> {
let url = self.url(&["media", &enc.id.to_string()]);
std::process::Command::new("mpv")
.arg("--force-window=yes")
.arg(format!("--http-header-fields=Authorization: Bearer {}", self.token))
.arg(url.as_str())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("could not start mpv; is it installed?")?;
Ok(())
}
fn set_pinned(&self, feed: &str, on: bool) -> Result<()> {
let req = self.http.patch(self.url(&["api", "feeds", feed])).json(&serde_json::json!({ "pinned": on }));
self.send(req).map(drop)
}
fn feeds(&self) -> Result<Vec<Feed>> {
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
}
/// The newest items of one feed, or of all of them when `feed` is None.
// shortcut: the newest 200 only, no paging; page through `offset` if someone wants older.
fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result<EntryPage> {
let mut url = match feed {
Some(id) => self.url(&["api", "feeds", id, "entries"]),
None => self.url(&["api", "entries"]),
};
let filter = if unread_only { "unread" } else { "all" };
url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter);
Ok(self.send(self.http.get(url))?.json()?)
}
/// Sets an item's read or flagged, and answers with its feed's row, for the new unread count.
fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result<Option<Feed>> {
let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]);
Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?)
}
}
#[derive(PartialEq, Clone, Copy)]
enum Pane {
Feeds,
Items,
Text,
}
struct App {
api: Api,
feeds: Vec<Feed>,
/// What the feed list shows, after All Subscriptions, which is index 0 of `feed_list`.
rows: Vec<(usize, bool)>,
/// The folders open; they start closed, as on the page, since one can hold dozens of feeds.
open: HashSet<String>,
feed_list: ListState,
entries: Vec<Entry>,
total: i64,
entry_list: ListState,
pane: Pane,
scroll: u16,
unread_only: bool,
status: String,
}
impl App {
fn feed(&self) -> Option<&Feed> {
let i = self.feed_list.selected()?;
self.rows.get(i.checked_sub(1)?).map(|&(f, _)| &self.feeds[f])
}
fn feed_id(&self) -> Option<&str> {
self.feed().map(|f| f.id.as_str())
}
/// The feed list again after the feeds or the open folders change, on the same feed, or on
/// its folder when that has just closed over it.
fn arrange(&mut self) {
let was = self.feed().map(|f| (f.id.clone(), f.group.clone()));
self.rows = order(&self.feeds, &self.open);
let at = |id: &str| self.rows.iter().position(|&(f, _)| self.feeds[f].id == id).map(|i| i + 1);
let row = was.and_then(|(id, group)| at(&id).or_else(|| group.and_then(|g| at(&g))));
self.feed_list.select(Some(row.unwrap_or(0)));
}
/// The unread count a row shows: a folder holds no items itself, so it is what is inside.
fn unread(&self, f: &Feed) -> i64 {
let inside: Vec<_> = self.feeds.iter().filter(|c| c.group.as_deref() == Some(f.id.as_str())).collect();
if inside.is_empty() { f.unread } else { inside.iter().map(|c| c.unread).sum() }
}
fn entry(&self) -> Option<&Entry> {
self.entries.get(self.entry_list.selected()?)
}
/// Runs an API call, putting what went wrong in the status line instead of ending the program.
fn attempt<T>(&mut self, what: Result<T>) -> Option<T> {
what.map_err(|e| self.status = format!("{e:#}")).ok()
}
fn load_feeds(&mut self) {
if let Some(feeds) = self.attempt(self.api.feeds()) {
self.feeds = feeds;
self.arrange();
self.status.clear();
}
}
fn load_entries(&mut self) {
let page = self.api.entries(self.feed_id(), self.unread_only);
if let Some(page) = self.attempt(page) {
self.entries = page.entries;
self.total = page.total;
self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) });
self.scroll = 0;
self.status.clear();
}
}
/// Sets the selected item's read or flagged, as the page does.
fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) {
let Some(e) = self.entry() else { return };
let on = on(e);
let result = self.api.set_flag(e, flag, on);
let Some(row) = self.attempt(result) else { return };
if let Some(i) = self.entry_list.selected() {
let e = &mut self.entries[i];
if flag == "read" { e.read = on } else { e.flagged = on }
}
if let Some(row) = row
&& let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id)
{
f.unread = row.unread;
}
}
/// Selecting an item is reading it, as on the page (items.ts markRead), j and k included.
fn mark_read(&mut self) {
if self.entry().is_some_and(|e| !e.read) {
self.flag("read", |_| true);
}
}
/// Opens the selected item, which marks it read, as opening one on the page does.
fn open(&mut self) {
self.scroll = 0;
self.pane = Pane::Text;
self.mark_read();
}
fn step(&mut self, down: bool) {
let list = match self.pane {
Pane::Feeds => &mut self.feed_list,
Pane::Items => &mut self.entry_list,
Pane::Text => {
self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) };
return;
}
};
if down {
list.select_next()
} else {
list.select_previous()
}
if self.pane == Pane::Items {
self.scroll = 0;
self.mark_read();
}
}
/// Handles a key, answering false when it is time to quit.
fn key(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char('q') => return false,
KeyCode::Esc if self.pane == Pane::Feeds => return false,
KeyCode::Char('j') | KeyCode::Down => self.step(true),
KeyCode::Char('k') | KeyCode::Up => self.step(false),
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
self.scroll = self.scroll.saturating_add(10)
}
KeyCode::Char(' ') if self.pane == Pane::Feeds => {
if let Some(id) = self.feed_id().map(str::to_owned) {
if !self.open.remove(&id) {
self.open.insert(id);
}
self.arrange();
}
}
KeyCode::Char('P') => {
let Some(f) = self.feed() else { return true };
let (id, on) = (f.id.clone(), !f.pinned);
if self.attempt(self.api.set_pinned(&id, on)).is_some() {
self.load_feeds();
self.status = if on { "pinned to the top" } else { "unpinned" }.into();
}
}
KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
Pane::Feeds => {
self.load_entries();
self.pane = Pane::Items;
}
Pane::Items => self.open(),
Pane::Text => {}
},
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
self.pane = match self.pane {
Pane::Text => Pane::Items,
_ => Pane::Feeds,
}
}
KeyCode::Char('p') => {
let Some(e) = self.entry() else { return true };
match playable(e).map(|enc| self.api.play(enc)) {
Some(r) => {
if self.attempt(r).is_some() {
self.status = "playing in mpv".into();
}
}
None if e.enclosures.is_empty() => self.status = "this item has no file to play".into(),
None => self.status = "iPX has not downloaded this item's file".into(),
}
}
KeyCode::Char('m') => self.flag("read", |e| !e.read),
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
KeyCode::Char('u') => {
self.unread_only = !self.unread_only;
self.load_entries();
}
KeyCode::Char('r') => {
self.load_feeds();
self.load_entries();
}
_ => {}
}
true
}
fn draw(&mut self, frame: &mut Frame) {
let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main);
let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right);
let block = |title: String, pane: Pane| {
let b = Block::bordered().title(title);
if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() }
};
let picked = Style::new().add_modifier(Modifier::REVERSED);
// What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or
// one from an OPML that is (#288).
let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all);
let all: i64 = self
.feeds
.iter()
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
.map(|f| f.unread)
.sum();
let feeds =
std::iter::once(format!("All Subscriptions ({all})")).chain(self.rows.iter().map(|&(i, inside)| {
let f = &self.feeds[i];
let folder = self.feeds.iter().any(|c| c.group.as_deref() == Some(f.id.as_str()));
let lead = match (inside, folder, self.open.contains(&f.id)) {
(true, ..) => " ",
(_, true, true) => "▾ ",
(_, true, false) => "▸ ",
_ => " ",
};
let pin = if f.pinned && !inside { "↑ " } else { "" };
let name = f.title.as_deref().unwrap_or(&f.url);
match self.unread(f) {
0 => format!("{lead}{pin}{name}"),
n => format!("{lead}{pin}{name} ({n})"),
}
}));
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
let items = self.entries.iter().map(|e| {
let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' });
let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)"));
if e.read { ListItem::new(line) } else { ListItem::new(line).bold() }
});
let shown = if self.unread_only { "unread" } else { "items" };
let title = format!("{} of {} {shown}", self.entries.len(), self.total);
let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked);
frame.render_stateful_widget(items, top, &mut self.entry_list);
let text = match self.entry() {
Some(e) => {
let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()];
if let Some(link) = &e.link {
lines.push(Line::from(link.clone()).dim());
}
if playable(e).is_some() {
lines.push(Line::from("▶ p to play").dim());
}
lines.push(Line::default());
lines
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
lines
}
None => vec![],
};
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
let help = "j/k move enter open h back space folder P pin p play m read f flag u unread only r reload q quit";
let foot_text = if self.status.is_empty() { help } else { &self.status };
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
}
}
fn date(t: Option<i64>) -> String {
t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
}
/// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own,
/// entities decoded. ipx has already cleaned it, so there is no script or style to skip.
fn text_of(html: &str) -> String {
let mut out = String::new();
let mut rest = html;
loop {
let (text, tag) = match rest.find('<') {
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
None => (rest, None),
};
// HTML's own whitespace is not layout; the tags say where lines break.
if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) {
out.push(' ');
}
out.push_str(&decode(&text.split_whitespace().collect::<Vec<_>>().join(" ")));
if text.ends_with(char::is_whitespace) && !text.trim().is_empty() {
out.push(' ');
}
let Some(tag) = tag else { break };
let Some(end) = tag.find('>') else { break };
let (inner, closing) = match tag[..end].strip_prefix('/') {
Some(t) => (t, true),
None => (&tag[..end], false),
};
let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase();
match name.as_str() {
"br" => out.push('\n'),
"li" => {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !closing {
out.push_str("• ");
}
}
"p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
if !out.is_empty() && !out.ends_with("\n\n") =>
{
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
}
_ => {}
}
rest = &tag[end + 1..];
}
out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim().to_string()
}
/// HTML's entities, the named ones feeds use and every numbered one.
fn decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let end = rest[..rest.len().min(12)].find(';');
let ch = end.and_then(|end| match &rest[1..end] {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"hellip" => Some('…'),
"mdash" => Some('—'),
"ndash" => Some('–'),
"rsquo" => Some('’'),
"lsquo" => Some('‘'),
"rdquo" => Some('”'),
"ldquo" => Some('“'),
n => {
let n = n.strip_prefix('#')?;
let code = match n.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => n.parse().ok()?,
};
char::from_u32(code)
}
});
match (ch, end) {
(Some(c), Some(end)) => {
out.push(c);
rest = &rest[end + 1..];
}
_ => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| app.draw(f))?;
// A poll, not a blocking read, so a resize redraws without waiting for a key.
if !event::poll(Duration::from_millis(500))? {
continue;
}
if let Event::Key(k) = event::read()?
&& k.kind == KeyEventKind::Press
&& !app.key(k.code)
{
return Ok(());
}
}
}
fn main() -> Result<()> {
let args = Args::parse();
let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?;
let api = Api { http, base: args.url, token: args.token };
// Before the terminal is taken over, so a wrong address or token is said plainly.
let feeds = api.feeds().context("could not read your feeds")?;
let mut app = App {
api,
feeds,
rows: vec![],
open: HashSet::new(),
feed_list: ListState::default().with_selected(Some(0)),
entries: vec![],
total: 0,
entry_list: ListState::default(),
pane: Pane::Feeds,
scroll: 0,
unread_only: true,
status: String::new(),
};
app.arrange();
app.load_entries();
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut app);
ratatui::restore();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_of_html() {
let html = "<p>One &amp; two,\n <b>bold</b>.</p><p>Next&nbsp;line<br>broken &#8212; &#x2019;s &bogus; &</p>\
<ul><li>a</li><li>b</li></ul>";
assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b");
assert_eq!(text_of("plain"), "plain");
assert_eq!(text_of("a <i>b</i> c"), "a b c");
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
}
#[test]
fn feeds_in_the_pages_order() {
let feed = |id: &str, group: Option<&str>, pinned: bool, unread: i64| Feed {
id: id.into(),
url: id.into(),
title: None,
unread,
group: group.map(Into::into),
hide_from_all: false,
pinned,
};
let feeds = [
feed("a", None, false, 0),
feed("opml", None, false, 0),
feed("in-read", Some("opml"), false, 0),
feed("in-unread", Some("opml"), false, 3),
feed("in-pinned", Some("opml"), true, 0),
feed("b", None, true, 0),
feed("lost", Some("gone"), false, 0),
];
let ids = |open: &[&str]| -> Vec<(&str, bool)> {
let open = open.iter().map(|s| s.to_string()).collect();
order(&feeds, &open).into_iter().map(|(i, inside)| (feeds[i].id.as_str(), inside)).collect()
};
// Closed, a folder hides its feeds; a pinned one is out of it, at the top; a feed whose
// folder is not listed stands on its own.
assert_eq!(ids(&[]), [("in-pinned", false), ("b", false), ("a", false), ("opml", false), ("lost", false)]);
assert_eq!(
ids(&["opml"]),
[
("in-pinned", false),
("b", false),
("a", false),
("opml", false),
("in-unread", true),
("in-read", true),
("lost", false),
]
);
}
#[test]
fn plays_the_first_downloaded_file() {
let e: Entry = serde_json::from_value(serde_json::json!({
"guid": "g", "feed_id": "f", "title": null, "link": null, "published": null,
"description": null, "read": false, "flagged": false,
"enclosures": [{"id": 1, "path": null}, {"id": 2, "path": "a.mp3"}, {"id": 3, "path": "b.mp3"}],
}))
.unwrap();
assert_eq!(playable(&e).map(|x| x.id), Some(2));
}
}

View File

@@ -5,7 +5,7 @@ Three services, each a container of its own with its own version and changelog (
| 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 | The daemon and CLI, one binary, `ipxd`. `ipxd 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
@@ -27,7 +27,8 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
| File | Responsibility | What it replaced in the Python |
|---|---|---|
| `crates/ipx/src/main.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
| `crates/ipx/src/lib.rs` | The module list and `main`: reads the command line and hands it on | `iPXAgent.py` |
| `crates/ipx/src/main.rs` | `ipxd`, which calls `main`: a library under it keeps the modules' log targets `ipx::...` | — |
| `crates/ipx/src/cli.rs` | The commands, and what each does when no daemon takes it | `iPXAgent.py` |
| `crates/ipx/src/ctx.rs` | `Ctx`, what every command shares: configuration, database, clients | — |
| `crates/ipx/src/daemon.rs` | The daemon: worker loop, web server, shutdown, `status` | `iPXAgent.py` |
@@ -68,12 +69,13 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
| `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 | — |
| `crates/tui/src/main.rs` | `ipx` (package `ipx-tui`), the reader in a terminal: ratatui over the API, with an API token | — |
| `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`/`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/build.mts` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
| `web/boot.ts` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
@@ -156,7 +158,7 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
`ipxd 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`,
@@ -213,10 +215,10 @@ 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
node tests/page-smoke.ts # the page script loads and every selector it wires at load exists
node tests/native-bridge.ts # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated feeds
node tests/load/run.ts # k6: many people at once against a daemon with 1,500 generated feeds
```
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that

View File

@@ -10,15 +10,15 @@ processes must never download the same thing. `--local` forces the work to happe
| Command | What it does |
|---|---|
| `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] [--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 the HTTP API (the page is ipx-web's) |
| `ipxd list` | Subscriptions and their state |
| `ipxd status` | Counts: feeds, pending, downloaded |
| `ipxd fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
| `ipxd 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 |
| `ipxd rm <feed>` | Unsubscribe; downloads and history are kept |
| `ipxd import <file.opml>` / `ipxd 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 |
| `ipxd reap [--dry-run]` | Run retention now |
| `ipxd user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipxd 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
@@ -29,11 +29,11 @@ its next change, undoing them. If it does not answer they say so: restart it.
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
```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; signs the account out everywhere
ipx user list # who exists, and how each signs in
ipx user rm sam # account, subscriptions and read state
echo -n 'a good password' | ipxd user add ray # local account
ipxd user add ray@example.com --no-password # signs in through the proxy only
echo -n 'a good password' | ipxd user passwd admin # change a password; signs the account out everywhere
ipxd user list # who exists, and how each signs in
ipxd 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
@@ -43,14 +43,14 @@ config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it.
To avoid even the command line, read it interactively:
```sh
read -s PW && echo -n "$PW" | ipx user passwd admin
read -s PW && echo -n "$PW" | ipxd user passwd admin
```
## Scanning
```sh
ipx fetch # everything due
ipx fetch atp --force # one feed, ignoring its TTL and schedule
ipxd fetch # everything due
ipxd fetch atp --force # one feed, ignoring its TTL and schedule
```
A scan: conditional GET (`If-None-Match` / `If-Modified-Since`), parse, record new entries, apply
@@ -60,8 +60,8 @@ answers `304` and costs one request.
## Retention
```sh
ipx reap --dry-run # what would go, oldest first
ipx reap # actually delete
ipxd reap --dry-run # what would go, oldest first
ipxd reap # actually delete
```
Files are deleted to get back under `max_total_gb`, oldest first, and items past `max_age_days`
@@ -72,8 +72,8 @@ stops the next scan fetching it again.
## The daemon
```sh
ipx daemon # scheduler + socket + HTTP API
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
ipxd daemon # scheduler + socket + HTTP API
ipxd daemon --web 0.0.0.0:8099 # override the configured bind for one run
```
One daemon per socket; a second refuses to start rather than fight over the database. It shuts down

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`, `art_cache_mb`). 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 `ipxd add`, `ipxd rm` and `ipxd import`; they
take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
@@ -137,11 +137,35 @@ metrics_bind = "" # e.g. "0.0.0.0:9187"; empty serves no
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.
## `[push]`
```toml
[push]
key_file = "/config/AuthKey_ABC123DEFG.p8" # the APNs key Apple gives once
key_id = "ABC123DEFG" # shown beside it in the developer account
team_id = "8743L8D729" # the team the iOS app is signed by
topic = "net.sdf1.ipx" # the app's bundle id, the default
```
The iOS app's icon shows All Subscriptions' unread count. With this set, ipx keeps it right while
the app is closed (#433): once a minute it compares each phone's count with the last one sent, and
sends a changed one through Apple's push service, a badge alone, with no alert or sound. Without
it, the count waits on the background refresh iOS runs when it chooses.
The key is made in the Apple developer account under Certificates, Identifiers & Profiles, Keys,
with Apple Push Notifications service enabled, and can be downloaded once. One key serves every
app of the team, the TestFlight and debug builds both. Keep it as private as the database: it lets
anyone send to the team's apps. A key, id or team that is wrong shows as a warning in the log,
`push: Apple refused the signing token`, and nothing is sent until it is right.
The app registers each phone through the page as the person signed in there
(`POST /api/push/devices`), and signing out removes it.
## `[feeds.<id>]`
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
feeds come and go with `ipxd add`, `ipxd rm` and `ipxd import`. The table key is the feed id: stable,
human-readable, and used in paths and the API. `ipxd add` derives it from the feed title.
```toml
[feeds.atp]
@@ -163,12 +187,17 @@ 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.
**Shared episodes** go to ipx-play ([play.md](play.md)), set up in Admin, Directory, Sharing
episodes: its address and this server's token, kept in the database (`play_url`, `play_token`).
Only episodes of feeds the directory would be sent; without it, Share hands over an episode's own
address.
**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
nobody subscribed, `ipxd 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

79
docs/play.md Normal file
View File

@@ -0,0 +1,79 @@
# ipx-play: shared episodes (#447)
When someone shares an episode in iPX, their server sends it to ipx-play, which keeps it and
answers with a short link, `https://play.ipxreader.app/e/<id>`. Anyone can open that link: it
shows the episode, plays it from the publisher's own file (from the time it was shared at, with
`?t=`), and offers to subscribe in the reader's own podcast app. Overcast's links are the model.
Only episodes of public feeds are sent, by the same rule that decides what the directory is sent:
nothing from a feed marked private, one with a login, one whose address carries a key, or one in
a private OPML. ipx-play refuses an address that looks like one too. Everything else is shared by
its own address, as before. ipx-play keeps what an episode is and where its file is; it serves no
file itself.
## Running it
A container of its own, `ipx-play`, in the Arcane project `ipx`, from its own image:
```sh
sh scripts/build-image.sh play
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-play
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-play
```
The compose service, beside `ipx-directory`:
```yaml
ipx-play:
image: 192.168.1.130:5000/ipx-play:latest
container_name: ipx-play
restart: unless-stopped
env_file: play.env # PLAY_DATABASE_URL=postgres://...
environment:
PLAY_LOG_FORMAT: json
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.1.130:4318 # Tempo is not on this network
volumes:
- /mnt/fast/appdata/ipx-play:/config
ports:
- "8102:8102"
```
Its config, `/mnt/fast/appdata/ipx-play/play.toml`:
```toml
bind = "0.0.0.0:8102"
public_url = "https://play.ipxreader.app"
# One for each ipx server allowed to make links; `ipx-play token <name>` prints the lines.
[[tokens]]
name = "tower"
token_sha256 = "..."
```
Tokens are read at start: after adding one, restart the container.
Its database is Postgres, `ipx_play` on the `databases` project's server, its URL in `play.env`
beside the compose file (mode 600), as the directory's is. It makes its one table, `shares`, on
start.
The tunnel publishes `play.ipxreader.app` to `http://192.168.1.130:8102` with no Access in front:
the pages are anyone's. cloudflared is not on the project's network, so not `ipx-play:8102`, as
the directory's route is the host's port too. `/v1` takes a token for everything.
## Setting up ipx
In Admin, Directory, Sharing episodes: the address ipx reaches it at, `http://ipx-play:8102`
inside Docker (the public address would go out through Cloudflare and back), and the token
`ipx-play token tower` printed. Without them Share hands over the episode's own address.
## The page
`/e/<id>` shows the show's art, its name, the episode's title, date and length, a player starting
at `?t=` (seconds, or `12:34`), the episode's own page if it has one, its notes (sanitized by
ammonia), and buttons that open Apple Podcasts, Overcast, Pocket Casts and Castro at the feed, with
the feed's address to paste into anything else. Open Graph tags give it a preview in Messages,
Slack and the like. It runs no script: the start time is the media fragment `#t=` on the file. Its
Content-Security-Policy lets it load only its stylesheet, the art and the file.
A show that inserts its ads per listener can move the time by an ad's length, as Overcast's links
can: the file is the publisher's, fetched fresh by each listener.

View File

@@ -364,7 +364,7 @@ erDiagram
- `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
- The icons are files of ipx-web's, not the database's: `web/build.mts` 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.
@@ -406,7 +406,7 @@ erDiagram
- `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.
- `node --test tests/page-unit.ts`: 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

View File

@@ -63,7 +63,7 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
see the next section.
3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries
`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.
account. `ipxd user rename rays rays@sdf1.net` fixed that without losing anything.
### The address to trust, and why it is 192.168.64.1
@@ -105,7 +105,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
send:
```sh
docker exec iPX ipx user rename <old name> <email address>
docker exec iPX ipxd user rename <old name> <email address>
```
### Signing out
@@ -225,19 +225,19 @@ Check it: the busybox request under [Check it](#check-it), which sends the email
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>`).
in with them until they are given a password (`ipxd user passwd <name>`).
---
## Everyday administration
```sh
ipx user list # who exists, how each signs in, and when
echo -n 'secret123' | ipx user add sam # local account, password on stdin
ipx user add sam@example.com --no-password # proxy-only account, made ahead of time
ipx user rename sam sam@example.com # give an account the name the proxy sends
echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account
ipxd user list # who exists, how each signs in, and when
echo -n 'secret123' | ipxd user add sam # local account, password on stdin
ipxd user add sam@example.com --no-password # proxy-only account, made ahead of time
ipxd user rename sam sam@example.com # give an account the name the proxy sends
echo -n 'newsecret' | ipxd user passwd sam # change a password
ipxd user rm sam # remove the account
```
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
@@ -261,5 +261,19 @@ the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client
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.
### `ipxapi.sdf1.net`, for the iOS app (#419)
The app's player cannot send an Access service token's pair of headers as well as sign in, and its
cookie from Access ends mid-drive, so it has an address of its own: `ipxapi.sdf1.net`, a route of
the `rays-unraid` tunnel to the same `192.168.1.130:8099` as `ipodderx.sdf1.net`, with a proxied
CNAME to the tunnel and **no Access application**. That is the exposure the paragraph above warns
against, held narrow another way: a custom firewall rule on the `sdf1.net` zone (Security, WAF)
blocks every request to that host unless it carries `Authorization: Bearer` and is for
`/api/feeds`, `/api/me`, `/api/art`, `/api/entries/*` or `/media/*`. So the page, the sign-in form,
`/api/login`, `?token=` sign-in and the admin API answer 403 there, and ipx still checks the
token on what is let through. A new path the app needs is added to that rule's expression. The
rule is on Cloudflare, not in this repository; if the hostname is ever served without it, the
whole of ipx is on the internet behind bearer tokens alone.
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.
for every `[web]` key, and [cli.md](cli.md) for the `ipxd user` commands.

View File

@@ -56,13 +56,13 @@ A database with no accounts creates **admin** on the next daemon start, with the
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
echo -n 'a good password' | ipxd user passwd admin
```
## Adding someone
```sh
echo -n 'their password' | ipx user add sam
echo -n 'their password' | ipxd user add sam
```
They sign in at `/login` and start with **no feeds**: subscriptions are per person. Adding a feed
@@ -107,5 +107,5 @@ and who else is an admin, and read the log, which names everyone's feeds and sig
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
```sh
ipx user list # the admin column says who
ipxd user list # the admin column says who
```

91
grafana/alerts.py Normal file
View File

@@ -0,0 +1,91 @@
"""The iPX alert rules in Grafana (#365): what should be looked at now rather than at the next prod
check, written out as Grafana's alerting provisioning, beside the dashboard.
python3 grafana/alerts.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/alerting/ipx.json
Grafana reads it at start, or at once from POST /api/admin/provisioning/alerting/reload. There is
no contact point yet: a rule that fires shows in Grafana's Alerting page, and adding one (email,
ntfy, a webhook) is a file in the same folder. Each rule is a query and a threshold on it; the
query's value is in the alert, so it says how bad as well as what.
"""
import json
# Prometheus was added in Grafana's settings, not provisioned, so its uid is Grafana's own.
PROM = "eenybu3ao2t4wb"
LOKI = "P8E80F9AEF21F6940"
def rule(uid, title, expr, op, value, for_, summary, *, ds=PROM, window=600, no_data="OK"):
"""Fires when `expr` is `op` (gt, lt) `value` for `for_`. No data is fine by default: most of
these metrics are absent until there is something to say (no job running, no scan yet)."""
return {
"uid": f"ipx-{uid}", "title": title, "condition": "C", "for": for_,
"data": [
{"refId": "A", "relativeTimeRange": {"from": window, "to": 0}, "datasourceUid": ds,
# Loki's instant query is a queryType; Prometheus's a flag.
"model": {"refId": "A", "expr": expr,
**({"queryType": "instant"} if ds == LOKI else {"instant": True})}},
{"refId": "C", "datasourceUid": "__expr__",
"model": {"refId": "C", "type": "threshold", "expression": "A",
"conditions": [{"evaluator": {"type": op, "params": [value]}}]}},
],
"noDataState": no_data, "execErrState": "Error",
"annotations": {"summary": summary},
"labels": {"service": "ipx"},
}
rules = [
# Down: Prometheus cannot reach it. A container gone is no data at all, so that fires too.
rule("down", "ipx is down", 'up{job="ipx"}', "lt", 1, "2m",
"Prometheus cannot reach ipx's /metrics: the iPX container is down or restarting.", no_data="Alerting"),
rule("directory-down", "ipx-directory is down", 'up{job="ipx-directory"}', "lt", 1, "5m",
"Prometheus cannot reach the directory's /metrics.", no_data="Alerting"),
rule("web-down", "ipx-web is down", 'up{job="ipx-web"}', "lt", 1, "5m",
"Prometheus cannot reach Caddy's /metrics: the page is likely down too.", no_data="Alerting"),
# The worker: the daemon answers `ipxd status` while it is stuck, so only these say so.
rule("scan-stale", "ipx's scans have stopped", 'max(ipx_last_scan_age_seconds{job="ipx"})', "gt", 900, "5m",
"No scan has finished for over 15 minutes; scans run at least every 10. The worker is stuck: its job and trace say on what."),
rule("job-long", "ipx's worker is stuck on one job", 'max(ipx_worker_job_seconds{job="ipx"})', "gt", 1800, "1m",
"One job has run for over 30 minutes (ipx_worker_job_seconds says which kind)."),
rule("pool-full", "ipx's database connections are all in use",
'max_over_time(ipx_db_connections{job="ipx",state="idle"}[5m])', "lt", 1, "5m",
"No idle connection for five minutes: requests and scans are waiting for one (ipx_db_wait_seconds)."),
rule("5xx", "ipx is answering with server errors",
'sum(increase(ipx_http_request_duration_seconds_count{job="ipx",status=~"5.."}[10m]))', "gt", 5, "0s",
"More than five 5xx answers in ten minutes; ipx's log has a warning for each, with its trace."),
rule("502", "ipx-web cannot reach ipx",
'sum(increase(caddy_http_request_duration_seconds_count{job="ipx-web",code=~"502|504"}[10m]))', "gt", 5, "0s",
"Caddy answered 502 or 504 for ipx more than five times in ten minutes: ipx is not answering it."),
rule("jev", "ipx's calls to Jev are refused",
'sum(increase(ipx_jev_requests_total{job="ipx",result=~"rate_limited|malformed"}[15m]))', "gt", 0, "0s",
"TypeSafe refused calls as over its limits (#277) or malformed (#257): ipx is asking wrongly."),
rule("signals-stale", "Signals have stopped", 'max(ipx_signals_last_pass_age_seconds{job="ipx"})', "gt", 900, "5m",
"The Signals judge has not finished a pass for 15 minutes; its signals trace shows which Jev call holds it."),
# Only once it has been up 13 hours: its rounds come in bursts about twelve hours apart, and the
# count starts again at each restart, so a deploy would read as stopped.
rule("directory-refresh", "The directory has stopped checking feeds",
'sum(increase(directory_feed_checks_total{job="ipx-directory"}[13h])) < 1'
' and on() max(directory_uptime_seconds{job="ipx-directory"}) > 46800', "lt", 1, "30m",
"No feed checked in 13 hours; each is due every 12.", window=46800),
# Tower, which everything shares: a stall here is every request slow (2026-10-18, load 160).
rule("load", "Tower is overloaded", 'max(node_load5{job="node"})', "gt", 48, "10m",
"Load over 48 on twelve threads for ten minutes: builds, or processes blocked on a disk."),
rule("memory", "Tower is out of memory", 'min(node_memory_MemAvailable_bytes{job="node"})', "lt", 2e9, "5m",
"Under 2 GB available, with no swap: next comes stalling on page faults, then the OOM killer."),
# The monitoring itself (#366): nothing else watches it.
rule("monitoring-down", "Part of the monitoring is down", 'min by (job) (up{job=~"prometheus|tempo|loki|alloy"})',
"lt", 1, "5m", "Prometheus cannot reach Tempo, Loki or Alloy."),
rule("tempo-blocks", "Tempo cannot read its stored traces",
'(max(tempodb_blocklist_tenant_index_age_seconds) > bool 900) + (max(tempodb_blocklist_length) < bool 1)',
"gt", 0, "10m",
"Tempo's look over its stored blocks is failing (2026-10-25: one empty block); searches see only the last 15 "
"minutes. Its log says which block: move it out of /mnt/user/logs/tempo/blocks/single-tenant."),
rule("logs-stopped", "No lines from iPX reach Loki", 'sum(count_over_time({container="iPX"}[3h]))', "lt", 1, "0s",
"Loki has nothing from iPX in three hours: Alloy is not shipping it, or the container is down.",
ds=LOKI, window=10800, no_data="Alerting"),
]
print(json.dumps({"apiVersion": 1, "groups": [
{"orgId": 1, "name": "ipx", "folder": "iPX", "interval": "1m", "rules": rules},
]}, indent=2))

View File

@@ -8,7 +8,7 @@ top-down as a request does: an overview, Tower, then each container (ipx-web, ip
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
(ipc.rs log_event), ipx's metrics (metrics.rs), 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
@@ -18,11 +18,13 @@ 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.
# fields (ev, feed, new, bytes, msg, error.type, ...); a failed request its method, path, route,
# status and duration_ms. Events are logged under ipx::scan, `ipxd status`'s answer 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"'
# Each request ipx answers, the event stream aside, as a histogram (#364): its line in the log is
# debug, so only a failed request's is in Loki.
REQ = "ipx_http_request_duration_seconds"
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
@@ -70,9 +72,11 @@ def loki(expr, ref="A", legend=None, instant=False, kind=None):
return t
def prom(expr, ref="A", legend=None):
def prom(expr, ref="A", legend=None, instant=False):
# ipx's /metrics (#194), scraped by Prometheus as job ipx.
t = {"refId": ref, "datasource": PROM, "expr": expr}
if instant:
t["instant"], t["range"], t["format"] = True, False, "table"
if legend:
t["legendFormat"] = legend
return t
@@ -105,7 +109,7 @@ def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc
"tooltip": {"mode": "multi", "sort": "desc"}})
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc="", unit=None):
return panel("table", title, w, h, x, targets, description=desc,
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
{"id": "organize", "options": {
@@ -113,7 +117,7 @@ def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
"service_name": True},
"renameByName": rename or {}}}],
options={"showHeader": True, "sortBy": sort or []},
fieldConfig={"defaults": {}, "overrides": []})
fieldConfig={"defaults": {"unit": unit} if unit else {}, "overrides": []})
GOOD_BAD = [{"color": "green", "value": None}, {"color": "orange", "value": 1}]
@@ -194,19 +198,27 @@ ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap
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
# Swipes in the reader and what came of them, and a reader that would not scroll (#384).
SWIPE = SEL + ' |= "\\"ev\\":\\"swipe\\"" | json | __error__=""'
STUCK = SEL + ' |= "\\"ev\\":\\"scroll_stuck\\"" | json | __error__=""'
ts("Swipes in the reader", [loki(f'sum by (msg) (count_over_time({SWIPE} [$__interval]))', legend="{{msg}}"),
loki(f'sum(count_over_time({STUCK} [$__interval]))', ref="B", legend="would not scroll")],
0, w=24, bars=True, stack=True,
desc="next, prev and list turned the item; end bounced off the last one loaded; short was too short or slow to count. Would not scroll is a drag with room to move the reader that left it where it was: its line in the log says what lay over it.")
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}}")],
ts("Requests by status", [prom(f'sum by (status) (increase({REQ}_count{{job="ipx"}}[$__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")
ts("Response time", [prom(f'histogram_quantile(0.5, sum by (le) (rate({REQ}_bucket{{job="ipx"}}[$__rate_interval])))', legend="median"),
prom(f'histogram_quantile(0.95, sum by (le) (rate({REQ}_bucket{{job="ipx"}}[$__rate_interval])))', ref="B", legend="95th percentile"),
prom(f'histogram_quantile(0.99, sum by (le) (rate({REQ}_bucket{{job="ipx"}}[$__rate_interval])))', ref="C", legend="99th percentile")],
12, unit="s", desc="From ipx_http_request_duration_seconds, whose buckets run from 5 ms to 10 s; the slowest requests themselves are in Tempo.")
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)],
table("Slowest routes", [prom(f'topk(15, sum by (method, route) (increase({REQ}_sum{{job="ipx",route!=""}}[$__range])) / sum by (method, route) (increase({REQ}_count{{job="ipx",route!=""}}[$__range])))', instant=True)],
0, w=8, unit="s", rename={"method": "Method", "route": "Route", "Value": "Average"}, sort=[{"displayName": "Average", "desc": True}])
table("Busiest routes", [prom(f'topk(15, sum by (method, route) (increase({REQ}_count{{job="ipx",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"),
@@ -214,6 +226,13 @@ ts("Open connections", [prom('max(ipx_http_requests_in_flight{job="ipx"})', lege
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
q95 = lambda m, by="": f'histogram_quantile(0.95, sum by (le{by}) (rate({m}_bucket{{job="ipx"}}[$__rate_interval])))'
ts("Database", [prom(q95("ipx_db_wait_seconds"), legend="wait for a connection, 95th"),
prom(q95("ipx_db_query_seconds", ", kind"), ref="B", legend="{{kind}}, 95th")],
0, unit="s", desc="A statement's time once it has a connection, and the wait for one (hand-written SQL only, #367). A wait that grows with the queries flat is the pool full.")
ts("The page's timings", [prom(f'histogram_quantile(0.95, sum by (le, ev) (rate(ipx_page_seconds_bucket{{job="ipx"}}[1h])))', legend="{{ev}}, 95th")],
12, unit="s", desc="What the browser timed (#369): list_shown a click to its list drawn, play_start play to sound, stall a wait mid-play. Over the hour, as there are few.")
y += 8
# ---- ipx: the worker, one job at a time: scans, downloads, DeadAir
row("ipx: worker")
@@ -230,7 +249,7 @@ ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the work
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"),
ts("Feeds checked and new items", [prom('sum(increase(ipx_feed_fetches_total{job="ipx"}[$__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"),
@@ -398,6 +417,37 @@ panel("logs", "ipx log", 24, 12, 0,
description="Without the event stream's requests, not-due skips and healthcheck status calls.", options=LOG_OPTS)
y += 12
# ---- Coverage: what the tests reach (#390, #394). scripts/coverage.sh writes each run's numbers
# for node-exporter's textfile collector, so they are job node's, and stay as the last run left them.
COV = 'ipx_coverage_lines{job="node"'
def cov(suite, by=""):
"""Lines covered over lines there are, for a suite, summed over its parts or by `by`."""
group = f" by ({by})" if by else ""
return (f'sum{group} ({COV},suite="{suite}",state="covered"}}) / '
f'sum{group} ({COV},suite="{suite}",state="total"}})')
row("Coverage")
COV_STEPS = [{"color": "red", "value": None}, {"color": "orange", "value": 0.6}, {"color": "green", "value": 0.8}]
stat("Rust, cargo tests", prom(cov("cargo")), 0, unit="percentunit", thresholds=COV_STEPS,
desc="Lines of ipx, ipx-directory and ipx-feed the cargo tests reach: sh scripts/coverage.sh. The tests' own code counts too, as it runs whole, so this reads higher than the code alone would.")
stat("Rust, browser tests", prom(cov("browser")), 4, unit="percentunit", thresholds=COV_STEPS,
desc="Lines of ipx and ipx-directory the browser suite reaches through the API, against an instrumented build: sh scripts/coverage.sh api.")
stat("Page, browser tests", prom(cov("page")), 8, unit="percentunit", thresholds=COV_STEPS,
desc="Lines of web/src the browser suite runs: npm run coverage:page, or coverage.sh api.")
stat("Since the last coverage run", prom('time() - max(ipx_coverage_run_timestamp_seconds{job="node"})'), 12, unit="s",
desc="The newest of the three suites' reports. Each suite's own is in the table beside it.")
table("Last run of each suite", [prom('time() - ipx_coverage_run_timestamp_seconds{job="node"}', instant=True)], 16, w=8, h=4,
rename={"Value": "ago", "suite": "suite"}, unit="s")
y += 4
ts("Lines covered", [prom(cov("cargo"), legend="Rust, cargo tests"), prom(cov("browser"), ref="B", legend="Rust, browser tests"),
prom(cov("page"), ref="C", legend="Page, browser tests")],
0, w=12, unit="percentunit", desc="As each suite's last run found them: a step is a run that found more, or less.")
table("By part", [prom(cov("cargo", "part"), ref="A", instant=True), prom(cov("browser", "part"), ref="B", instant=True),
prom(cov("page", "part"), ref="C", instant=True)],
12, w=12, unit="percentunit", rename={"Value #A": "cargo tests", "Value #B": "browser tests", "Value #C": "page"},
desc="Each crate's lines, and the page's, by the suite that reached them. A crate has more lines under the cargo tests: their own code, which a build for the browser suite leaves out.")
panels[-1]["transformations"].insert(0, {"id": "merge", "options": {}})
y += 8
dash = {
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,

8
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
*.xcodeproj/
build/
DerivedData/
Resources/Info.plist
.envrc
Resources/iPX.entitlements
Widgets/Info.plist
Widgets/iPXWidgets.entitlements

21
ios/CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# Changelog
The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418).
- Player token (Settings, This device): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421). Reading in the app and new items arriving while it is open set it at once, and while an episode plays it is checked every quarter of an hour.
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
- Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427).
- Settings in the app has a This device section: the server the app opens and the player token, with a button to make one (#445).
- On an iPhone the app stays upright; on an iPad it turns every way, and works in Split View and Slide Over (#434, #445).
- CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest. It lists only shows and episodes with a downloaded audio file, and the phone's player bar takes up what the car plays (#420, #442, #443).
- A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428).
- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433).
- Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430).

155
ios/README.md Normal file
View File

@@ -0,0 +1,155 @@
# iPX for iOS
ipx's page in an app, with the audio played by the phone rather than the page: it plays on with
the screen locked, and the lock screen, Control Center, headphones and a car over Bluetooth or USB
drive it. Everything you see is the page, as on the home screen; the app adds the player behind it.
## Build it
On a Mac with Xcode:
```sh
brew install xcodegen
cd ios && xcodegen generate && open iPX.xcodeproj
```
Set your team under Signing & Capabilities, pick your phone, and run. The project is generated
from `project.yml`, so `.xcodeproj` is not in git: edit the yml. Nothing on Tower can build it.
A free personal team works, but a build on it expires after seven days and is reinstalled from
Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen
locked is a plist key (`UIBackgroundModes: audio`), not an entitlement, so it works on either.
CarPlay does not: Apple grants `com.apple.developer.carplay-audio` on request (granted for team
8743L8D729 on 2026-11-09), and the App ID needs it ticked under Additional Capabilities.
## Which server
The first launch asks, with `https://ipodderx.sdf1.net` filled in; a bare host gets `https://`.
Change it in the page's Settings, under This device, or from the alert that comes up when the
server cannot be reached. Plain `http://` only on your own network
(`http://192.168.1.130:8099`), which is all `NSAllowsLocalNetworking` allows. Sign in on the page
that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the
player uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run. `-ipx.noBadgePrompt YES` skips the badge permission dialog, which covers the page in a simulator run nobody is there to tap.
### Opening with no connection
The page's service worker, which keeps iPX readable offline in a browser (#389), runs in the app
only for a server in `WKAppBoundDomains` in `project.yml` (#435): `ipodderx.sdf1.net`, with
Cloudflare Access's and Authentik's hosts for signing in. For those the web view is app-bound, and
iPX opens without a connection from the second launch on (the first installs the worker). What
that costs:
* The web view goes nowhere outside the list. A link to anywhere else opens in Safari; an
embedded player from another site in an item's text may not load.
* A server not in the list still opens, but WebKit then cuts the bridge, so the page plays its own
audio: no lock screen, no CarPlay, nothing kept on the phone. That includes the LAN address.
Another server means adding it to the list (ten at most) and building again.
* Signing in that leaves those hosts, through another identity provider, opens in Safari and does
not come back. Add its host too.
A debug build adds `localhost`, so a simulator can test the worker against a page served on the Mac.
## Player token
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
To make it outlast that, open Settings in the app and, under This device, press Make, or paste an
ipx API token (Settings, API tokens), with the address to send it to, `https://ipxapi.sdf1.net`. The token is
kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and
the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it
did, at the server address. Without a token the player sends only the cookie, as before.
`ipxapi.sdf1.net` is the tunnel's route to the same ipx-web as `ipodderx.sdf1.net`, with no Access
application in front, which would turn a bearer header away. Cloudflare's firewall (a custom rule
on the `sdf1.net` zone) blocks every request to it except a bearer-token one for `/api/feeds`,
`/api/me`, `/api/art`, `/api/entries/*` and `/media/*`; ipx still checks the token.
## How it hangs together
| | |
|---|---|
| `Sources/App.swift` | the app and its one scene |
| `Sources/WebViewController.swift` | the `WKWebView`, the server question, a page that will not load |
| `Sources/Bridge.swift` | the messages to and from `web/src/native.ts` |
| `Sources/Playback.swift` | `AVPlayer`, the audio session, now playing, the remote commands, saving positions |
| `Sources/CookieBridge.swift` | the web view's cookies into `HTTPCookieStorage.shared`, for the player |
| `Sources/Server.swift` | which server |
| `Sources/Intents.swift`, `Shared/PlayPauseIntent.swift` | Siri and Shortcuts (#427) |
| `Sources/LastPlayed.swift` | the last episode and its place, so Resume works with the app closed |
| `Widgets/`, `Shared/Snapshot.swift`, `Sources/WidgetFeed.swift` | the widget (#428), and what the app writes for it |
### Siri and Shortcuts
With nothing to set up, Siri answers "Resume iPX" (or "Play iPX"), "Pause iPX", "Skip forward
in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX"; each is also
an action in the Shortcuts app. The playback ones are `AudioPlaybackIntent`s, which iOS runs in the
app and lets start audio with the phone locked. Resume carries on with what is loaded, or else the
last episode from where it got to (`LastPlayed`), which plays without the page: opening the app
afterwards shows the page with nothing in its player, while the lock screen has it.
### The widget
Small and medium on the home screen, circular and rectangular on the lock screen: All
Subscriptions' unread count and the last episode, with a Resume or Pause button (the same
`PlayPauseIntent`). The widget has no sign-in and no player, so it draws what the app last wrote
to the App Group `group.net.sdf1.ipx` (`Snapshot`), and the app asks WidgetKit to redraw on a play,
a pause, a new episode or a new count, not on every position saved, since iOS rations redraws.
With the app closed, ipx's push (#433) carries `content-available`, which wakes the app for a
moment to give the widget the new count; iOS rations those wakes too, so the widget can trail the
icon. `Shared/` is built into both targets, with `WIDGET` set in the widget's, where the player
does not exist.
## The messages
The page's half is `web/src/native.ts`, and `node tests/native-bridge.ts` holds it to these. A
change to them is a change to both halves in one commit, and the page has to be deployed before
an app that needs it.
Page to app, on `webkit.messageHandlers.ipx`:
| | |
|---|---|
| `{t:"ready", version, rate, volume}` | the bridge is in |
| `{t:"load", url, enc, feedId, guid, title, feedTitle, artwork, position, duration, rate, volume}` | the element's `src` was set to an audio file |
| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | |
| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | |
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
| `{t:"clearKept"}` | signing out: everything kept goes |
| `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) |
| `{t:"unread", n}` | All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) |
| `{t:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) |
| `{t:"setServer", url}` | Settings' This device: open another server, which replaces the page (#445) |
| `{t:"setToken", api, token?}` `{t:"clearToken"}` | Settings' This device: the player address, and a token to keep in the keychain or none (#445) |
| `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
App to page, as `window.ipxNative.on(...)`:
| | |
|---|---|
| `{t:"time", cur, dur}` | `timeupdate` |
| `{t:"meta", dur}` | `loadedmetadata`: the page seeks to where you left off |
| `{t:"state", playing}` | `play` or `pause` |
| `{t:"ended"}` | |
| `{t:"error", message}` | the page says so |
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
| `{t:"keepFailed", url, message}` | one that could not be kept, and why |
| `{t:"host", can}` | at `ready`: what this build does itself (`sleep`, `route`, `settings`), so a page newer than the app offers only those |
| `{t:"appSettings", server, api, tokenSet, saved}` | at `ready` and after a change: the app's server, player address and whether a player token is set, for Settings' This device (#445) |
| `{t:"appSettingsError", message}` | a server or player address the app would not take |
| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) |
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
| `{t:"slept"}` | the timer ran out and playback paused |
| `{t:"nowPlaying", feedId, guid, enc, title, feedTitle, artwork, playing, cur, dur}` | what the phone plays that the page did not choose: the car's pick, and at `ready` and on coming forward whatever is loaded; the page's player bar takes it up (#442) |
| `{t:"replaced"}` | builds before `nowPlaying`: the car chose another episode, and the page's player bar closes without saving |
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
its own clock, so both happen while the page is frozen. Video stays in the page.
## Not yet
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
mid-drive, playback stops with "Sign in again", and opening the app signs in.
* CarPlay (`CarScreen.swift`) reads ipx with the player's credentials and nothing else: launched
by the car with the phone's app never opened since a restart, it has only cookies saved to disk,
and without the player token a lapsed Access cookie shows "iPX can't reach your server". Its
lists need a connection; a kept episode plays without one once it is picked.

View File

@@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1 @@
{ "info": { "author": "xcode", "version": 1 } }

View File

@@ -0,0 +1,18 @@
import AppIntents
/// Play or pause (#427, #428): Siri, Shortcuts and the widget's button. An `AudioPlaybackIntent`,
/// so iOS runs it in the app, which may start audio from the background, wherever it is asked
/// from. Compiled into the widget too, which needs the type for its button but never runs it.
struct PlayPauseIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Play or Pause"
static let description = IntentDescription(
"Pauses what iPX is playing, or carries on with the last episode where you left off.")
@MainActor
func perform() async throws -> some IntentResult {
#if !WIDGET
if Playback.shared.isPlaying { Playback.shared.pause() } else { Playback.shared.resumeLast() }
#endif
return .result()
}
}

30
ios/Shared/Snapshot.swift Normal file
View File

@@ -0,0 +1,30 @@
import Foundation
/// What the widget shows (#428): the unread count and the episode you were last on. Written by
/// the app into the App Group the two share, since the widget has no sign-in and no player of its
/// own; it shows what the app last knew.
struct Snapshot: Codable, Equatable {
var unread: Int?
var title: String?
var feedTitle: String?
var position: Double?
var duration: Double?
var playing = false
static let group = "group.net.sdf1.ipx"
private static let key = "ipx.snapshot"
static func load(from defaults: UserDefaults? = UserDefaults(suiteName: group)) -> Snapshot {
defaults?.data(forKey: key).flatMap { try? JSONDecoder().decode(Snapshot.self, from: $0) } ?? Snapshot()
}
func save(to defaults: UserDefaults? = UserDefaults(suiteName: group)) {
if let data = try? JSONEncoder().encode(self) { defaults?.set(data, forKey: Self.key) }
}
/// How far through, 0 to 1, when both are known.
var progress: Double? {
guard let position, let duration, duration > 0 else { return nil }
return min(1, max(0, position / duration))
}
}

23
ios/Sources/API.swift Normal file
View File

@@ -0,0 +1,23 @@
import Foundation
/// Reading ipx without the page: the badge and CarPlay (#420), which has no web view. Signed in
/// as the player is, by the credentials or else the cookies the web view left (CookieBridge).
enum API {
private static let session: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
cfg.timeoutIntervalForRequest = 20
return URLSession(configuration: cfg)
}()
/// The body of a 200, or nil: not signed in, no server, no connection.
static func get(_ path: String) async -> Data? {
guard let url = Server.resolveForPlayer(path) else { return nil }
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "Accept")
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
guard let (data, resp) = try? await session.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
return data
}
}

73
ios/Sources/App.swift Normal file
View File

@@ -0,0 +1,73 @@
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
Badge.registerBackgroundRefresh()
Offline.shared.resume()
Outbox.watch()
return true
}
/// A download kept going while the app was closed has finished (#425): the system launched us
/// to hand it over, and waits on this handler.
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
Offline.shared.backgroundCompletion = completionHandler
Offline.shared.resume()
}
/// Apple's address for this phone's copy of the app (#433), handed to the page to register.
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Push.token = deviceToken.map { String(format: "%02x", $0) }.joined()
}
/// ipx's count, arriving with the app closed (#433): `content-available` wakes the app for a
/// moment, which the badge alone does not, so the widget's count follows the icon's (#428).
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard let n = (userInfo["aps"] as? [String: Any])?["badge"] as? Int else {
completionHandler(.noData)
return
}
WidgetFeed.update { $0.unread = n }
completionHandler(.newData)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
NSLog("ipx: push registration: %@", error.localizedDescription)
}
}
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
/// be a second scene beside this one.
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let scene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: scene)
window.rootViewController = WebViewController()
window.makeKeyAndVisible()
self.window = window
}
/// iOS wants the request made as the app leaves, with the user's last look at the list; the
/// one made on coming forward stays for a run that was cut short.
func sceneDidEnterBackground(_ scene: UIScene) {
Badge.scheduleBackgroundRefresh()
}
func sceneDidBecomeActive(_ scene: UIScene) {
Badge.requestPermission()
Badge.scheduleBackgroundRefresh()
Task {
await Outbox.flush()
await Badge.refresh()
}
}
}

97
ios/Sources/Badge.swift Normal file
View File

@@ -0,0 +1,97 @@
import BackgroundTasks
import UIKit
import UserNotifications
/// The number on the icon: All Subscriptions' unread count, as the page's badge shows it (#421).
///
/// Set when the app comes forward, after the player marks an item read, and by a background
/// refresh while it is closed. iOS decides when that runs (a few times a day for an app that is
/// opened often), so the number can be hours behind; push would make it exact and needs a sender
/// in ipx.
enum Badge {
static let taskId = "net.sdf1.ipx.refresh"
private struct Row: Decodable {
let id: String
let group: String?
let unread: Int?
let hide_from_all: Bool?
}
/// web/src/feeds.ts `unreadInAll`: a feed counts unless it, or the OPML it came from, is
/// hidden from All Subscriptions. Nil when the body is not a feed list, so an error page
/// does not turn the badge to zero.
static func unread(inAll body: Data) -> Int? {
guard let rows = try? JSONDecoder().decode([Row].self, from: body) else { return nil }
let hidden = Set(rows.filter { $0.hide_from_all == true }.map(\.id))
return rows.reduce(0) { n, f in
if f.hide_from_all == true || f.group.map(hidden.contains) == true { return n }
return n + max(0, f.unread ?? 0)
}
}
/// Asks once, for the badge alone: no alerts, no sounds.
static func requestPermission() {
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
// has nobody to tap Allow, and the dialog covers it.
if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in
// Each launch, not once: Apple can change the token, and says to ask every time.
guard granted else { return }
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
}
}
/// The page's own count, sent as its list changes (#421).
@MainActor static func set(_ n: Int) {
lastSet = Date()
WidgetFeed.update { $0.unread = max(0, n) }
Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) }
}
/// When the icon was last set from a count, by either way.
@MainActor private static var lastSet = Date.distantPast
/// While an episode plays with the screen locked the app is awake, unlike the page: so new
/// items reach the icon every quarter of an hour, which background refresh does not promise.
@MainActor static func refreshIfStale() {
guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return }
lastSet = Date()
Task { await refresh() }
}
/// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer
/// with a feed list: an old number is better than a wrong one.
@discardableResult
static func refresh() async -> Bool {
guard let n = await fetch() else { return false }
await MainActor.run { set(n) }
return true
}
/// All Subscriptions' unread count from ipx, or nil when it cannot be had.
static func fetch() async -> Int? {
guard let data = await API.get("/api/feeds") else { return nil }
return unread(inAll: data)
}
// MARK: - while the app is closed
/// Before the app finishes launching, which is when iOS wants the handler registered.
static func registerBackgroundRefresh() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in
// The next one first, so a run that fails or is cut short does not end the chain.
scheduleBackgroundRefresh()
let work = Task {
task.setTaskCompleted(success: await refresh())
}
task.expirationHandler = { work.cancel() }
}
}
static func scheduleBackgroundRefresh() {
let req = BGAppRefreshTaskRequest(identifier: taskId)
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
try? BGTaskScheduler.shared.submit(req)
}
}

204
ios/Sources/Bridge.swift Normal file
View File

@@ -0,0 +1,204 @@
import AVKit
import Foundation
import WebKit
/// Between the page and the phone's player.
///
/// The page's half is web/src/native.ts. It replaces the playback surface of the page's media
/// element with one that posts here, so player.ts (the player bar, the row buttons, the keys)
/// carries on unchanged while this side plays. tests/native-bridge.ts holds the page's half to
/// the messages; ios/README.md lists them.
@MainActor
final class Bridge: NSObject, WKScriptMessageHandler {
static let name = "ipx"
private weak var webView: WKWebView?
private let playback: Playback
private let impact = UIImpactFeedbackGenerator(style: .light)
private let notice = UINotificationFeedbackGenerator()
/// The system's AirPlay picker, which has no API to open it but its own button: kept on the
/// web view, invisible, and pressed when the page's button is.
private lazy var routePicker: AVRoutePickerView = {
let v = AVRoutePickerView()
v.alpha = 0
v.isUserInteractionEnabled = false
v.prioritizesVideoDevices = false
webView?.addSubview(v)
return v
}()
init(webView: WKWebView, playback: Playback) {
self.webView = webView
self.playback = playback
super.init()
playback.onTime = { [weak self] cur, dur in
// Built up rather than `dur as Any`: a nil cast to Any is an Any holding nil, which
// JSONSerialization refuses, and every tick before the length was known would drop.
var m: [String: Any] = ["t": "time", "cur": cur]
if let dur { m["dur"] = dur }
self?.send(m)
}
playback.onMeta = { [weak self] dur in self?.send(["t": "meta", "dur": dur]) }
playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
playback.onChosen = { [weak self] in self?.sendNowPlaying() }
// Again on coming forward: a page frozen while the car chose may have missed it (#442).
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil,
queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.sendNowPlaying() }
}
Push.onToken = { [weak self] _ in self?.sendPush() }
Offline.shared.onChange = { [weak self] in self?.sendKept() }
Offline.shared.onFail = { [weak self] path, msg in
self?.send(["t": "keepFailed", "url": path, "message": msg])
}
}
/// What the phone holds, and is fetching, by the paths the page keeps them under (#425).
private func sendKept() {
send(["t": "kept", "urls": Offline.shared.store.paths,
"keeping": Offline.shared.downloading.sorted()])
}
/// A new server, checked, for the web view to load in place of this page (#445).
var onServer: ((URL) -> Void)?
/// The app's own settings for the page's Settings to show (#445); `saved` says a change took.
private func sendAppSettings(saved: Bool = false) {
send(["t": "appSettings", "server": Server.url?.absoluteString ?? Server.suggested,
"api": Server.apiURL?.absoluteString ?? Server.suggestedAPI,
"tokenSet": Credentials.apiToken != nil, "saved": saved])
}
/// The page's Settings, setting what an alert on shaking the phone used to (#445).
private func setToken(_ m: [String: Any]) {
guard let url = Server.normalise(m["api"] as? String ?? ""), url.scheme == "https" else {
send(["t": "appSettingsError",
"message": "The player address has to be https, such as \(Server.suggestedAPI)."])
return
}
Server.apiURL = url
if let token = m["token"] as? String, !token.isEmpty { Credentials.apiToken = token }
sendAppSettings(saved: true)
}
private func setServer(_ text: String) {
guard let url = Server.normalise(text) else {
send(["t": "appSettingsError", "message": "That is not an address the app can open. Plain http "
+ "only works on your own network, such as 192.168.1.130:8099."])
return
}
onServer?(url)
}
/// What is loaded, for the page's player bar to take up (#442): the car's pick, or an episode
/// playing when the page loads, as when the app was opened from CarPlay. Without it the sound
/// went on with no bar on the phone to pause it.
private func sendNowPlaying() {
guard let item = playback.current else { return }
var m: [String: Any] = ["t": "nowPlaying", "feedId": item.feedId, "guid": item.guid, "enc": item.enc,
"title": item.title, "feedTitle": item.feedTitle,
"playing": playback.wantsToPlay, "cur": playback.currentTime]
if let a = item.artwork { m["artwork"] = a }
if let d = playback.duration ?? item.duration.map(Double.init) { m["dur"] = d }
send(m)
}
/// For the page to register with ipx (#433), at ready and whenever Apple gives a new one.
private func sendPush() {
guard let token = Push.token else { return }
send(["t": "push", "token": token, "sandbox": Push.sandbox])
}
// MARK: - page to host
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let m = message.body as? [String: Any], let t = m["t"] as? String else { return }
switch t {
case "ready":
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
// What this build can do, so a newer page offers nothing it would ask for in vain.
send(["t": "host", "can": ["sleep", "route", "settings"]])
sendAppSettings()
// A page reloaded while the timer runs is told how long is left.
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
sendKept()
sendPush()
sendNowPlaying()
case "load":
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
let enc = m["enc"] as? Int,
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
playback.load(.init(
url: url, enc: enc, feedId: feedId, guid: guid,
title: m["title"] as? String ?? "",
feedTitle: m["feedTitle"] as? String ?? "",
artwork: m["artwork"] as? String,
duration: m["duration"] as? Int))
case "play": playback.play()
case "pause": playback.pause()
case "stop": playback.stop()
case "seek": if let to = m["to"] as? Double { playback.seek(to: to) }
case "rate": if let v = m["v"] as? Double { playback.setRate(Float(v)) }
case "volume": if let v = m["v"] as? Double { playback.setVolume(Float(v)) }
// The page sends the path it would have written to, not a time: ours is the clock that
// is not stale.
case "position": playback.savePosition()
case "keep": if let url = m["url"] as? String { Offline.shared.keep(url) }
case "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
case "clearKept": Offline.shared.removeAll()
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
case "route": showRoutes(m)
case "haptic": haptic(m["kind"] as? String)
case "unread": if let n = m["n"] as? Int { Badge.set(n) }
case "setServer": setServer(m["url"] as? String ?? "")
case "setToken": setToken(m)
case "clearToken":
Credentials.apiToken = nil
sendAppSettings(saved: true)
default: break
}
}
/// Opens the picker over the page's button: on an iPhone a sheet, on an iPad a popover
/// pointing at it. The page sends where the button is, in the web view's points.
private func showRoutes(_ m: [String: Any]) {
let n = { (k: String) in m[k] as? Double ?? 0 }
routePicker.frame = CGRect(x: n("x"), y: n("y"), width: max(1, n("w")), height: max(1, n("h")))
// Its button is made on layout, and opens the picker on its primary action; a version
// with nothing on that, as older ones may have, gets the touch that used to do it.
routePicker.layoutIfNeeded()
guard let button = Self.controls(in: routePicker).first else { return }
let primary = button.allTargets.contains {
button.actions(forTarget: $0, forControlEvent: .primaryActionTriggered)?.isEmpty == false
}
button.sendActions(for: primary ? .primaryActionTriggered : .touchUpInside)
}
/// The page's presses felt (#430): light for a tap, the system's success and warning for
/// something done and something gone wrong, as Mail and Messages use them.
private func haptic(_ kind: String?) {
switch kind {
case "done": notice.notificationOccurred(.success)
case "warn": notice.notificationOccurred(.warning)
default: impact.impactOccurred()
}
}
private static func controls(in v: UIView) -> [UIControl] {
v.subviews.flatMap { ($0 as? UIControl).map { [$0] } ?? controls(in: $0) }
}
// MARK: - host to page
private func send(_ payload: [String: Any]) {
guard let webView, let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) else { return }
// A frozen or reloading page misses a tick; the next one puts it right.
webView.evaluateJavaScript("window.ipxNative && window.ipxNative.on(\(json))", completionHandler: nil)
}
}

245
ios/Sources/CarScreen.swift Normal file
View File

@@ -0,0 +1,245 @@
import CarPlay
import UIKit
// CarPlay (#420). A car shows no web view, so this is the app's own: three lists from ipx's API,
// In Progress, Unread and Shows, read as the badge reads it (API), and the same Playback the
// page drives, so a car's pick plays on with the phone locked and saves its place as the page's
// does. Now Playing is the system's, fed by Playback's now-playing info and remote commands.
/// An item as a car's list needs it: db.rs EntryRow, the fields used.
struct CarEpisode: Decodable {
struct Enclosure: Decodable {
let id: Int
let url: String
let mime: String?
let deadair: Double?
/// Set once ipx has the file; `/media/{id}` answers 404 before that.
let path: String?
}
let guid: String
let feed_id: String
let title: String?
let image: String?
let duration: Double?
let position: Double
let enclosures: [Enclosure]
/// The file to play: the first downloaded one that is sound (items.ts isPlayable, less video,
/// which a car would play the sound of with nothing to say so). An item with none is not listed
/// (#443): a post's picture or a file still to come is nothing a car can play.
var audio: Enclosure? {
enclosures.first { e in
guard e.path != nil else { return false }
let mime = (e.mime ?? "").lowercased()
if !mime.isEmpty { return mime.hasPrefix("audio/") }
let ext = URL(string: e.url)?.pathExtension.lowercased() ?? ""
return ["mp3", "m4a", "m4b", "aac", "wav", "flac"].contains(ext)
}
}
/// Its title, or the file's name: a car's list cannot show the opening of a post as the
/// page's does, and "Untitled" twelve times says nothing.
var name: String {
if let t = title?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty { return t }
let file = audio.flatMap { URL(string: $0.url)?.deletingPathExtension().lastPathComponent }
return file.flatMap { $0.isEmpty ? nil : $0.removingPercentEncoding ?? $0 } ?? "Untitled episode"
}
/// player.ts `mediaSrc`: the copy kept on the phone if there is one, else DeadAir's when it
/// made one, so the clock the place is saved by is the one the page would use.
static func path(of e: Enclosure, kept: Set<String>) -> String {
let keepPath = (e.deadair ?? 0) > 0 ? "/media/\(e.id)/deadair" : "/media/\(e.id)"
if kept.contains(keepPath) { return keepPath }
return e.deadair != nil ? "/media/\(e.id)/deadair.m3u8" : "/media/\(e.id)"
}
/// What is left of it, once started: "12 min left".
var left: String? {
guard position > 5, let d = duration, d > position else { return nil }
return "\(max(1, Int(((d - position) / 60).rounded()))) min left"
}
func item(feedTitle: String, feedImage: String?, kept: Set<String>) -> Playback.Item? {
guard let enc = audio, let url = Server.resolveForPlayer(Self.path(of: enc, kept: kept)) else { return nil }
return Playback.Item(url: url, enc: enc.id, feedId: feed_id, guid: guid, title: name,
feedTitle: feedTitle, artwork: image ?? feedImage,
duration: duration.map { Int($0) })
}
}
/// A subscription, from `/api/feeds`.
struct CarFeed: Decodable {
let id: String
let title: String?
let image: String?
let unread: Int?
let entries: Int?
/// Its files ipx has downloaded.
let downloaded: Int?
let group: String?
var name: String { title ?? id }
}
private struct CarPage: Decodable {
let entries: [CarEpisode]
}
final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var screen: CarScreen?
func templateApplicationScene(_ scene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController) {
screen = CarScreen(interface: interfaceController)
}
func templateApplicationScene(_ scene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController) {
screen = nil
}
}
@MainActor
final class CarScreen: NSObject, CPTabBarTemplateDelegate {
private let interface: CPInterfaceController
private let inProgress = CPListTemplate(title: "In Progress", sections: [])
private let unread = CPListTemplate(title: "Unread", sections: [])
private let shows = CPListTemplate(title: "Shows", sections: [])
private var feeds: [String: CarFeed] = [:]
private var art: [String: UIImage] = [:]
init(interface: CPInterfaceController) {
self.interface = interface
super.init()
inProgress.tabImage = UIImage(systemName: "play.circle")
unread.tabImage = UIImage(systemName: "tray.full")
shows.tabImage = UIImage(systemName: "square.stack")
let tabs = CPTabBarTemplate(templates: [inProgress, unread, shows])
tabs.delegate = self
interface.setRootTemplate(tabs, animated: false, completion: nil)
refresh(inProgress)
}
/// Each list again whenever it is shown: a drive is long, and what was unread at the start
/// has been heard by the end.
func tabBarTemplate(_ tabBarTemplate: CPTabBarTemplate, didSelect selectedTemplate: CPTemplate) {
refresh(selectedTemplate)
}
private func refresh(_ t: CPTemplate) {
Task {
await loadFeeds()
// More than the car shows, since items with no file to play are dropped.
switch t {
case inProgress: await episodes(in: inProgress, from: "/api/entries?filter=in_progress&limit=100",
empty: "Nothing started")
case unread: await episodes(in: unread, from: "/api/entries?filter=unread&limit=100",
empty: "Nothing unread")
case shows: showFeeds()
default: break
}
}
}
private func loadFeeds() async {
guard let data = await API.get("/api/feeds"),
let rows = try? JSONDecoder().decode([CarFeed].self, from: data) else { return }
feeds = Dictionary(rows.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
}
private func episodes(in list: CPListTemplate, from path: String, empty: String, oneFeed: Bool = false) async {
guard let data = await API.get(path), let page = try? JSONDecoder().decode(CarPage.self, from: data) else {
unreachable(list)
return
}
let kept = Set(Offline.shared.store.paths)
let playing = Playback.shared.current
let rows: [CPListItem] = page.entries.compactMap { e in
let feed = feeds[e.feed_id]
guard let item = e.item(feedTitle: feed?.name ?? e.feed_id, feedImage: feed?.image, kept: kept) else {
return nil
}
// In a show's own list its name is the title above; across shows it is the detail.
let detail = (oneFeed ? [e.left] : [item.feedTitle, e.left]).compactMap { $0 }.joined(separator: " · ")
let row = CPListItem(text: item.title, detailText: detail.isEmpty ? nil : detail)
row.isPlaying = playing?.guid == e.guid && playing?.feedId == e.feed_id
row.handler = { [weak self] _, done in
self?.play(item, from: e.position)
done()
}
picture(item.artwork, for: row)
return row
}
list.emptyViewTitleVariants = [empty]
list.emptyViewSubtitleVariants = []
list.updateSections([CPListSection(items: Array(rows.prefix(CPListTemplate.maximumItemCount)))])
}
/// Alphabetical, which a driver can find a show in without reading the whole list. Only
/// shows with files downloaded (#443): a blog has nothing a car can play, and an OPML
/// subscription's own row holds no items, its feeds do.
private func showFeeds() {
guard !feeds.isEmpty else {
unreachable(shows)
return
}
let sorted = feeds.values.filter { ($0.downloaded ?? 0) > 0 }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
let rows: [CPListItem] = sorted.map { f in
let n = f.unread ?? 0
let row = CPListItem(text: f.name, detailText: n > 0 ? "\(n) unread" : nil)
row.accessoryType = .disclosureIndicator
row.handler = { [weak self] _, done in
self?.open(f)
done()
}
picture(f.image, for: row)
return row
}
shows.emptyViewTitleVariants = ["No shows"]
shows.emptyViewSubtitleVariants = []
shows.updateSections([CPListSection(items: Array(rows.prefix(CPListTemplate.maximumItemCount)))])
}
private func open(_ f: CarFeed) {
let list = CPListTemplate(title: f.name, sections: [])
interface.pushTemplate(list, animated: true, completion: nil)
let id = f.id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? f.id
Task { await episodes(in: list, from: "/api/feeds/\(id)/entries?limit=100", empty: "No episodes", oneFeed: true) }
}
/// Signed out, or no connection. Sign-in is the page's, which a car cannot show.
private func unreachable(_ list: CPListTemplate) {
list.emptyViewTitleVariants = ["iPX can't reach your server"]
list.emptyViewSubtitleVariants = ["Open iPX on your phone and sign in."]
list.updateSections([])
}
private func play(_ item: Playback.Item, from position: Double) {
Playback.shared.playChosen(item, startAt: position > 5 ? position : nil)
interface.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
}
/// A show's or an episode's picture, fetched through ipx's `/api/art` as the page's are, and
/// kept for the drive: the lists are made again on every visit.
private func picture(_ src: String?, for row: CPListItem) {
guard let src else { return }
if let image = art[src] {
row.setImage(image)
return
}
let path = src.lowercased().hasPrefix("http")
? "/api/art?u=" + (src.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? src)
: src
Task {
guard let data = await API.get(path), let image = UIImage(data: data) else { return }
let size = CPListItem.maximumImageSize
let small = UIGraphicsImageRenderer(size: size).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
art[src] = small
row.setImage(small)
}
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
import WebKit
/// The web view signs in; the player rides on what that left behind.
///
/// ipx knows who is asking by cookie: `ipx_session` from its own form, `CF_Authorization` from
/// Cloudflare Access in front of the tunnel. That is why the player needs no API of its own. But
/// AVPlayer and URLSession read `HTTPCookieStorage.shared`, and WKWebView keeps a store of its
/// own, so this copies one into the other whenever the web view's changes.
final class CookieBridge: NSObject, WKHTTPCookieStoreObserver {
private let store: WKHTTPCookieStore
init(store: WKHTTPCookieStore) {
self.store = store
super.init()
store.add(self)
sync()
}
func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { sync() }
/// All of them, replaced: a cookie the web view dropped (signing out) has to go here too, or
/// the player would go on playing as someone who signed out.
private func sync() {
store.getAllCookies { all in
let shared = HTTPCookieStorage.shared
shared.cookies?.forEach(shared.deleteCookie)
all.forEach(shared.setCookie)
}
}
}

View File

@@ -0,0 +1,51 @@
import Foundation
import Security
/// What the player and CarPlay send instead of the web view's cookie (#419).
///
/// The cookie Cloudflare Access leaves in the web view lasts as long as its session, and when it
/// ends mid-drive the player has nothing. An ipx API token (Settings, API tokens), which ipx reads
/// as `Authorization: Bearer`, does not end by itself. Access would turn a request carrying only
/// that away, so the player sends it to a second address with no Access in front, which
/// Cloudflare's firewall holds to the paths the player uses (`Server.apiURL`).
enum Credentials {
private static let service = "net.sdf1.ipx"
private static let account = "ipx.api-token"
static var apiToken: String? {
get {
var query = base
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var out: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
set {
SecItemDelete(base as CFDictionary)
guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return }
var item = base
item[kSecValueData as String] = Data(value.utf8)
// After first unlock, not when unlocked: CarPlay and a locked phone both play.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(item as CFDictionary, nil)
}
}
private static var base: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
/// Empty unless there is a token, and an address to send it to: sent to the page's own
/// address it would only be turned away by Access.
static var headers: [String: String] {
guard let token = apiToken, Server.apiURL != nil else { return [:] }
return ["Authorization": "Bearer \(token)"]
}
static var isSet: Bool { !headers.isEmpty }
}

View File

@@ -0,0 +1,89 @@
import Foundation
/// The episodes kept on this device (#425): which file holds each, by the path the page keeps it
/// under, `/media/42`, or `/media/42/deadair` for the copy without its dead air. A plain value over
/// a folder, so a test can point it at a temporary one.
struct EpisodeStore {
struct Entry: Codable, Equatable {
var file: String
var bytes: Int64
}
let directory: URL
private(set) var entries: [String: Entry]
private var indexURL: URL { directory.appendingPathComponent("index.json") }
init(directory: URL) {
self.directory = directory
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
// The files can be fetched again, so they are not worth the user's iCloud backup.
var dir = directory
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? dir.setResourceValues(values)
let saved = (try? Data(contentsOf: directory.appendingPathComponent("index.json")))
.flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:]
// A file the system took back is not kept, whatever the index says.
entries = saved.filter {
FileManager.default.fileExists(atPath: directory.appendingPathComponent($0.value.file).path)
}
}
var paths: [String] { entries.keys.sorted() }
func fileURL(for path: String) -> URL? {
entries[path].map { directory.appendingPathComponent($0.file) }
}
/// Only the page's two kinds of file, which also keeps what a path can make of a file name to
/// digits and a word: a message from the page is not trusted to name a place on disk.
static func isKeepable(_ path: String) -> Bool {
path.range(of: #"^/media/[0-9]+(/deadair)?$"#, options: .regularExpression) != nil
}
static func fileName(for path: String, ext: String) -> String {
path.dropFirst().replacingOccurrences(of: "/", with: "-") + "." + ext
}
/// What AVFoundation can tell a file's type from, since a download has no name of its own.
static func ext(forMime mime: String?) -> String {
switch mime?.lowercased().split(separator: ";").first.map(String.init) {
case "audio/mp4", "audio/x-m4a", "audio/m4a": return "m4a"
case "audio/aac", "audio/aacp": return "aac"
case "audio/ogg", "audio/opus": return "ogg"
case "audio/wav", "audio/x-wav": return "wav"
case "audio/flac", "audio/x-flac": return "flac"
default: return "mp3"
}
}
/// Takes the file at `temp`, which the system deletes when the delegate returns, as `path`'s.
mutating func add(_ path: String, moving temp: URL, ext: String) throws {
precondition(Self.isKeepable(path))
let name = Self.fileName(for: path, ext: ext)
let dest = directory.appendingPathComponent(name)
try? FileManager.default.removeItem(at: dest)
try FileManager.default.moveItem(at: temp, to: dest)
let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path))?[.size] as? Int64 ?? 0
entries[path] = Entry(file: name, bytes: bytes)
save()
}
mutating func remove(_ path: String) {
guard let e = entries.removeValue(forKey: path) else { return }
try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file))
save()
}
mutating func removeAll() {
for e in entries.values { try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file)) }
entries = [:]
save()
}
private func save() {
if let data = try? JSONEncoder().encode(entries) { try? data.write(to: indexURL, options: .atomic) }
}
}

109
ios/Sources/Intents.swift Normal file
View File

@@ -0,0 +1,109 @@
import AppIntents
// Siri and Shortcuts (#427). Each is an App Intent, and IPXShortcuts below gives the common ones
// phrases that work with nothing set up: "Resume iPX", "Pause iPX", "How many unread in iPX".
// The playback ones are AudioPlaybackIntents, which iOS runs in the app and lets start audio
// from the background, so they work with the phone locked and the app closed.
struct ResumeIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Resume Listening"
static let description = IntentDescription(
"Carries on with what iPX was playing, or the last episode, from where you left off.")
@MainActor
func perform() async throws -> some IntentResult {
guard Playback.shared.resumeLast() else { throw NothingToResume() }
return .result()
}
}
/// What Siri says when there is no episode to carry on with.
struct NothingToResume: Error, CustomLocalizedStringResourceConvertible {
var localizedStringResource: LocalizedStringResource {
"There is nothing to resume. Play an episode in iPX first."
}
}
struct PauseIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Pause"
static let description = IntentDescription("Pauses what iPX is playing.")
@MainActor
func perform() async throws -> some IntentResult {
Playback.shared.pause()
return .result()
}
}
struct SkipForwardIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Skip Forward"
static let description = IntentDescription("Skips 30 seconds ahead, as the player's button does.")
@MainActor
func perform() async throws -> some IntentResult {
Playback.shared.seek(to: Playback.shared.currentTime + 30)
return .result()
}
}
struct SkipBackIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Skip Back"
static let description = IntentDescription("Goes back 15 seconds, as the player's button does.")
@MainActor
func perform() async throws -> some IntentResult {
Playback.shared.seek(to: Playback.shared.currentTime - 15)
return .result()
}
}
struct SleepTimerIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Set Sleep Timer"
static let description = IntentDescription(
"Pauses iPX after a number of minutes, fading out over the last ten seconds. Zero turns it off.")
@Parameter(title: "Minutes", default: 30, inclusiveRange: (0, 600),
requestValueDialog: "For how many minutes?")
var minutes: Int
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
Playback.shared.sleep(after: Double(minutes) * 60)
return .result(dialog: minutes == 0 ? "The sleep timer is off." : "iPX will pause in \(minutes) minutes.")
}
}
struct UnreadIntent: AppIntent {
static let title: LocalizedStringResource = "Unread Count"
static let description = IntentDescription("How many items are unread in All Subscriptions.")
func perform() async throws -> some IntentResult & ReturnsValue<Int> & ProvidesDialog {
guard let n = await Badge.fetch() else {
return .result(value: 0, dialog: "iPX could not reach your server.")
}
await MainActor.run { Badge.set(n) }
return .result(value: n, dialog: n == 0 ? "Nothing is unread." : "\(n) unread in All Subscriptions.")
}
}
struct IPXShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(intent: ResumeIntent(),
phrases: ["Resume \(.applicationName)", "Play \(.applicationName)",
"Carry on listening in \(.applicationName)"],
shortTitle: "Resume", systemImageName: "play.fill")
AppShortcut(intent: PauseIntent(), phrases: ["Pause \(.applicationName)"],
shortTitle: "Pause", systemImageName: "pause.fill")
AppShortcut(intent: SkipForwardIntent(),
phrases: ["Skip forward in \(.applicationName)", "Skip ahead in \(.applicationName)"],
shortTitle: "Skip Forward", systemImageName: "goforward.30")
AppShortcut(intent: SkipBackIntent(), phrases: ["Skip back in \(.applicationName)"],
shortTitle: "Skip Back", systemImageName: "gobackward.15")
AppShortcut(intent: SleepTimerIntent(),
phrases: ["Set a sleep timer in \(.applicationName)", "\(.applicationName) sleep timer"],
shortTitle: "Sleep Timer", systemImageName: "moon.zzz")
AppShortcut(intent: UnreadIntent(),
phrases: ["How many unread in \(.applicationName)", "What's new in \(.applicationName)"],
shortTitle: "Unread", systemImageName: "tray.full")
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
/// The episode the player last had and where it got to, kept across launches, so "Resume iPX"
/// (#427) and the widget's button carry on with it when the app was closed and no page is loaded
/// to say what to play.
enum LastPlayed {
private static let itemKey = "ipx.last.item"
private static let positionKey = "ipx.last.position"
static var defaults = UserDefaults.standard
static var item: Playback.Item? {
get { defaults.data(forKey: itemKey).flatMap { try? JSONDecoder().decode(Playback.Item.self, from: $0) } }
set {
// A different episode starts from where the page put it, not the last one's place.
if newValue != item { defaults.removeObject(forKey: positionKey) }
defaults.set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: itemKey)
}
}
static var position: Double? {
get { defaults.object(forKey: positionKey) as? Double }
set { defaults.set(newValue, forKey: positionKey) }
}
}

124
ios/Sources/Offline.swift Normal file
View File

@@ -0,0 +1,124 @@
import Foundation
/// Episodes kept on the device to play with no connection (#425). The page's Keep button asks
/// (through the bridge); a background `URLSession` fetches, so a download goes on with the app
/// closed and the phone locked; `Playback` plays from the file when there is one.
///
/// A browser keeps them in a cache its service worker answers from, which a `WKWebView` does not
/// run, so the app keeps its own and the page is told which it holds.
@MainActor
final class Offline: NSObject, URLSessionDownloadDelegate {
static let shared = Offline()
/// What is kept, or being fetched, changed.
var onChange: (() -> Void)?
/// A path that could not be kept, and why in words for the person.
var onFail: ((String, String) -> Void)?
/// The system's handler for a launch it made to deliver a finished download: told when done.
var backgroundCompletion: (() -> Void)?
private(set) var store: EpisodeStore
private(set) var downloading: Set<String> = []
private lazy var session: URLSession = {
let cfg = URLSessionConfiguration.background(withIdentifier: "net.sdf1.ipx.offline")
cfg.sessionSendsLaunchEvents = true
cfg.httpCookieStorage = .shared
// The delegate's queue is main, which is what lets this class be the main actor's.
return URLSession(configuration: cfg, delegate: self, delegateQueue: .main)
}()
private override init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
store = EpisodeStore(directory: base.appendingPathComponent("episodes", isDirectory: true))
super.init()
}
/// At launch: reconnects to downloads begun before the app was last closed, so the page can be
/// told they are still going.
func resume() {
session.getAllTasks { [weak self] tasks in
let paths = tasks.compactMap { $0.taskDescription }
Task { @MainActor in
self?.downloading.formUnion(paths)
if !paths.isEmpty { self?.onChange?() }
}
}
}
func keep(_ path: String) {
guard EpisodeStore.isKeepable(path) else { return }
guard store.fileURL(for: path) == nil, !downloading.contains(path) else { onChange?(); return }
guard let url = Server.resolveForPlayer(path) else {
onFail?(path, "Set the server first.")
return
}
var req = URLRequest(url: url)
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
let task = session.downloadTask(with: req)
task.taskDescription = path
downloading.insert(path)
task.resume()
onChange?()
}
func remove(_ path: String) {
session.getAllTasks { tasks in tasks.filter { $0.taskDescription == path }.forEach { $0.cancel() } }
downloading.remove(path)
store.remove(path)
onChange?()
}
/// Signing out: the next person on this phone is not to hear what was kept for the last.
func removeAll() {
session.getAllTasks { tasks in tasks.forEach { $0.cancel() } }
downloading = []
store.removeAll()
onChange?()
}
// MARK: - URLSessionDownloadDelegate
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// On the main queue (see `session`), and the file is the system's until this returns, so
// it is moved here and not later.
MainActor.assumeIsolated {
guard let path = downloadTask.taskDescription else { return }
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
guard status == 200 else {
downloading.remove(path)
onFail?(path, status == 401 || status == 403
? "Check the player token in Settings." : "The server said \(status).")
onChange?()
return
}
let ext = EpisodeStore.ext(forMime: downloadTask.response?.mimeType)
do {
try store.add(path, moving: location, ext: ext)
} catch {
onFail?(path, "There is not enough room on this phone to keep it.")
}
downloading.remove(path)
onChange?()
}
}
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error, (error as NSError).code != NSURLErrorCancelled else { return }
MainActor.assumeIsolated {
guard let path = task.taskDescription else { return }
downloading.remove(path)
onFail?(path, (error as NSError).code == NSURLErrorNotConnectedToInternet
? "You are offline. Keep it once you are back." : "Unable to keep it on this device. Try again.")
onChange?()
}
}
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
MainActor.assumeIsolated {
backgroundCompletion?()
backgroundCompletion = nil
}
}
}

66
ios/Sources/Outbox.swift Normal file
View File

@@ -0,0 +1,66 @@
import Foundation
import Network
/// Writes the player made with no connection, sent when there is one (#425): where you got to in
/// an episode, and that you finished it. In a browser the page's service worker holds these; the
/// app's player is the one writing them, so it holds its own.
///
/// A newer write to the same address replaces an older one, since each says where you are now, and
/// the queue then stays as long as the episodes played offline, not as long as the time spent.
enum Outbox {
struct Write: Codable, Equatable {
var path: String
var json: Data
}
private static let key = "ipx.outbox"
private static var flushing = false
private static let monitor = NWPathMonitor()
static var pending: [Write] {
get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode([Write].self, from: $0) } ?? [] }
set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
}
/// A write that never arrived. Only the player's own two kinds are kept: anything else, such
/// as adding a feed, fails offline as it always has.
static func add(path: String, json: [String: Any]) {
guard path.hasSuffix("/position") || path.hasSuffix("/flags"),
let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) else { return }
var all = pending.filter { $0.path != path }
all.append(Write(path: path, json: data))
pending = all
}
/// In the order they were made. The first that does not reach the server stops it for the next
/// try; one the server answered at all is done, whatever it said, as the worker treats it.
static func flush() async {
guard !flushing, !pending.isEmpty else { return }
flushing = true
defer { flushing = false }
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
cfg.timeoutIntervalForRequest = 20
let session = URLSession(configuration: cfg)
for w in pending {
guard let url = Server.resolveForPlayer(w.path) else { return }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
req.httpBody = w.json
guard (try? await session.data(for: req)) != nil else { return }
// Removed by what it is, not where it was: a newer write may have replaced it while
// this one was on its way.
pending.removeAll { $0 == w }
}
}
/// Sends what waited whenever the phone gets a connection back.
static func watch() {
monitor.pathUpdateHandler = { path in
if path.status == .satisfied { Task { await flush() } }
}
monitor.start(queue: .global(qos: .utility))
}
}

421
ios/Sources/Playback.swift Normal file
View File

@@ -0,0 +1,421 @@
import AVFoundation
import MediaPlayer
import UIKit
/// The phone's player: the one that plays on with the screen locked, and the one the lock screen
/// and a car's buttons reach. Knows nothing of the web view; the bridge drives it.
@MainActor
final class Playback: NSObject {
/// One for the app: the page, Siri, Shortcuts and the widget's button all drive it (#427), and
/// the last three with no page loaded at all.
static let shared = Playback()
/// What is loaded: enough for the lock screen, and to save a position without asking the page.
struct Item: Codable, Equatable {
var url: URL
var enc: Int
var feedId: String
var guid: String
var title: String
var feedTitle: String
var artwork: String?
var duration: Int?
}
var onTime: ((Double, Double?) -> Void)?
var onMeta: ((Double) -> Void)?
var onState: ((Bool) -> Void)?
var onEnded: (() -> Void)?
var onError: ((String) -> Void)?
/// The sleep timer ran out and playback paused.
var onSlept: (() -> Void)?
/// Something the page did not choose is loaded now: the car's pick (#420).
var onChosen: (() -> Void)?
private let session: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
cfg.timeoutIntervalForRequest = 20
return URLSession(configuration: cfg)
}()
private var player: AVPlayer?
private var item: Item?
private var timeObserver: Any?
private var statusObservation: NSKeyValueObservation?
private var endObserver: NSObjectProtocol?
private var lastSaved: Double = 0
/// Where to start once the file is ready, for an episode resumed without the page (#427),
/// whose own seek on loadedmetadata is what places it otherwise.
private var startAt: Double?
private var markedRead = false
private var rate: Float = 1
private var volume: Float = 1
private var sleepTimer: Timer?
/// When the sleep timer pauses playback, if it is set.
private(set) var sleepAt: Date?
var isPlaying: Bool { player?.timeControlStatus == .playing }
/// Playing, or about to once it has buffered: what a player bar's button should say.
var wantsToPlay: Bool { player.map { $0.timeControlStatus != .paused } ?? false }
/// What is loaded, for the car's lists to mark.
var current: Item? { item }
var currentTime: Double { player?.currentTime().seconds ?? 0 }
var duration: Double? {
guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil }
return d
}
private override init() {
super.init()
// spokenAudio is what tells the system this is speech, so navigation's voice ducks it
// properly, and longFormAudio is what routes it as a podcast does.
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
policy: .longFormAudio)
} catch {
NSLog("ipx: audio session: %@", error.localizedDescription)
}
wireRemoteCommands()
}
// MARK: - what the page asks for
func load(_ item: Item, startAt: Double? = nil) {
stop(notify: false)
self.item = item
self.startAt = startAt
markedRead = false
lastSaved = 0
LastPlayed.item = item
WidgetFeed.update { s in
s.title = item.title
s.feedTitle = item.feedTitle
s.duration = item.duration.map(Double.init)
s.position = startAt
s.playing = false
}
// An AVURLAsset does not read the shared cookie store by itself, and the cookie is how
// ipx knows whose file this is, unless the credentials are set, which do not expire.
// The header option has no constant in the SDK; AVFoundation reads it by this name.
var options: [String: Any] = [
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
]
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
// One kept on the phone plays from there, connection or not (#425). The page asks for it
// as `/media/42?offline`; the path alone is what it was kept under.
let asset = Offline.shared.store.fileURL(for: item.url.path).map { AVURLAsset(url: $0) }
?? AVURLAsset(url: item.url, options: options)
let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem)
player.volume = volume
player.defaultRate = rate
self.player = player
// Where to start is the page's to decide, on loadedmetadata, so one piece of code owns it.
// Here only the length is reported.
statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
Task { @MainActor in
guard let self else { return }
switch pi.status {
case .readyToPlay:
if let at = self.startAt {
self.startAt = nil
self.seek(to: at)
}
if let d = self.duration { self.onMeta?(d) }
case .failed:
// A file that will not play, or a session that went while it played: only the
// second has something to do about it.
let msg = pi.error?.localizedDescription ?? "the file could not be played"
let status = await self.request("GET", "/api/me")
let expired = Credentials.isSet ? "Check the player token in Settings" : "Sign in again"
self.onError?(status == 401 || status == 403 ? expired : msg)
default: break
}
}
}
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main
) { [weak self] t in
MainActor.assumeIsolated { self?.tick(t.seconds) }
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.didEnd() }
}
updateNowPlaying()
}
func play() {
guard let player else { return }
try? AVAudioSession.sharedInstance().setActive(true)
player.playImmediately(atRate: rate)
onState?(true)
updateNowPlaying()
WidgetFeed.update { $0.playing = true }
}
/// Carries on: what is loaded, or else the last episode, from where it got to (#427). False
/// when there is nothing to resume.
@discardableResult
func resumeLast() -> Bool {
if player == nil {
guard let last = LastPlayed.item else { return false }
load(last, startAt: LastPlayed.position)
}
play()
return true
}
/// An episode chosen away from the page, in the car (#420): played from where it got to, and
/// the page told before it plays, so its player bar shows this one, not the old one or none
/// (#442), and the state that follows reaches it.
func playChosen(_ item: Item, startAt: Double?) {
if item == self.item {
play()
return
}
load(item, startAt: startAt)
onChosen?()
play()
}
func pause() {
player?.pause()
onState?(false)
savePosition()
updateNowPlaying()
WidgetFeed.update { $0.playing = false }
}
func seek(to secs: Double) {
// Exact, not the default tolerance, which snaps to a keyframe minutes from where the
// scrubber was let go on a long episode.
player?.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600),
toleranceBefore: .zero, toleranceAfter: .zero)
updateNowPlaying()
}
func setRate(_ r: Float) {
rate = r
player?.defaultRate = r
// Setting rate on a paused player starts it, which changing the speed should not do.
if isPlaying { player?.rate = r }
updateNowPlaying()
}
func setVolume(_ v: Float) {
volume = v
player?.volume = v
}
/// The sleep timer (#429): pauses after `secs`, the last ten fading out; 0 turns it off. Kept
/// here, not in the page: with the screen locked the web view is frozen, and a timer of its own
/// would not fire until the app was opened again. This one fires because the app is playing.
func sleep(after secs: Double) {
sleepTimer?.invalidate()
sleepTimer = nil
sleepAt = nil
player?.volume = volume
guard secs > 0 else { return }
sleepAt = Date(timeIntervalSinceNow: secs)
sleepTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.sleepTick() }
}
}
private func sleepTick() {
guard let sleepAt else { return }
let left = sleepAt.timeIntervalSinceNow
// A fade, not a cut: a sudden stop wakes the person it was set for.
if left > 0 {
if left < 10 { player?.volume = volume * Float(left / 10) }
return
}
sleep(after: 0)
pause()
onSlept?()
}
func stop(notify: Bool = true) {
sleep(after: 0)
savePosition()
if let timeObserver { player?.removeTimeObserver(timeObserver) }
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
timeObserver = nil
endObserver = nil
statusObservation = nil
player?.pause()
player = nil
item = nil
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
if notify { onState?(false) }
WidgetFeed.update { $0.playing = false }
}
/// Where we are, saved from this clock. The page asks for it too, and sends no time of its
/// own: a web view frozen in the background holds a time from minutes ago, and writing that
/// over where you got to is the stale write player.ts guards against.
func savePosition() {
guard let item, let player, player.currentItem?.status == .readyToPlay else { return }
let secs = Int(player.currentTime().seconds.rounded())
guard secs > 0 else { return }
lastSaved = Double(secs)
LastPlayed.position = Double(secs)
WidgetFeed.update(reload: false) { s in
s.position = Double(secs)
if let d = duration { s.duration = d }
}
var body: [String: Any] = ["secs": secs]
if let d = duration { body["duration"] = Int(d.rounded()) }
// DeadAir's copy runs on a shorter clock; the server has to know which one this is.
if item.url.path.contains("/deadair") { body["deadair"] = item.enc }
let path = entryPath(item, "position")
// Not arrived, kept for when the connection is back: offline, the ten-second retry would
// only fail again, and the place reached on the last pause would be lost.
Task { if await request("POST", path, body) == nil { Outbox.add(path: path, json: body) } }
}
// MARK: - as it plays
private func tick(_ secs: Double) {
guard secs.isFinite else { return }
onTime?(secs, duration)
updateElapsed(secs)
if abs(secs - lastSaved) > 10 { savePosition() }
Badge.refreshIfStale()
// Heard, not just started: the same 90% the page uses, so an item does not leave Unread
// the moment it is pressed. The page does the same while it runs; this is for when it
// does not.
if let d = duration, secs / d >= 0.9 { markRead() }
}
private func didEnd() {
savePosition()
// Finished: nothing to resume, and the widget says so.
LastPlayed.item = nil
WidgetFeed.update { s in
s.title = nil
s.feedTitle = nil
s.position = nil
s.playing = false
}
markRead()
onState?(false)
onEnded?()
updateNowPlaying()
}
private func markRead() {
guard !markedRead, let item else { return }
markedRead = true
let path = entryPath(item, "flags")
Task {
if await request("POST", path, ["read": true]) == nil { Outbox.add(path: path, json: ["read": true]) }
await Badge.refresh()
}
}
// MARK: - the lock screen and the car
private func wireRemoteCommands() {
let c = MPRemoteCommandCenter.shared()
c.playCommand.addTarget { [weak self] _ in self?.play(); return .success }
c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
c.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.isPlaying ? self.pause() : self.play()
return .success
}
// The page's 15 back and 30 forward, so a steering wheel and the keyboard agree.
c.skipBackwardCommand.preferredIntervals = [15]
c.skipForwardCommand.preferredIntervals = [30]
c.skipBackwardCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.seek(to: self.currentTime - 15)
return .success
}
c.skipForwardCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.seek(to: self.currentTime + 30)
return .success
}
c.changePlaybackPositionCommand.addTarget { [weak self] ev in
guard let self, let e = ev as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
self.seek(to: e.positionTime)
return .success
}
}
private func updateNowPlaying() {
guard let item else { return }
var info: [String: Any] = [
MPMediaItemPropertyTitle: item.title,
MPMediaItemPropertyArtist: item.feedTitle,
MPMediaItemPropertyAlbumTitle: item.feedTitle,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? rate : 0,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
]
if let d = duration ?? item.duration.map(Double.init) {
info[MPMediaItemPropertyPlaybackDuration] = d
}
let centre = MPNowPlayingInfoCenter.default()
centre.nowPlayingInfo = info
guard let art = item.artwork, let url = Server.resolveForPlayer(art) else { return }
Task {
var artReq = URLRequest(url: url)
Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) }
guard let (data, _) = try? await session.data(for: artReq),
let image = UIImage(data: data), self.item?.guid == item.guid else { return }
// Read back, not `info`: by the time the picture arrives that elapsed time is stale,
// and writing it would jerk the progress bar back.
var latest = centre.nowPlayingInfo ?? [:]
latest[MPMediaItemPropertyArtwork] = Self.artwork(image)
centre.nowPlayingInfo = latest
}
}
/// Made outside the main actor: the system asks for the picture on a thread of its own.
nonisolated private static func artwork(_ image: UIImage) -> MPMediaItemArtwork {
MPMediaItemArtwork(boundsSize: image.size) { _ in image }
}
/// The clock alone, each tick: rewriting the whole dictionary that often makes a car's
/// progress bar stutter.
private func updateElapsed(_ secs: Double) {
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? rate : 0
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
// MARK: - ipx
private func entryPath(_ item: Item, _ what: String) -> String {
let esc = { (s: String) in s.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? s }
return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)"
}
/// One call to ipx, signed in by the credentials or else the web view's cookies; its status, or nil if it never
/// arrived. A position that does not save is saved again ten seconds on.
@discardableResult
private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? {
guard let url = Server.resolveForPlayer(path) else { return nil }
var req = URLRequest(url: url)
req.httpMethod = method
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
if let json {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: json)
}
guard let (_, resp) = try? await session.data(for: req) else { return nil }
return (resp as? HTTPURLResponse)?.statusCode
}
}

23
ios/Sources/Push.swift Normal file
View File

@@ -0,0 +1,23 @@
import Foundation
/// This phone's push token (#433). ipx sends the icon's count to it while the app is closed,
/// which background refresh only guessed at. The page registers it with ipx, signed in as the
/// page is, since the player's address lets through only the player's own paths.
@MainActor
enum Push {
/// Told when the token arrives or changes, to pass it to the page.
static var onToken: ((String) -> Void)?
static var token: String? {
didSet { if let token, token != oldValue { onToken?(token) } }
}
/// A debug build is served by Apple's sandbox, which ipx has to send to instead.
static var sandbox: Bool {
#if DEBUG
true
#else
false
#endif
}
}

69
ios/Sources/Server.swift Normal file
View File

@@ -0,0 +1,69 @@
import Foundation
/// Which ipx-web the app shows. Asked for on first launch; changed in the page's Settings (#445).
/// `-ipx.server <url>` on the launch arguments overrides it for that run (UserDefaults' argument
/// domain), which is how a test points the app somewhere else.
enum Server {
private static let key = "ipx.server"
static let suggested = "https://ipodderx.sdf1.net"
static var url: URL? {
get { UserDefaults.standard.string(forKey: key).flatMap(normalise) }
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: key) }
}
/// What someone typed, as an address: a bare host gets https. Plain http is allowed only on
/// your own network, which is all NSAllowsLocalNetworking lets through, so it is refused here
/// rather than failing later and looking like the server is down.
static func normalise(_ text: String) -> URL? {
var s = text.trimmingCharacters(in: .whitespacesAndNewlines)
if s.isEmpty { return nil }
if !s.contains("://") { s = "https://" + s }
guard let u = URL(string: s), let host = u.host, !host.isEmpty else { return nil }
switch u.scheme {
case "https": return u
case "http": return isLocal(host) ? u : nil
default: return nil
}
}
static func isLocal(_ host: String) -> Bool {
if host == "localhost" || host.hasSuffix(".local") { return true }
let p = host.split(separator: ".").compactMap { Int($0) }
guard p.count == 4 else { return false }
return p[0] == 10 || p[0] == 127 || (p[0] == 192 && p[1] == 168)
|| (p[0] == 172 && (16...31).contains(p[1]))
}
/// The hosts in the Info.plist's WKAppBoundDomains (#435): the server and where its sign-in
/// goes. Fixed when the app is built, as Apple has it; a debug build adds localhost.
static let appBoundHosts = Set(Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String] ?? [])
static func isAppBound(_ url: URL?) -> Bool {
url?.host.map { appBoundHosts.contains($0.lowercased()) } ?? false
}
/// A path the page sent (`/media/42`), against the server.
static func resolve(_ path: String) -> URL? {
guard let base = url else { return nil }
return URL(string: path, relativeTo: base)?.absoluteURL
}
/// Where the player, the badge and CarPlay talk to ipx, which is not where the page is: an
/// address with no Cloudflare Access in front, for the API token alone (#419). Always https,
/// since the token travels in a header.
private static let apiKey = "ipx.api"
static let suggestedAPI = "https://ipxapi.sdf1.net"
static var apiURL: URL? {
get { UserDefaults.standard.string(forKey: apiKey).flatMap(normalise).flatMap { $0.scheme == "https" ? $0 : nil } }
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: apiKey) }
}
/// A path against the player's address when there are credentials to send there, else the
/// page's, where the web view's cookie does the signing in.
static func resolveForPlayer(_ path: String) -> URL? {
guard Credentials.isSet, let base = apiURL else { return resolve(path) }
return URL(string: path, relativeTo: base)?.absoluteURL
}
}

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