69 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:52:22 +00:00
e459d1474c Start 0.12.1-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:38:34 +00:00
93 changed files with 6845 additions and 1260 deletions

View File

@@ -1,14 +1,25 @@
--- ---
name: ipx-prod-check name: ipx-prod-check
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki, its traces in Tempo and its metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site. description: Look for problems in production ipx (the ipx-web, iPX and ipx-directory containers on Tower, Tower itself and the monitoring that watches them) from their logs in Loki, their traces in Tempo and the metrics in Prometheus, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
--- ---
# Checking production ipx # Checking production ipx
Production logs one JSON object a line (`IPX_LOG_FORMAT=json`), which Alloy ships to Loki under Production is three containers in the Arcane project `ipx` on Tower
`{container="iPX"}`, and sends traces to Tempo tagged (`/mnt/fast/arcane/projects/ipx/compose.yaml`), one request passing through them in this order:
`resource.deployment.environment.name="production"`. Anything without that tag is a daemon run by
hand, not production. Loki and Tempo publish no query port, so use the script beside this file: | container | what | logs, in Loki | traces, in Tempo | metrics, in Prometheus |
|---|---|---|---|---|
| `ipx-web` | Caddy: serves the page, passes `/api` and `/media` to ipx (#203). Port 8099, the LAN and the tunnel | `{container="ipx-web"}`, JSON: a line a request (`http.log.access`), its own errors | service `ipx-web`: a span a request, passing `traceparent` on to ipx (#213) | none |
| `iPX` | the daemon: API, scans, downloads, DeadAir | `{container="iPX"}`, JSON (`IPX_LOG_FORMAT=json`) | service `ipx`; passes `traceparent` to the directory | `job="ipx"`, its `/metrics` |
| `ipx-directory` | the directory of public podcasts, `directory.ipxreader.app` (#204, #214) | `{container="ipx-directory"}`, JSON (`DIRECTORY_LOG_FORMAT=json`) | service `ipx-directory`: a span a request, joining ipx's trace when ipx called; a trace a refresher round (#213) | none |
Under them, Tower itself: `job="node"` (the node exporter: load, CPU, memory, disks). The Grafana
dashboard `iPX` draws all of it in that order (`grafana/dashboard.py`). A trace without the
production tag (`resource.deployment.environment.name="production"`, on all three) is a daemon run
by hand, not production. Since #213 a request is one trace across the services it touched: a
Directory page is ipx-web, then ipx, then the directory, so start from the trace, not the logs. Alloy ships the logs; Loki, Tempo and
Prometheus publish no query port to this machine, so use the script beside this file:
```sh ```sh
Q=.claude/skills/ipx-prod-check/query.py Q=.claude/skills/ipx-prod-check/query.py
@@ -20,13 +31,24 @@ $Q prom '<PromQL>' [since] # Prometheus, now; $range becomes `sinc
``` ```
`since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new. `since` is `30m`, `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
"Since the last deploy" is since the container started: "Since the last deploy" is since that container started (each deploys on its own):
```sh ```sh
S=$(docker inspect iPX -f '{{.State.StartedAt}}'); echo $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m for c in iPX ipx-web ipx-directory; do S=$(docker inspect $c -f '{{.State.StartedAt}}'); echo "$c $(( ($(date +%s) - $(date -d "$S" +%s)) / 60 + 1 ))m"; done
``` ```
## What the log carries Start every check by seeing that all three run, since Loki cannot see a container that is not:
```sh
docker ps -a --filter name=iPX --filter name=ipx-web --filter name=ipx-directory --format '{{.Names}} {{.Status}}'
```
All three should be `Up`, with no recent restart; they have no Docker healthcheck (#218). Then work through ipx (the checks below), the page, the
directory, Tower and the monitoring itself, in that order.
## ipx
### What the log carries
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`. innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
@@ -59,7 +81,7 @@ Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`):
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
`| json | __error__=""` drops them. `| json | __error__=""` drops them.
## What Prometheus carries ### What Prometheus carries
The daemon's own state between requests, which no log line or trace holds (#194): ipx serves 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 `/metrics` on `[web] metrics_bind` (production: `192.168.1.130:9187`, LAN only), on a thread and
@@ -74,7 +96,8 @@ from 2026-10-11 15:15 UTC. All gauges:
| `ipx_torrent_slots_free` | of 2 | | `ipx_torrent_slots_free` | of 2 |
| `ipx_deadair_playing` | episodes being played through DeadAir's playlist | | `ipx_deadair_playing` | episodes being played through DeadAir's playlist |
| `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread | | `ipx_runtime_workers`, `ipx_runtime_tasks`, `ipx_runtime_queue` | tokio: threads, live tasks, tasks waiting for a thread |
| `ipx_process_resident_bytes`, `ipx_process_open_fds` | memory and open files and sockets | | `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_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; the healthcheck passes while it is stuck |
| `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told | | `ipx_jobs_total{kind}`, `ipx_job_seconds_total{kind}` | counters: jobs finished, and their seconds all told |
@@ -86,9 +109,10 @@ from 2026-10-11 15:15 UTC. All gauges:
| `ipx_feed_retries` | feeds waiting to retry a failed name lookup | | `ipx_feed_retries` | feeds waiting to retry a failed name lookup |
| `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design | | `ipx_art_cache_bytes`, `ipx_art_cache_limit_bytes` | artwork kept, as of the last sweep, and the most kept: kept sits at the limit by design |
| `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small | | `ipx_art_evicted_total`, `ipx_art_requests_total{result="hit"\|"miss"}` | counters: images the sweep threw out to stay under the limit, and artwork served from disk or fetched from the publisher; many evicted at every sweep with a falling hit rate means the limit is too small |
| `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) | | `ipx_feeds`, `ipx_downloads_pending`, `ipx_files_downloaded` | the library, as the healthcheck last counted it (every 30 s) |
## The checks ### The checks
Run these, then read the lines behind whatever stands out. Most of the time is in the reading: Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
a count says something happened, the lines and traces say why. a count says something happened, the lines and traces say why.
@@ -131,7 +155,7 @@ a count says something happened, the lines and traces say why.
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is `feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
the real queue (#113): files a scan will download on its own. Back-catalogue files are 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. `held`, listed but not counted, so it is usually 0 or a handful.
5. **Slow and failed traces.** 5. **Slow and failed traces.** All three services' (filter by `resource.service.name` for one).
``` ```
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}' $Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
$Q traces '{resource.deployment.environment.name="production" && status = error}' $Q traces '{resource.deployment.environment.name="production" && status = error}'
@@ -198,13 +222,133 @@ a count says something happened, the lines and traces say why.
the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a
`stall`, is worth a trace: was it the server, Cloudflare, or the network. `stall`, is worth a trace: was it the server, Cloudflare, or the network.
Also check the container itself, since Loki cannot see a daemon that is not running: `docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
## ipx-web, the page
Caddy logs one JSON line a request (#210), logger `http.log.access.log0`: Loki's `| json` gives
`request_uri`, `request_method`, `request_remote_ip`, `status`, `duration` (seconds, not ms), `size`.
The request's headers and a `?token=` are filtered out before it is written. Requests from
`127.0.0.1` are local checks; leave them out. `/api` and `/media` pass on to ipx and
are in ipx's own access log too, with ipx's time; Caddy's `duration` for them adds the hop.
```sh ```sh
docker ps --filter name=iPX --format '{{.Status}}' W='{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
docker logs --since 10m iPX 2>&1 | tail -5 $Q metric "sum by (status) (count_over_time($W [\$range]))"
$Q metric "sum by (request_uri) (count_over_time($W | status >= 500 [\$range]))"
$Q metric "topk(10, quantile_over_time(0.95, $W | request_uri !~ \"/(api|media)/.*\" | unwrap duration [\$range]) by (request_uri))"
$Q logs '{container="ipx-web"} | json | __error__="" | level=~"warn|error" | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"'
``` ```
A 502 is ipx not answering Caddy: a restart (match the deploy) or ipx down; Caddy's error line
names it (`dial tcp ...:8099: connect: connection refused`). The page itself (`/`, `/admin`)
waits on `/api/page-check` before it is sent, so a slow page is usually a slow page-check: look it
up in ipx's access log. 401 and 303 on `/` are people signed out being sent to `/login`, not a
problem. Caddy's own notices at each start (no admin endpoint, no HTTPS or HTTP/3, its goodbye on
SIGTERM) are by design and filtered out above.
Each access line carries the request's `traceID` (`| json` gives `traceID`): give it to `$Q trace`
to see where the time went, Caddy's span with ipx's inside it. A slow page with ipx's span short is
Caddy or the network; a long one is ipx.
```sh
$Q traces '{resource.service.name="ipx-web" && duration > 2s}'
$Q traces '{resource.service.name="ipx-web" && status = error}'
```
Who reaches it: `request_remote_ip` is `192.168.64.1` for the tunnel and anything else on Tower
(Docker's gateway), `192.168.1.130` for Tower itself, a LAN address for the LAN. Caddy passes
Cloudflare Access's headers on only from `192.168.64.1` (`TUNNEL_PEER`), and ipx trusts them only
from Caddy, with a valid signed token. Testing sign-in from Tower proves nothing about the tunnel
(#209): send it from a container on another network.
## ipx-directory
It logs a line a request (`target` `ipx_directory::http`: `method`, `path`, `status`,
`duration_ms`, `token`, the token's name and never the token), a line a snapshot (`message`
`snapshot`: `instance`, `accepted`, `refused_private`, `refused_blocked`, `refused_invalid`), a
line a refresher round (`feeds checked`: `checked`), a line a failed feed check (`feed check
failed`: `feed`, the directory's id, and `error`), the admin's actions (`feed removed and
blocked`, `feed unblocked`, `snapshot dropped`, each with `by`), and `config read again` when its
token file changes. It has no metrics. Its traces (#213): a span a request (`http`), a child of
ipx's when ipx called with a `traceparent`, and a trace a refresher round (`refresh`, `feeds`)
with a `feed` span for each check. Its log lines carry no trace id; find the trace by time or by
the feed.
```sh
$Q traces '{resource.service.name="ipx-directory" && duration > 2s}'
$Q traces '{resource.service.name="ipx-directory" && name="feed" && duration > 10s}'
$Q traces '{resource.service.name="ipx" && name="directory_share"}'
```
A slow Directory page in ipx is usually its `http` span in the directory: the trace says whether.
A refresher round far over a minute is one publisher's feed holding it, the slow `feed` span.
```sh
D='{container="ipx-directory"} | json | __error__=""'
$Q metric "sum by (status, token) (count_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" [\$range]))"
$Q metric "topk(5, max_over_time({container=\"ipx-directory\"} |= \"ipx_directory::http\" | json | __error__=\"\" | unwrap duration_ms [\$range]) by (path))"
$Q logs "$D | level=~\"WARN|ERROR\""
$Q logs "$D | message=\"snapshot\"" 7d
$Q metric "sum(count_over_time($D | message=\"snapshot\" [\$range])) by (instance)"
$Q metric "sum by (feed, error) (count_over_time($D | message=\"feed check failed\" [\$range]))" 7d
$Q metric "sum(sum_over_time($D | message=\"feeds checked\" | unwrap checked [\$range]))" 24h
```
Expect a snapshot from each server when what it shares changes and every half hour, so about 48 a
day each; one every half minute is ipx resending what has not changed (#211, fixed). A burst of
401s with an empty `token` is someone without a token trying the public address, expected now and
then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A snapshot whose
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
in that server's log, since the directory refuses it but the sender should not have sent it. Many
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
checked every six hours, 25 at a time, so `feeds checked` over a day should be about four times
the feeds listed; none for over six hours means the refresher is stuck. `could not store a feed
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
`databases` project's Postgres). A config that does not load (`config changed but does not
load`) keeps the old tokens: worth fixing before someone's new token is needed.
## Tower
The three share Tower with everything else, so a slow or failing ipx can be the host. The node
exporter (`job="node"`) has the history; twelve threads.
```sh
$Q prom 'max_over_time(node_load1{job="node"}[$range])' 24h
$Q prom 'min_over_time(node_memory_MemAvailable_bytes{job="node"}[$range]) / 1e9' 24h
$Q prom 'sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$range]))' 1h
$Q prom 'max by (device) (rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$range]))' 1h
```
For when, step back with `offset`: `max_over_time(node_load1[10m] offset 60m)`. Load far above
twelve with little CPU busy is processes waiting, on a disk or on the user shares: on 2026-10-12
debug builds linking through `/mnt/user` (shfs, FUSE) took load to 248 with mostly system time and
stalled everything on the shares, ipx's downloads included. Available memory near nothing makes
it worse. Disk busy near 100% on `nvme1n1` is `/mnt/fast`, appdata and Postgres's data; on `md*`
the array behind `/mnt/user`, the downloads. Say what was running at the time if you can tell
(`docker stats --no-stream`); a host problem is the user's, not an ipx issue, unless ipx caused it.
## The monitoring itself
A quiet dashboard can be a dead exporter. Check what Prometheus scrapes and that each
container's lines are reaching Loki:
```sh
curl -s http://192.168.1.130:9090/api/v1/targets | python3 -c 'import json,sys
for t in json.load(sys.stdin)["data"]["activeTargets"]: print(t["health"], t["labels"]["job"], t["scrapeUrl"], t.get("lastError","")[:80])'
for c in iPX ipx-web ipx-directory; do echo "$c $($Q metric "sum(count_over_time({container=\"$c\"} [1h]))" 1h | tail -1)"; done
docker ps -a --filter label=com.docker.compose.project=monitoring --format '{{.Names}} {{.Status}}'
```
`ipx` and `node` should be `up`. Every monitoring container should be running; after Docker
restarts, one can be left `Exited (255)` though its policy is `unless-stopped` (Prometheus, on
2026-10-12, when Docker lost its exit during an overload): `docker start` it and say so. blocky
and nut on 192.168.2.21 are another machine, not ipx's; leave them. A container with no lines in
an hour while it runs means Alloy is not shipping it; Alloy can take a few minutes to pick up a
container after a deploy.
## Rules
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres `docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
directly: ask the user if a question needs the database. directly: ask the user if a question needs the database.
@@ -225,12 +369,14 @@ Put in each issue what would let someone pick it up cold: the LogQL or TraceQL t
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
saying so (the user may want to unsubscribe or find its new address); a single 503 is not. saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
Finish with a short report to the user: what is healthy, what is wrong (with the issue numbers), Finish with a short report to the user, a line for each of ipx-web, ipx, the directory, Tower and
and anything you could not tell from logs and traces alone. Do not fix things unless asked; the the monitoring: what is healthy, what is wrong (with the issue numbers), and anything you could
not tell from logs, traces and metrics alone. Do not fix things unless asked; the
check is for finding them. check is for finding them.
## When the checks come back empty ## When the checks come back empty
Check that there is data before concluding all is well: `$Q metric 'sum(count_over_time({container="iPX"} [1h]))' 1h` Check that there is data before concluding all is well (the monitoring section's per-container
should be in the hundreds or more. Nothing at all means Alloy is not shipping (it can take a few count): iPX should have hundreds of lines an hour or more, ipx-web at least its healthchecks'
minutes to pick up a container after a deploy), or the container is down. 120, ipx-directory a few dozen. Nothing at all means Alloy is not shipping it, or the container
is down.

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@
.claude/settings.local.json .claude/settings.local.json
__pycache__/ __pycache__/
site/dist/

163
CLAUDE.md
View File

@@ -7,18 +7,22 @@ made here.
## Where things are ## Where things are
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs; Arcane project `ipx`: `/mnt/fast/arcane/projects/ipx/compose.yaml`. That file is what runs, and
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production. the containers of the split (#207) join it there. `docker-compose.yml` in this repo is a sample for
others, ipx alone on SQLite; editing it changes nothing in production. Until 2026-10-12 ipx was a
service of the `content` project, beside audiobookshelf, immich and Plex (#208).
| | Host | In the container | | | Host | In the container |
|---|---|---| |---|---|---|
| Image | `192.168.1.130:5000/ipx:latest` | | | Image | `192.168.1.130:5000/ipx:latest` | |
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` | | Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | | | Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` | | 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` | | Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | | 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`), 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` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) | | Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`. `TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
@@ -47,13 +51,43 @@ Deploying a change is: build and push the image, then pull it and recreate the c
```sh ```sh
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx
docker logs --tail 20 iPX docker logs --tail 20 iPX
``` ```
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich The directory is the same, with its own image:
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
```sh
docker buildx build -f Dockerfile.directory --tag 192.168.1.130:5000/ipx-directory:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-directory
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-directory
```
And the page, `ipx-web`, the same again; a change to `web/` alone needs only this one:
```sh
docker buildx build -f Dockerfile.web --tag 192.168.1.130:5000/ipx-web:latest . --push
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml pull ipx-web
docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml up -d ipx-web
```
The home page (`site/`, #216) is not on Tower. `sh site/build.sh` puts it in `site/dist` with the
typeface, logo and icon from `web/`, and it goes to the Cloudflare Pages project `ipxreader` by
direct upload, since there is no wrangler login here: hash each file as wrangler does (BLAKE3 of
its base64 contents plus its extension, the first 32 hex digits, with the `blake3-wasm` package),
ask the project for an upload token (`GET .../pages/projects/ipxreader/upload-token`), send the
files to `/pages/assets/upload` with that token, then create a deployment
(`POST .../pages/projects/ipxreader/deployments`) with the manifest of paths and hashes and
`_headers`. Its screenshots (`site/shots`) come from a scratch iPX on public feeds, never
production's, which would show whose subscriptions are whose.
Testing sign-in from Tower itself proves nothing about the tunnel: Tower's own requests reach the
published port as `192.168.1.130`, and Caddy rightly strips their `Cf-Access-*`. Send them from a
container on another network, as cloudflared's are (#209).
**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 **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 this host. There is no Docker Hub login here, and the build asks about `debian:trixie-slim` and
@@ -69,11 +103,13 @@ 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. Run those again now and then, or the local copies go stale.
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means The containers have no Docker healthcheck (#218): Docker runs one with `exec`, which on Tower
the daemon answers there and can read its database, not just that the web port is up. The socket fails whenever root is logged out, because elogind removes `/run/user/0`, where containerd puts
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or each exec's state. Whether ipx is well is Prometheus's question (`up`, the worker's job and time)
download does not fail the check; it also means a worker stuck on one job would still pass. The and `ipx status` against the control socket, which says the daemon answers and can read its
container restarts on its own after a reboot. database. The socket answers `status` itself instead of queuing it behind the worker's current
job, so a worker stuck on one job still answers it. The containers restart on their own after a
reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run `/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
@@ -87,13 +123,23 @@ running the command and kills the session (exit 144), which has happened more th
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to 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, 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 `web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
make up each page's script. `build.rs` runs make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is (`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a serves no page. The page loads its script as `/app.js?v=<hash of its contents>`, and `/app.css`
deploy changes them and their names. So **every page change needs a and `/boot.js` the same way: the page is served `no-cache` and those `immutable`, so a browser
rebuild** before it is visible, and building needs node and `npm ci` run once. keeps them until a deploy changes them and their names. So **every page change needs ipx-web
rebuilt and deployed**, not ipx, and building needs node and `npm ci` run once.
Caddy asks ipx before it serves a page (`forward_auth`): `/api/page-check` sends a browser that is
not signed in to `/login` and anyone but an admin away from `/admin` (and refuses them
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
for everyone, so what ipx used to write into it per person is the page's own job now:
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
theme).
The files in `web/src` are not modules. They are one script split up, concatenated in the order 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.mjs` lists them, sharing one top-level scope as the single inline script did; a new
@@ -105,13 +151,19 @@ After any edit to it:
```sh ```sh
npx tsc -p . npx tsc -p .
node tests/page-smoke.js node tests/page-smoke.js
node --test tests/page-unit.js
``` ```
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a
type, what the API sends is typed in `web/src/types.ts`, which mirrors the Rust structs and changes
with them, and a value that may be null has to be checked before use. `$<T>` returns the element
type it is given (`$<HTMLInputElement>('#nurl')`) and assumes the element is there; one that may
not be is written `$(...)?.`, and the page check below proves the ones wired at load exist. The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a function, `String.replace` silently matched nothing, and the whole UI died with a
`ReferenceError` while every server-side test passed. `ReferenceError` while every server-side test passed. The third loads the same built script and
calls its helpers directly (#234): a pure function added to the page gets its check there.
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping. (`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
@@ -122,6 +174,7 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src npx tsc -p . # type-checks web/src
node tests/page-smoke.js 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/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 66 browser tests against a real daemon on fixture feeds npx playwright test # 66 browser tests against a real daemon on fixture feeds
@@ -155,8 +208,7 @@ 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. 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 * 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. that has started asking once per feed, or writers queueing on a lock, not a slow minute.
* `ipx status`, the Docker healthcheck, runs every second throughout, and a run fails if one takes * `ipx status` runs every second throughout, and a run fails if one takes 5s.
the healthcheck's 5s.
* One at a time: `node tests/load/run.js signin`. All four take about six minutes. * One at a time: `node tests/load/run.js signin`. All four take about six minutes.
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in * 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 a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
@@ -203,7 +255,7 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a * `/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. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log. getting through its jobs, watch for `scan complete` in the log.
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.** * **The database goes through SeaORM, and the entities in `crates/ipx/src/entity.rs` are the schema.**
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx` `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 `ipx status` included, so it must never write when nothing is
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
@@ -221,24 +273,55 @@ Comments explain **why**, not what. If a line looks odd, the comment says what w
it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and it. No emoji, no exclamation marks, no "obviously". Prose in the UI and docs is plain English and
addressed to the person using it. addressed to the person using it.
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its Every change gets one line under `## [Unreleased]` in the changelog of the service it changes, in
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated, its [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to Removed, Fixed or Security. One changelog a service: [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md)
(the daemon and its API; until 0.12.0 everything), [web/CHANGELOG.md](web/CHANGELOG.md) (the page
and ipx-web) and [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) (ipx-directory). A
change to more than one gets a line in each, each saying what that service does differently. The
monitoring (the Grafana dashboard, the prod-check skill) and the repository's own tooling are not
a service anyone uses and get no line. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, it goes in the commit message's say, such as what was wrong before or what it cost to find out, it goes in the commit message's
body, where `git log` and `git blame` find it beside the change. (There was a long-form body, where `git log` and `git blame` find it beside the change. (There was a long-form
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.) `docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Cutting a release: first read `[Unreleased]` against the last release, not line by line, and rewrite Each service has its own version and is released on its own, when its changelog has something to
it as what changed between the two. Lines go in as the work happens, so they record the way there: release:
a feature added and then reworked in the same release is one line saying what it is now, under
Added; something added and taken out again before the release, or a fix for a bug no release ever | service | version in | changelog | tag |
had, is not there at all; and "instead of" and "no longer" compare with the last release, not with |---|---|---|---|
last week. Issue numbers stay, all of them, on the line that survives. Then rename `[Unreleased]` to | ipx | `crates/ipx/Cargo.toml` | `crates/ipx/CHANGELOG.md` | `vX.Y.Z`, as every release before the split |
`## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit | ipx-directory | `crates/directory/Cargo.toml` | `crates/directory/CHANGELOG.md` | `directory-vX.Y.Z` |
`vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit, | ipx-web, the page | `package.json` | `web/CHANGELOG.md` | `web-vX.Y.Z` |
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
in the logo's tooltip instead of claiming to be the last release. The release that follows can `crates/feed` is a library the two Rust services build in, not released (`0.0.0`); a change to it
still be a minor or major one; `-dev` only says the work comes after `X.Y.Z`. is a line in the changelog of each service it changes. The page shows ipx's version and its own in
the logo's tooltip; the directory logs its own at start; each binary answers `--version`.
Cutting a release of one: first read its `[Unreleased]` against its last release, not line by
line, and rewrite it as what changed between the two. Lines go in as the work happens, so they
record the way there: a feature added and then reworked in the same release is one line saying
what it is now, under Added; something added and taken out again before the release, or a fix for
a bug no release ever had, is not there at all; and "instead of" and "no longer" compare with the
last release, not with last week. Issue numbers stay, all of them, on the line that survives. Then
rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty `[Unreleased]` above it,
set the service's version to `X.Y.Z` (dropping `-dev`), tag the commit with the service's tag, and
update the compare links at the bottom of its changelog. Push the commit and the tag, build and
deploy the service's image, then `sh scripts/release.sh ipx|directory|web` makes the Gitea
release: that version's changelog section as its notes, and a tarball of what the image runs (the
binary, or the page's files with the Caddyfile) attached, checked to be that version. Then, in the
next commit, set its version
to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
instead of claiming to be the last release. The release that follows can still be a minor or major
one; `-dev` only says the work comes after `X.Y.Z`. A service never released is at `0.1.0-dev`,
working towards its first release, `0.1.0`.
**Traces first, logs second.** Anything that does work (a service, a handler, a job, a call to
another service) gets spans, and passes `traceparent` on to whatever it calls, so one trace in
Tempo follows a request or a job across ipx-web, ipx and the directory. A trace shows where the
time went and what called what; a log line only that something happened. Log what a trace cannot
carry: events worth counting on the dashboard, warnings and errors. A new service or route without
traces is not finished.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g. Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`. `// ponytail: global connection mutex, move to a pool if feed count makes it contend`.

70
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.12.0" version = "1.2.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
@@ -1830,11 +1830,11 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"futures-util", "futures-util",
"ipx-directory",
"ipx-feed",
"jsonwebtoken", "jsonwebtoken",
"librqbit", "librqbit",
"opentelemetry", "opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"opml", "opml",
"percent-encoding", "percent-encoding",
"quick-xml 0.42.0", "quick-xml 0.42.0",
@@ -1854,6 +1854,52 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "ipx-directory"
version = "1.1.1"
dependencies = [
"ammonia",
"anyhow",
"axum",
"clap",
"futures-util",
"ipx-feed",
"mimalloc",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"percent-encoding",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"toml",
"tower",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"url",
]
[[package]]
name = "ipx-feed"
version = "0.0.0"
dependencies = [
"anyhow",
"atom_syndication",
"chrono",
"opml",
"quick-xml 0.42.0",
"reqwest",
"rss",
"serde_json",
"tokio",
"tracing",
"url",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -2058,6 +2104,15 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.23" version = "0.1.23"
@@ -2497,6 +2552,15 @@ dependencies = [
"rapidhash", "rapidhash",
] ]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"

View File

@@ -1,9 +1,15 @@
[package] [workspace]
name = "ipx" members = ["crates/ipx", "crates/feed", "crates/directory"]
version = "0.12.0" resolver = "3"
# Each service has its own version, in its own Cargo.toml (ipx, ipx-directory) or package.json
# (the page), released and tagged on its own; see CLAUDE.md.
[workspace.package]
edition = "2024" edition = "2024"
[dependencies] [workspace.dependencies]
ipx-feed = { path = "crates/feed" }
ipx-directory = { path = "crates/directory" }
ammonia = "4.1.4" ammonia = "4.1.4"
anyhow = "1.0.104" anyhow = "1.0.104"
argon2 = "0.6.0" argon2 = "0.6.0"
@@ -18,6 +24,7 @@ opentelemetry = { version = "0.33", default-features = false, features = ["trace
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] } opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] } opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
opml = "1.1.6" opml = "1.1.6"
mimalloc = "0.1.52"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] } quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }

View File

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

25
Dockerfile.directory Normal file
View File

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

16
Dockerfile.web Normal file
View File

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

View File

@@ -31,11 +31,13 @@ With Docker:
```sh ```sh
docker build -t ipx . docker build -t ipx .
docker build -f Dockerfile.web -t ipx-web .
docker compose up -d docker compose up -d
``` ```
`docker-compose.yml` is set up for the author's own server. Point its `image` and its three volumes `docker-compose.yml` is a sample: ipx, with its database in SQLite and its three volumes
(`/config`, `/data` and `/downloads`) at yours first. The UI is on port 8099. BitTorrent uses 6881 (`/config`, `/data` and `/downloads`) in folders beside the file, and ipx-web, the page, served by
Caddy in front of it. Change them to suit. The UI is on port 8099. BitTorrent uses 6881
over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default. over TCP and UDP. Files are written as `PUID`/`PGID`, 99:100 by default.
From source: From source:
@@ -62,7 +64,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
| [docs/users.md](docs/users.md) | Accounts, and what several people share | | [docs/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/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 | | [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release | | [crates/ipx/CHANGELOG.md](crates/ipx/CHANGELOG.md), [web/CHANGELOG.md](web/CHANGELOG.md), [crates/directory/CHANGELOG.md](crates/directory/CHANGELOG.md) | What changed, by release, for ipx, the page and the directory |
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed | | [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
## Tests ## Tests

View File

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

View File

@@ -0,0 +1,51 @@
# Changelog: ipx-directory
The directory of public podcasts that ipx servers share (docs/directory.md). ipx's changelog is
[crates/ipx/CHANGELOG.md](../ipx/CHANGELOG.md), the page's [web/CHANGELOG.md](../../web/CHANGELOG.md).
Its version is in `crates/directory/Cargo.toml`, and a release is tagged `directory-vX.Y.Z`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.1.1] - 2026-10-15
### Changed
- The directory checks five feeds at a time, so a publisher that does not answer no longer holds up the rest of its round (#227).
- A feed's server that takes 10 s to connect, or sends nothing for 15 s, is given up on (#230).
### Security
- A failed feed check is logged without its address's query or password (#238).
## [1.1.0] - 2026-10-13
### Added
- With `metrics_bind` set, the directory serves Prometheus metrics on a port of its own: memory, open files, and the feeds it has fetched and their bytes. A feed check's trace says how large the feed was (#224).
### Changed
- The directory uses mimalloc instead of glibc's allocator, so it gives back memory it has freed instead of holding over a gigabyte while idle (#224).
- The example in docs/directory.md has no healthcheck; `ipx-directory health` is still there to run by hand (#218).
### Security
- A feed check's trace keeps the feed's address without its query or password (#225).
## [1.0.0] - 2026-10-12
### Added
- Anyone can browse the directory in a web browser, read only: the most subscribed, categories, every feed A to Z, a search by name, and each feed's page with its latest items and its address to copy into any podcast app (#215).
- `GET /v1/instances` lists the servers holding a snapshot, with their feeds and subscribers, for an admin token (#206).
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...main
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,11 +1,10 @@
//! Feed fetching and parsing. Replaces FeedData.__getFeed / __getEntries. //! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
//! FeedData.__getFeed / __getEntries.
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use reqwest::StatusCode; use reqwest::StatusCode;
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
use crate::config::Feed as FeedCfg;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct ParsedFeed { pub struct ParsedFeed {
pub title: Option<String>, pub title: Option<String>,
@@ -62,19 +61,65 @@ pub enum Fetched {
/// downloads, which are not, have no such limit. /// downloads, which are not, have no such limit.
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// The longest a feed's server may send nothing, for the clients that fetch feeds to set: one
/// that took the connection and never answered ran the whole 30 s, every scan it was in (#230).
/// No feed that answered took over 8 s in a day of traces; one that keeps sending, however
/// slowly, still has until FEED_TIMEOUT.
pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15);
/// Feeds fetched and the bytes of their bodies, since the process started, for /metrics to set
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
/// allocations.
pub static FETCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub static FETCHED_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so /// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
/// each hop is seen. The second value is where the feed now is when every hop said so for good /// each hop is seen. The second value is where the feed now is when every hop said so for good
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather /// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing. /// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
/// `system-proxy` reads http_proxy/https_proxy. /// `system-proxy` reads http_proxy/https_proxy.
#[tracing::instrument(skip_all, fields(url = %cfg.url))] /// `auth` is a username and password, sent only to the feed's own host.
/// An address as a trace may keep it: without its query or user and password, where a private
/// feed carries its credential (Patreon's `?auth=`), which Tempo kept for anyone reading it (#225).
/// shortcut: a token in the path (`/feed/<token>.xml`) stays; strip paths too if a feed does that.
pub fn redact(url: &str) -> String {
match url::Url::parse(url) {
Ok(mut u) => {
u.set_query(None);
u.set_fragment(None);
let _ = u.set_username("");
let _ = u.set_password(None);
u.to_string()
}
// Not an address ipx could fetch; keep what comes before anything that could be a query.
Err(_) => url.split(['?', '#', '@']).next().unwrap_or_default().to_owned(),
}
}
/// Text with each address in it redacted, for what is logged and sent to every open page: an
/// error from reqwest names the whole address it fetched, a private feed's key with it (#238).
pub fn redact_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("http://").into_iter().chain(rest.find("https://")).min() {
out.push_str(&rest[..i]);
let end = rest[i..].find(|c: char| c.is_whitespace() || "()<>\"'".contains(c)).map_or(rest.len(), |n| i + n);
out.push_str(&redact(&rest[i..end]));
rest = &rest[end..];
}
out.push_str(rest);
out
}
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
pub async fn fetch( pub async fn fetch(
client: &reqwest::Client, client: &reqwest::Client,
cfg: &FeedCfg, feed_url: &str,
auth: Option<(&str, Option<String>)>,
etag: Option<&str>, etag: Option<&str>,
last_modified: Option<&str>, last_modified: Option<&str>,
) -> Result<(Fetched, Option<String>)> { ) -> Result<(Fetched, Option<String>)> {
let start = reqwest::Url::parse(&cfg.url).context("the feed's address")?; let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
let mut url = start.clone(); let mut url = start.clone();
let mut permanent = true; let mut permanent = true;
for _ in 0..10 { for _ in 0..10 {
@@ -86,10 +131,10 @@ pub async fn fetch(
req = req.header(IF_MODIFIED_SINCE, lm); req = req.header(IF_MODIFIED_SINCE, lm);
} }
// The feed's own host only: a redirect elsewhere must not be handed the password. // The feed's own host only: a redirect elsewhere must not be handed the password.
if let Some(user) = &cfg.username if let Some((user, password)) = &auth
&& url.host_str() == start.host_str() && url.host_str() == start.host_str()
{ {
req = req.basic_auth(user, cfg.password()); req = req.basic_auth(user, password.as_deref());
} }
let resp = req.send().await.context("connecting")?; let resp = req.send().await.context("connecting")?;
@@ -105,6 +150,7 @@ pub async fn fetch(
continue; continue;
} }
let moved = (permanent && url != start).then(|| url.to_string()); let moved = (permanent && url != start).then(|| url.to_string());
FETCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if status == StatusCode::NOT_MODIFIED { if status == StatusCode::NOT_MODIFIED {
return Ok((Fetched::NotModified, moved)); return Ok((Fetched::NotModified, moved));
} }
@@ -118,6 +164,8 @@ pub async fn fetch(
let etag = header(ETAG); let etag = header(ETAG);
let last_modified = header(LAST_MODIFIED); let last_modified = header(LAST_MODIFIED);
let bytes = resp.bytes().await.context("reading body")?.to_vec(); let bytes = resp.bytes().await.context("reading body")?.to_vec();
tracing::Span::current().record("http.response.body.size", bytes.len());
FETCHED_BYTES.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
return Ok((Fetched::Body { bytes, etag, last_modified }, moved)); return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
} }
// Worded as reqwest worded it, which failure_kind reads as a redirect loop. // Worded as reqwest worded it, which failure_kind reads as a redirect loop.
@@ -231,10 +279,21 @@ pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
let doc = opml::OPML::from_str(&text) let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?; .map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
let mut out = vec![]; let mut out = vec![];
crate::collect_outlines(&doc.body.outlines, &mut out); collect_outlines(&doc.body.outlines, &mut out);
Ok(out) Ok(out)
} }
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
for o in outlines {
if let Some(url) = &o.xml_url {
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
out.push((title, url.clone()));
}
collect_outlines(&o.outlines, out);
}
}
/// The <head><title> of an OPML document. /// The <head><title> of an OPML document.
pub fn opml_title(bytes: &[u8]) -> Option<String> { pub fn opml_title(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes); let text = String::from_utf8_lossy(bytes);
@@ -352,6 +411,7 @@ fn parse_patreon_shows(json: &[u8]) -> Result<(Option<String>, Vec<(String, Stri
} }
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`. /// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
#[tracing::instrument(skip_all, fields(bytes = bytes.len()))]
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> { pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
match rss::Channel::read_from(bytes) { match rss::Channel::read_from(bytes) {
Ok(ch) => Ok(from_rss(ch, bytes)), Ok(ch) => Ok(from_rss(ch, bytes)),
@@ -1121,8 +1181,67 @@ fn picked(answer: &serde_json::Value) -> Result<String> {
.ok_or_else(|| anyhow!("no category in the answer: {answer}")) .ok_or_else(|| anyhow!("no category in the answer: {answer}"))
} }
/// An address that carries a subscriber's own access: a paid-feed host, credentials in the URL,
/// or a key in the query. ipx leaves such a feed out of anything others see, and the directory
/// refuses one sent to it (#202).
///
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
/// through; a per-feed private flag is the upgrade (#205).
pub fn url_looks_private(feed_url: &str) -> bool {
let Ok(u) = url::Url::parse(feed_url) else { return true };
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
// long path segment alone proves nothing (acast's public show ids look the same). So a
// feed from one of them is private whatever its URL looks like. Supercast is why this
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
let host = u.host_str().unwrap_or("");
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|| !u.username().is_empty()
|| u.password().is_some()
|| u.query_pairs().any(|(k, _)| {
let k = k.to_ascii_lowercase();
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
.iter()
.any(|w| k.contains(w))
})
}
/// Services whose feeds are always one subscriber's own.
const PAID_HOSTS: &[&str] =
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn redact_drops_what_carries_a_credential() {
assert_eq!(super::redact("https://www.patreon.com/rss/x?auth=SECRET&show=1"), "https://www.patreon.com/rss/x");
assert_eq!(super::redact("https://me:pw@example.com/feed.xml#top"), "https://example.com/feed.xml");
assert_eq!(super::redact("https://example.com/feed.xml"), "https://example.com/feed.xml");
assert_eq!(super::redact("not a url?token=SECRET"), "not a url");
// As reqwest words a failure, and with two addresses and none.
assert_eq!(
super::redact_text("connecting: error sending request for url (https://www.patreon.com/rss/x?auth=SECRET&show=1): operation timed out"),
"connecting: error sending request for url (https://www.patreon.com/rss/x): operation timed out"
);
assert_eq!(super::redact_text("from http://a/f?k=1 to https://u:p@b/g"), "from http://a/f to https://b/g");
assert_eq!(super::redact_text("HTTP 404 Not Found"), "HTTP 404 Not Found");
}
#[test]
fn an_address_carrying_someones_access_looks_private() {
let private = super::url_looks_private;
assert!(!private("https://feeds.twit.tv/twit.xml"));
// Patreon's shape: the key is a query parameter.
assert!(private("https://www.patreon.com/rss/x?auth=abc123&show=2073588"));
assert!(private("https://example.com/rss?api_key=abc"));
// Paid-feed services put the key in the path; the host gives them away.
assert!(private("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx"));
assert!(private("https://someshow.supportingcast.fm/content/abc123.rss"));
// A long path segment alone is not a key: acast's public show ids look the same.
assert!(!private("https://feeds.acast.com/public/shows/0123456789abcdef01234567"));
assert!(private("https://ray:hunter2@example.com/rss"));
assert!(private("not a url"), "unparseable is not safe to list");
}
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good /// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
/// to /tmp, /new is the feed. /// to /tmp, /new is the feed.
async fn redirecting_server() -> String { async fn redirecting_server() -> String {
@@ -1157,9 +1276,9 @@ mod tests {
let base = redirecting_server().await; let base = redirecting_server().await;
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap(); let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
let get = |path: &str| { let get = |path: &str| {
let cfg: crate::config::Feed = serde_json::from_value(serde_json::json!({ "url": format!("{base}{path}") })).unwrap(); let url = format!("{base}{path}");
let client = client.clone(); let client = client.clone();
async move { super::fetch(&client, &cfg, None, None).await } async move { super::fetch(&client, &url, None, None, None).await }
}; };
let (got, moved) = get("/old").await.unwrap(); let (got, moved) = get("/old").await.unwrap();
assert!(matches!(got, super::Fetched::Body { .. })); assert!(matches!(got, super::Fetched::Body { .. }));
@@ -1175,7 +1294,7 @@ mod tests {
#[test] #[test]
fn parses_rss_with_itunes_extensions() { fn parses_rss_with_itunes_extensions() {
let bytes = include_bytes!("../tests/data/rss2.xml"); let bytes = include_bytes!("../../../tests/data/rss2.xml");
let feed = parse(bytes).unwrap(); let feed = parse(bytes).unwrap();
assert_eq!(feed.title.as_deref(), Some("Test Cast")); assert_eq!(feed.title.as_deref(), Some("Test Cast"));
@@ -1381,7 +1500,7 @@ mod tests {
#[test] #[test]
fn parses_atom_enclosure_links() { fn parses_atom_enclosure_links() {
let bytes = include_bytes!("../tests/data/atom.xml"); let bytes = include_bytes!("../../../tests/data/atom.xml");
let feed = parse(bytes).unwrap(); let feed = parse(bytes).unwrap();
assert_eq!(feed.title.as_deref(), Some("Atom Cast")); assert_eq!(feed.title.as_deref(), Some("Atom Cast"));
@@ -1479,8 +1598,8 @@ mod tests {
assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into())); assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into()));
// A feed must never be mistaken for a subscription list. // A feed must never be mistaken for a subscription list.
assert!(!is_opml(include_bytes!("../tests/data/rss2.xml"))); assert!(!is_opml(include_bytes!("../../../tests/data/rss2.xml")));
assert!(!is_opml(include_bytes!("../tests/data/atom.xml"))); assert!(!is_opml(include_bytes!("../../../tests/data/atom.xml")));
} }
#[test] #[test]

View File

@@ -1,17 +1,68 @@
# Changelog # Changelog: ipx
All notable changes to this project will be documented in this file. The daemon: the API, scans, downloads, DeadAir and the database. The page has its own changelog,
[web/CHANGELOG.md](../../web/CHANGELOG.md), and the directory service its own,
[crates/directory/CHANGELOG.md](../directory/CHANGELOG.md). Until 0.12.0 this was the changelog of
all of ipx, the page included; they were split with the services (#207).
Its version is in `crates/ipx/Cargo.toml`, and a release is tagged `vX.Y.Z`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [1.2.0] - 2026-10-15
### Added
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
### Changed
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
- A scan's trace names the feed each fetch is for (#229).
### Security
- A failure's words, and the addresses in an event, go to the log and to every open page without their query or password, where a private feed keeps its key: a failed fetch of a Patreon feed logged its `auth` key (#238).
## [1.1.0] - 2026-10-13
### Added
- `/metrics` counts the feeds fetched and the bytes of their bodies (`ipx_feed_fetches_total`, `ipx_feed_bytes_total`), and a feed's trace says how large its body was (#224).
### Changed
- ipx uses mimalloc instead of glibc's allocator, which gives freed memory back to the system: with glibc's, the memory a night of scans needed stayed held until a restart (#224).
- `docker-compose.yml` has no healthchecks: Docker runs them with exec, which can fail on the host while ipx is well. The library's counts in `/metrics` are kept every 30 s by the daemon itself (#218).
### Security
- Traces no longer keep a feed's or file's address with its query or password, where private feeds carry their credentials, such as Patreon's `auth` (#225).
## [1.0.0] - 2026-10-12
### Added
- With a directory token that may administer it, ipx's admins remove a feed from the directory (blocking it) or check it now, list and unblock what is blocked, and list and drop the servers sharing (#206).
- ipx passes its trace on when it calls the directory, so a Directory page is one trace from the browser to the directory (#213).
### Changed
- The Directory comes from a directory service that iPX servers share, set up in Admin, Directory with its address and this server's token, instead of only this server's feeds. Sharing this server's public feeds with it is a setting, never a private one, and subscriber counts are every sharing server's. ipx sends the directory its feeds when they change, and every half hour (#205, #211).
- A feed can be marked private, which keeps it out of the directory. A feed with a login, or whose address carries a key, is private already (#205).
- ipx serves no page: the page is ipx-web's, which passes the API on to ipx, and ipx is built without node (#203).
- `docker-compose.yml` is a sample to start from, ipx alone on SQLite with its folders beside the file, instead of a copy of the author's server (#208).
- The source is a Cargo workspace: the daemon in `crates/ipx`, feed fetching and parsing in `crates/feed`. `cargo build --release` still makes `target/release/ipx` (#202).
## [0.12.0] - 2026-10-11 ## [0.12.0] - 2026-10-11
### Added ### Added
- With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. The iPX dashboard graphs them in a Daemon row (#194, #195, #198, #199). - With `[web] metrics_bind` set, ipx serves Prometheus metrics on a port and thread of their own: the database pool, the worker's job and queue, jobs done and their time, scans, downloads and requests in progress, download speed, live-update listeners and missed updates, DeadAir's backlog, the artwork cache's fill, hit rate and evictions, memory, open files and the library's counts. (#194, #195, #198, #199).
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192). - The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186). - Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183). - Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
@@ -151,7 +202,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method, - `IPX_LOG_FORMAT=json` logs one JSON object a line, for Loki and the like: a request's method,
route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type` route, status and `duration_ms`, a scan's or download's details, a failure's kind in `error.type`
(and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`. (and its HTTP status in `http.response.status_code`), and each line's `trace_id` and `span_id`.
- A Grafana dashboard for iPX, from its log in Loki and its traces in Tempo (`grafana/dashboard.py`).
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web - With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its requests to a collector such as Tempo; a scan's shows the time each feed spends fetching, on its
artwork and in the database. artwork and in the database.
@@ -782,7 +832,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout. - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...main [unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...main
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
[0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0 [0.12.0]: https://git.sdf1.net/rays/ipx/compare/v0.11.0...v0.12.0
[0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0 [0.11.0]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...v0.11.0
[0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3 [0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3

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

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

View File

@@ -137,7 +137,7 @@ mod tests {
const AUD: &str = "aud-tag"; const AUD: &str = "aud-tag";
fn jwks() -> JwkSet { fn jwks() -> JwkSet {
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap() serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
} }
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String { fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
@@ -155,8 +155,8 @@ mod tests {
}) })
} }
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der"); const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der"); const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
async fn check(keys: &Keys, t: &str) -> Option<String> { async fn check(keys: &Keys, t: &str) -> Option<String> {
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await

View File

@@ -42,6 +42,14 @@ pub struct General {
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from /// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
/// every publisher; the least recently shown goes first. 0 keeps none. /// every publisher; the least recently shown goes first. 0 keeps none.
pub art_cache_mb: u64, pub art_cache_mb: u64,
/// The directory service the Directory shows (#205), as `https://directory.ipxreader.app`. Empty
/// for none: then the Directory says so, and feeds are added by their address.
pub directory_url: String,
/// The token the directory gave this server. Never sent to the page: it says only whether
/// one is set.
pub directory_token: String,
/// Send the directory the public feeds people here subscribe to, and how many of them do.
pub directory_share: bool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -174,6 +182,10 @@ pub struct Feed {
/// in the catalogue when the last one leaves, where anyone else's feed is dropped. /// in the catalogue when the last one leaves, where anyone else's feed is dropped.
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub listed: bool, pub listed: bool,
/// Never sent to the directory (#205), whatever its address looks like: someone's own
/// access to a paid show that `feed::url_looks_private` does not recognise.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub private: bool,
} }
fn yes() -> bool { fn yes() -> bool {
@@ -192,6 +204,9 @@ impl Default for General {
max_new_per_check: 3, max_new_per_check: 3,
media_types: vec!["audio".into(), "video".into()], media_types: vec!["audio".into(), "video".into()],
art_cache_mb: 500, art_cache_mb: 500,
directory_url: String::new(),
directory_token: String::new(),
directory_share: false,
} }
} }
} }
@@ -273,6 +288,11 @@ impl Feed {
} }
self.password.clone() self.password.clone()
} }
/// What `feed::fetch` sends the feed's host: a username, and its password if there is one.
pub fn auth(&self) -> Option<(&str, Option<String>)> {
self.username.as_deref().map(|u| (u, self.password()))
}
} }
impl Config { impl Config {
@@ -305,6 +325,12 @@ pub struct Stored {
/// Settings saved before it existed have none: they get the default. /// Settings saved before it existed have none: they get the default.
#[serde(default = "art_cache_mb")] #[serde(default = "art_cache_mb")]
pub art_cache_mb: u64, pub art_cache_mb: u64,
#[serde(default)]
pub directory_url: String,
#[serde(default)]
pub directory_token: String,
#[serde(default)]
pub directory_share: bool,
} }
fn art_cache_mb() -> u64 { fn art_cache_mb() -> u64 {
@@ -312,8 +338,17 @@ fn art_cache_mb() -> u64 {
} }
/// `[general]` keys that live in the database once it holds the configuration. /// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 6] = const STORED_KEYS: [&str; 9] = [
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"]; "schedule",
"max_total_gb",
"max_age_days",
"max_new_per_check",
"media_types",
"art_cache_mb",
"directory_url",
"directory_token",
"directory_share",
];
impl Stored { impl Stored {
pub fn of(cfg: &Config) -> Self { pub fn of(cfg: &Config) -> Self {
@@ -325,6 +360,9 @@ impl Stored {
max_new_per_check: g.max_new_per_check, max_new_per_check: g.max_new_per_check,
media_types: g.media_types.clone(), media_types: g.media_types.clone(),
art_cache_mb: g.art_cache_mb, art_cache_mb: g.art_cache_mb,
directory_url: g.directory_url.clone(),
directory_token: g.directory_token.clone(),
directory_share: g.directory_share,
} }
} }
@@ -336,6 +374,9 @@ impl Stored {
g.max_new_per_check = self.max_new_per_check; g.max_new_per_check = self.max_new_per_check;
g.media_types = self.media_types; g.media_types = self.media_types;
g.art_cache_mb = self.art_cache_mb; g.art_cache_mb = self.art_cache_mb;
g.directory_url = self.directory_url;
g.directory_token = self.directory_token;
g.directory_share = self.directory_share;
} }
} }
@@ -612,7 +653,7 @@ mod tests {
taken.insert("the-daily".to_string(), Feed { taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None, auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None, listed: false, password: None, password_env: None, category: None, listed: false, private: false,
}); });
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2"); assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
} }
@@ -634,6 +675,7 @@ mod tests {
password_env: None, password_env: None,
category: None, category: None,
listed: false, listed: false,
private: false,
}; };
assert_eq!(f.password().as_deref(), Some("literal")); assert_eq!(f.password().as_deref(), Some("literal"));

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

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

View File

@@ -438,7 +438,7 @@ mod tests {
let mut f = crate::config::Feed { let mut f = crate::config::Feed {
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None, url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true, schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false, private: false,
}; };
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show"); assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");

View File

@@ -36,7 +36,15 @@ pub enum Event {
DeadairDone { feed: String, enclosure: i64, saved_s: i64 }, DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
Reaped { path: String, bytes: u64 }, Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here. /// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize }, ScanDone {
feeds: usize,
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
#[serde(default)]
only: Option<String>,
#[serde(default)]
forced: bool,
},
ReapDone { files: usize, bytes: u64 }, ReapDone { files: usize, bytes: u64 },
Status { feeds: usize, pending: i64, downloaded: i64 }, Status { feeds: usize, pending: i64, downloaded: i64 },
Error { msg: String }, Error { msg: String },
@@ -82,7 +90,7 @@ impl Event {
Event::FeedStart { feed } => format!("{feed}: checking"), Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"), Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"), Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
}) })
} }
} }
@@ -136,6 +144,7 @@ impl Emitter {
} }
pub fn emit(&self, e: Event) { pub fn emit(&self, e: Event) {
let e = e.redacted();
// Also log it. Scans and downloads travel as events, not tracing calls, so // Also log it. Scans and downloads travel as events, not tracing calls, so
// without this the log view shows only startup and HTTP lines and none of the // without this the log view shows only startup and HTTP lines and none of the
// work the daemon is actually doing. Progress goes to debug: it fires on every // work the daemon is actually doing. Progress goes to debug: it fires on every
@@ -159,6 +168,29 @@ impl Emitter {
} }
} }
impl Event {
/// The event as it may go out: to the log, to every open page whoever's feed it is, to a
/// terminal. Addresses lose their query and password, where a private feed keeps its key, and
/// so do the ones in an error's words (#238).
fn redacted(mut self) -> Self {
use crate::feed::{redact, redact_text};
match &mut self {
Event::DownloadError { url, msg, .. } => {
*url = redact(url);
*msg = redact_text(msg);
}
Event::TorrentDeferred { url, .. } => *url = redact(url),
Event::FeedMoved { from, to, .. } => {
*from = redact(from);
*to = redact(to);
}
Event::FeedError { msg, .. } | Event::Error { msg } => *msg = redact_text(msg),
_ => {}
}
self
}
}
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as /// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and, /// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex. /// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
@@ -198,7 +230,7 @@ fn log_event(e: &Event, wire: bool) {
) )
}; };
} }
// The healthcheck's answer, every 30s: a reply on the socket rather than work done, so it // `ipx status`'s answer: a reply on the socket rather than work done, so it
// stays with the rest of the conversation, and the Scans tab stays about scans. // stays with the rest of the conversation, and the Scans tab stays about scans.
if matches!(e, Event::Status { .. }) { if matches!(e, Event::Status { .. }) {
return line!(info, "ipx::io", format!("<- {json}")); return line!(info, "ipx::io", format!("<- {json}"));
@@ -378,6 +410,30 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed"); assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
} }
#[test]
fn what_goes_out_names_no_private_feeds_key() {
let (tx, mut rx) = broadcast::channel(8);
let out = Emitter::socket(tx, false);
let key = "auth=SECRET";
out.emit(Event::FeedError {
feed: "f".into(),
msg: format!("connecting: error sending request for url (https://x/rss?{key}&show=1): operation timed out"),
});
out.emit(Event::DownloadError {
feed: "f".into(),
enclosure: 1,
url: format!("https://cdn/ep.mp3?{key}"),
msg: format!("connecting: error sending request for url (https://cdn/ep.mp3?{key})"),
});
out.emit(Event::FeedMoved { feed: "f".into(), from: format!("https://x/rss?{key}"), to: format!("https://y/rss?{key}") });
// What every open page is sent, whoever's feed it is (#238).
for _ in 0..3 {
let sent = serde_json::to_string(&rx.try_recv().unwrap()).unwrap();
assert!(!sent.contains("SECRET"), "{sent}");
assert!(sent.contains("https://"), "the address is still there, without its key: {sent}");
}
}
#[test] #[test]
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() { fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() }; let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
@@ -434,7 +490,7 @@ mod tests {
#[test] #[test]
fn only_completion_events_end_a_client_session() { fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1 }.is_terminal()); assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal()); assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
assert!(!Event::FeedDone { assert!(!Event::FeedDone {
feed: "a".into(), feed: "a".into(),

View File

@@ -4,9 +4,11 @@ mod auth;
mod config; mod config;
mod db; mod db;
mod deadair; mod deadair;
mod directory;
mod entity; mod entity;
mod download; mod download;
mod feed; // The crate root's import, so `crate::feed` reaches it from every module as before.
use ipx_feed as feed;
mod ipc; mod ipc;
mod logbuf; mod logbuf;
mod metrics; mod metrics;
@@ -160,6 +162,13 @@ pub struct Ctx {
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since /// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
/// starting. /// starting.
pub directory_cleaned: std::sync::atomic::AtomicI64, pub directory_cleaned: std::sync::atomic::AtomicI64,
/// Sends the snapshot to the directory now, as when its settings change (#205).
pub directory_wake: tokio::sync::Notify,
/// When the snapshot last went and what came of it, for the admin page.
pub directory_shared: std::sync::Mutex<Option<directory::Shared>>,
/// Send the snapshot even if it has not changed: after an unblock or a dropped snapshot the
/// directory needs it again, though nothing here differs (#206).
pub directory_resend: std::sync::atomic::AtomicBool,
} }
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it. /// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
@@ -229,6 +238,9 @@ impl Ctx {
fn set_cfg(&self, cfg: config::Config) { fn set_cfg(&self, cfg: config::Config) {
*self.cfg.write().unwrap() = std::sync::Arc::new(cfg); *self.cfg.write().unwrap() = std::sync::Arc::new(cfg);
tracing::info!("config reloaded"); tracing::info!("config reloaded");
// A feed added, removed, made private or given a category, or the directory's own
// settings changed: the directory hears of it now, not at the next half hour (#205).
self.directory_wake.notify_one();
} }
async fn torrents(&self) -> Result<&torrent::Torrents> { async fn torrents(&self) -> Result<&torrent::Torrents> {
@@ -242,10 +254,10 @@ impl Ctx {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// The daemon only: `ipx status` runs every half minute as the healthcheck, and a trace // The daemon only: a trace for every `ipx status` a script runs would bury the ones worth
// for each would bury the ones worth looking at. // looking at.
let otel = match cli.command { let otel = match cli.command {
Command::Daemon { .. } => otel_provider()?, Command::Daemon { .. } => ipx_directory::otel::provider("ipx")?,
_ => None, _ => None,
}; };
// Everything goes to stderr as before, and is mirrored into a ring the UI can read. // Everything goes to stderr as before, and is mirrored into a ring the UI can read.
@@ -287,7 +299,7 @@ async fn main() -> Result<()> {
use opentelemetry::trace::TracerProvider; use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer() tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx")) .with_tracer(p.tracer("ipx"))
.with_filter(tracing_subscriber::EnvFilter::new("ipx=info")) .with_filter(tracing_subscriber::EnvFilter::new("ipx=info,ipx_feed=info"))
})) }))
.init(); .init();
} }
@@ -343,6 +355,7 @@ async fn main() -> Result<()> {
feed_client: reqwest::Client::builder() feed_client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(feed::FEED_SILENCE)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.build()?, .build()?,
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() }, out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
@@ -354,6 +367,9 @@ async fn main() -> Result<()> {
cfg_write: Default::default(), cfg_write: Default::default(),
directory_cleaned: Default::default(), directory_cleaned: Default::default(),
deadair: Default::default(), deadair: Default::default(),
directory_wake: Default::default(),
directory_shared: Default::default(),
directory_resend: Default::default(),
}); });
let result = match cli.command { let result = match cli.command {
@@ -413,23 +429,6 @@ where
} }
} }
/// Traces over OTLP, to Tempo for one, when OTEL_EXPORTER_OTLP_ENDPOINT names a collector
/// (`http://host:4318`: the exporter speaks OTLP over HTTP and adds `/v1/traces`). The exporter
/// reads that and the other `OTEL_` variables itself.
fn otel_provider() -> Result<Option<opentelemetry_sdk::trace::SdkTracerProvider>> {
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default().is_empty() {
return Ok(None);
}
let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?;
let resource = opentelemetry_sdk::Resource::builder().with_service_name("ipx").build();
Ok(Some(
opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build(),
))
}
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps` /// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
/// listing. /// listing.
async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> { async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
@@ -570,7 +569,7 @@ pub(crate) async fn status(ctx: &Ctx) -> Event {
match ctx.db.counts().await { match ctx.db.counts().await {
Ok((pending, downloaded)) => { Ok((pending, downloaded)) => {
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0); let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0);
// Kept for /metrics, which asks the database nothing (#195): the healthcheck asks this every 30 s. // Kept for /metrics, which asks the database nothing (#195); the daemon counts every 30 s.
use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::Ordering::Relaxed;
let st = &metrics::STATS; let st = &metrics::STATS;
st.feeds.store(feeds as i64, Relaxed); st.feeds.store(feeds as i64, Relaxed);
@@ -664,6 +663,22 @@ async fn daemon(
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads // Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
// must not wait behind it. // must not wait behind it.
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone())); tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
tokio::spawn(directory::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).
{
let (ctx, mut stop) = (ctx.clone(), rx_stop.clone());
tokio::spawn(async move {
let mut every = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
tokio::select! {
_ = every.tick() => { status(&ctx).await; }
_ = stop.changed() => break,
}
}
});
}
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop. // Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
async fn until_stopped( async fn until_stopped(
@@ -906,9 +921,10 @@ async fn add_one(
password_env: None, password_env: None,
category: None, category: None,
listed: false, listed: false,
private: false,
}; };
let title = match feed::fetch(&ctx.feed_client, &probe, None, None).await.map(|(got, _)| got) { let title = match feed::fetch(&ctx.feed_client, &probe.url, None, None, None).await.map(|(got, _)| got) {
// An OPML subscription is named from its own <head><title>, not by trying to // An OPML subscription is named from its own <head><title>, not by trying to
// parse it as a feed and falling back to the hostname. // parse it as a feed and falling back to the hostname.
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => { Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
@@ -999,7 +1015,7 @@ pub async fn subscribe_opml(
user_id: i64, user_id: i64,
) -> Result<(usize, usize)> { ) -> Result<(usize, usize)> {
let mut found = vec![]; let mut found = vec![];
collect_outlines(&doc.body.outlines, &mut found); feed::collect_outlines(&doc.body.outlines, &mut found);
let mut cfg = ctx.edit_cfg().await; let mut cfg = ctx.edit_cfg().await;
let known = subscriptions(ctx).await?; let known = subscriptions(ctx).await?;
@@ -1033,6 +1049,7 @@ pub async fn subscribe_opml(
password_env: None, password_env: None,
category: None, category: None,
listed: false, listed: false,
private: false,
}, },
); );
grew = true; grew = true;
@@ -1057,17 +1074,6 @@ pub async fn subscribe_opml(
Ok((added, had)) Ok((added, had))
} }
/// OPML nests feeds inside folder outlines, so this walks the whole tree.
pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, String)>) {
for o in outlines {
if let Some(url) = &o.xml_url {
let title = o.title.clone().unwrap_or_else(|| o.text.clone());
out.push((title, url.clone()));
}
collect_outlines(&o.outlines, out);
}
}
/// The configuration ipx runs with: config.toml for where things are and who may sign in, the /// The configuration ipx runs with: config.toml for where things are and who may sign in, the
/// database for the feeds and the server settings (issue #18). The first time a database holds /// database for the feeds and the server settings (issue #18). The first time a database holds
/// neither, it takes them from config.toml, which is then cut down to the rest, the original kept /// neither, it takes them from config.toml, which is then cut down to the rest, the original kept
@@ -1255,18 +1261,33 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
const AHEAD: usize = 6; const AHEAD: usize = 6;
let mut bodies = futures_util::StreamExt::buffered( let mut bodies = futures_util::StreamExt::buffered(
futures_util::stream::iter(due.iter().map(|(sub, state)| { futures_util::stream::iter(due.iter().map(|(sub, state)| {
let (client, feed_cfg) = (ctx.feed_client.clone(), sub.cfg.clone()); let (client, web, feed_cfg) = (ctx.feed_client.clone(), ctx.client.clone(), sub.cfg.clone());
let (etag, modified) = (state.etag.clone(), state.last_modified.clone()); let (etag, modified) = (state.etag.clone(), state.last_modified.clone());
let early = !feed::is_patreon_creator(&feed_cfg.url); let early = !feed::is_patreon_creator(&feed_cfg.url);
// Named for its feed: the fetch's own span has only the address, without its query
// since #225, which for every YouTube channel is the same (#229).
let span = tracing::info_span!("fetch_ahead", feed = %sub.id);
tokio::spawn(tracing::Instrument::instrument( tokio::spawn(tracing::Instrument::instrument(
async move { async move {
if !early { if !early {
return None; return None;
} }
let _fetching = metrics::busy(&metrics::STATS.feeds_fetching); let _fetching = metrics::busy(&metrics::STATS.feeds_fetching);
Some(feed::fetch(&client, &feed_cfg, etag.as_deref(), modified.as_deref()).await) let got = feed::fetch(&client, &feed_cfg.url, feed_cfg.auth(), etag.as_deref(), modified.as_deref()).await;
// A forced scan looks for the site's icon of each feed naming no artwork: here,
// a few feeds at a time, not each in its turn, 22 s of a 62 s scan (#228).
let icon = match &got {
Ok((feed::Fetched::Body { bytes, .. }, _)) if force => match feed::parse(bytes) {
Ok(feed::ParsedFeed { image: None, site: Some(site), .. }) => {
Some(feed::site_icon(&web, &site).await.unwrap_or_default())
}
_ => None,
}, },
tracing::Span::current(), _ => None,
};
Some((got, icon))
},
span,
)) ))
})), })),
AHEAD, AHEAD,
@@ -1340,7 +1361,9 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
} }
} }
} }
ctx.out.emit(Event::ScanDone { feeds: scanned }); ctx.out.emit(Event::ScanDone { feeds: scanned, only: only.map(str::to_owned), forced: force });
// What people subscribe to may have changed, and new feeds have titles now: share it.
ctx.directory_wake.notify_one();
Ok(()) Ok(())
} }
@@ -1407,6 +1430,7 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
password_env: parent.and_then(|p| p.password_env.clone()), password_env: parent.and_then(|p| p.password_env.clone()),
category: None, category: None,
listed: false, listed: false,
private: false,
}, },
managed: true, managed: true,
}); });
@@ -1620,9 +1644,14 @@ async fn scan_one(
feed_cfg: &config::Feed, feed_cfg: &config::Feed,
state: &db::HttpState, state: &db::HttpState,
force: bool, force: bool,
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did. // The body the scan already fetched for it, if it did (#104), and where it moved, if it did;
prefetched: Option<Result<(feed::Fetched, Option<String>)>>, // with the site's icon when a forced scan looked it up meanwhile, "" for none (#228).
prefetched: Option<(Result<(feed::Fetched, Option<String>)>, Option<String>)>,
) -> Result<Outcome> { ) -> Result<Outcome> {
let (prefetched, icon) = match prefetched {
Some((got, icon)) => (Some(got), icon),
None => (None, None),
};
// The queue follows the feed's settings each time it is due, changed or not, so files a // The queue follows the feed's settings each time it is due, changed or not, so files a
// limit or auto-download no longer reaches stop counting as waiting (#113). // limit or auto-download no longer reaches stop counting as waiting (#113).
let policy = policy_for(ctx, id, feed_cfg).await?; let policy = policy_for(ctx, id, feed_cfg).await?;
@@ -1654,7 +1683,7 @@ async fn scan_one(
let (mut fetched, moved) = match prefetched { let (mut fetched, moved) = match prefetched {
Some(got) => got?, Some(got) => got?,
None => feed::fetch(&ctx.feed_client, feed_cfg, state.etag.as_deref(), state.last_modified.as_deref()).await?, None => feed::fetch(&ctx.feed_client, &feed_cfg.url, feed_cfg.auth(), state.etag.as_deref(), state.last_modified.as_deref()).await?,
}; };
if let Some(to) = moved { if let Some(to) = moved {
follow_move(ctx, id, &to).await?; follow_move(ctx, id, &to).await?;
@@ -1669,7 +1698,7 @@ async fn scan_one(
if matches!(fetched, feed::Fetched::NotModified) && (stored.entries == 0 || stored.image.is_none()) { if matches!(fetched, feed::Fetched::NotModified) && (stored.entries == 0 || stored.image.is_none()) {
tracing::info!(feed = id, "not modified, but nothing stored; refetching without the validator"); tracing::info!(feed = id, "not modified, but nothing stored; refetching without the validator");
ctx.db.clear_validators(id).await?; ctx.db.clear_validators(id).await?;
fetched = feed::fetch(&ctx.feed_client, feed_cfg, None, None).await?.0; fetched = feed::fetch(&ctx.feed_client, &feed_cfg.url, feed_cfg.auth(), None, None).await?.0;
} }
let (bytes, etag, last_modified) = match fetched { let (bytes, etag, last_modified) = match fetched {
@@ -1724,13 +1753,16 @@ async fn scan_one(
&& stored.image.as_deref() != Some(art.as_str()) && stored.image.as_deref() != Some(art.as_str())
&& !feed::is_image(&ctx.client, art).await && !feed::is_image(&ctx.client, art).await
{ {
tracing::info!(feed = id, art = %art, "the feed's artwork is not an image; trying its site's icon"); tracing::info!(feed = id, art = %feed::redact(art), "the feed's artwork is not an image; trying its site's icon");
parsed.image = None; parsed.image = None;
} }
if parsed.image.is_none() { if parsed.image.is_none() {
parsed.image = Some(match (&stored.image, &parsed.site) { parsed.image = Some(match (&stored.image, &parsed.site) {
(Some(known), _) if !force => known.clone(), (Some(known), _) if !force => known.clone(),
(_, Some(site)) => feed::site_icon(&ctx.client, site).await.unwrap_or_default(), (_, Some(site)) => match &icon {
Some(found) => found.clone(),
None => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
},
(_, None) => String::new(), (_, None) => String::new(),
}); });
} }
@@ -2131,7 +2163,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
/// Downloads one file and records it. None when it turned out to be a torrent, handed off to run /// Downloads one file and records it. None when it turned out to be a torrent, handed off to run
/// on its own, which records it when it finishes. /// on its own, which records it when it finishes.
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))] #[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = %feed::redact(url)))]
async fn fetch_one( async fn fetch_one(
ctx: &Arc<Ctx>, ctx: &Arc<Ctx>,
feed_id: &str, feed_id: &str,
@@ -2289,7 +2321,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
} }
} }
// Terminal, so a UI waiting on this request stops here. // Terminal, so a UI waiting on this request stops here.
ctx.out.emit(Event::ScanDone { feeds: 1 }); ctx.out.emit(Event::ScanDone { feeds: 1, only: None, forced: false });
Ok(()) Ok(())
} }
@@ -2436,6 +2468,7 @@ mod tests {
password_env: None, password_env: None,
category: None, category: None,
listed: false, listed: false,
private: false,
} }
} }
@@ -2509,6 +2542,9 @@ mod tests {
retry: Default::default(), retry: Default::default(),
cfg_write: Default::default(), cfg_write: Default::default(),
deadair: Default::default(), deadair: Default::default(),
directory_wake: Default::default(),
directory_shared: Default::default(),
directory_resend: Default::default(),
directory_cleaned: Default::default(), directory_cleaned: Default::default(),
} }
} }
@@ -2647,6 +2683,58 @@ mod tests {
assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none()); assert!(deadair::plan_for(&ctx, u, "f", 7).await.unwrap().is_none());
} }
#[tokio::test]
async fn two_people_play_one_file_each_in_their_own_version_and_place() {
let ctx = test_ctx(config::Config::default()).await;
let db = &ctx.db;
for sql in [
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 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)",
] {
db.sql(sql).await.unwrap();
}
db.store_plans(7, r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []},
"500": {"cuts": [[60000,100000]], "pieces": []}}"#).await.unwrap();
// One file on disk, three listeners at once: DeadAir at the usual pause, at a longer
// one, and the original.
let (usual, long, plain) = (
db.create_user("usual", None, false).await.unwrap(),
db.create_user("long", None, false).await.unwrap(),
db.create_user("plain", None, false).await.unwrap(),
);
for u in [usual, long, plain] {
db.subscribe(u, "f").await.unwrap();
}
for u in [usual, long] {
db.set_deadair(u, "f", Some(true)).await.unwrap();
}
db.set_deadair_pause(long, "f", 500).await.unwrap();
// Each is handed their own playlist, as /media/7/deadair.m3u8 does.
let playing = deadair::Playing::default();
for u in [usual, long] {
let plan = deadair::plan_for(&ctx, u, "f", 7).await.unwrap().unwrap();
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(plan));
}
assert!(deadair::plan_for(&ctx, plain, "f", 7).await.unwrap().is_none());
// All three save 3:20 of what they hear, as the position route does: each is read in
// that person's own clock and kept as a place in the original, theirs alone.
let mut kept = vec![];
for u in [usual, long, plain] {
let (secs, len) = deadair::on_save(&ctx, &playing, u, "f", "g", 200, None).await.unwrap();
db.set_position(u, "f", "g", secs, len).await.unwrap();
kept.push(secs);
}
assert_eq!(kept, [320, 240, 200], "two minutes cut before 3:20, forty seconds, none");
// Each comes back to 3:20 of their own version, whatever the others saved.
let order = db::order_sql("published", "desc");
for (u, len) in [(usual, 480), (long, 560), (plain, 600)] {
let mut rows = db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
deadair::for_reader(&ctx, u, &mut rows).await.unwrap();
assert_eq!((rows[0].position, rows[0].duration), (200, Some(len)), "user {u}");
}
}
#[tokio::test] #[tokio::test]
async fn two_changes_to_the_catalogue_at_once_both_keep() { async fn two_changes_to_the_catalogue_at_once_both_keep() {
let ctx = Arc::new(test_ctx(config::Config::default()).await); let ctx = Arc::new(test_ctx(config::Config::default()).await);
@@ -2746,6 +2834,18 @@ mod tests {
assert_eq!(text().await, ["As it should read."]); assert_eq!(text().await, ["As it should read."]);
} }
#[tokio::test]
async fn a_forced_scan_takes_the_icon_looked_up_ahead() {
let ctx = Arc::new(test_ctx(config::Config::default()).await);
// No artwork, and a site nothing answers at: only the icon found ahead can be stored (#228).
let body = b"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><link>http://127.0.0.1:9/</link><item><title>One</title><guid>g1</guid></item></channel></rss>".to_vec();
let got = feed::Fetched::Body { bytes: body, etag: None, last_modified: None };
let icon = "https://example.com/favicon.ico".to_string();
let cfg = config::Feed { url: "http://127.0.0.1:9/feed".into(), ..feed() };
scan_one(&ctx, "iconic", &cfg, &db::HttpState::default(), true, Some((Ok((got, None)), Some(icon.clone())))).await.unwrap();
assert_eq!(ctx.db.feed_summary("iconic").await.unwrap().image, Some(icon));
}
#[tokio::test] #[tokio::test]
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() { 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; let base = empty_or_feed_server().await;

View File

@@ -36,7 +36,7 @@ pub struct Stats {
/// Artwork served from disk, and fetched from the publisher because it was not there. /// Artwork served from disk, and fetched from the publisher because it was not there.
pub art_hits: AtomicU64, pub art_hits: AtomicU64,
pub art_misses: AtomicU64, pub art_misses: AtomicU64,
/// The library as the healthcheck last counted it, every 30 s; -1 until it has. /// The library as the daemon last counted it, every 30 s; -1 until it has.
pub feeds: AtomicI64, pub feeds: AtomicI64,
pub pending: AtomicI64, pub pending: AtomicI64,
pub downloaded: AtomicI64, pub downloaded: AtomicI64,
@@ -169,13 +169,15 @@ pub fn render(s: &Sources) -> String {
gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]); gauge(&mut out, "ipx_runtime_workers", "The async runtime's worker threads.", &[("", rt.num_workers() as f64)]);
gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]); gauge(&mut out, "ipx_runtime_tasks", "Tasks alive in the async runtime.", &[("", rt.num_alive_tasks() as f64)]);
gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]); gauge(&mut out, "ipx_runtime_queue", "Tasks waiting in the runtime's global queue.", &[("", rt.global_queue_depth() as f64)]);
let (rss, fds) = process(); let (rss, fds) = ipx_directory::metrics::process();
if let Some(v) = rss { if let Some(v) = rss {
gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]); gauge(&mut out, "ipx_process_resident_bytes", "Memory the process holds.", &[("", v)]);
} }
if let Some(v) = fds { if let Some(v) = fds {
gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]); gauge(&mut out, "ipx_process_open_fds", "Files and sockets the process has open.", &[("", v)]);
} }
counter(&mut out, "ipx_feed_fetches_total", "Feeds fetched, answered 304 included.", &[("", ipx_feed::FETCHES.load(Relaxed) as f64)]);
counter(&mut out, "ipx_feed_bytes_total", "Bytes of feed bodies fetched.", &[("", ipx_feed::FETCHED_BYTES.load(Relaxed) as f64)]);
gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]); gauge(&mut out, "ipx_uptime_seconds", "Seconds since the daemon started.", &[("", s.started.elapsed().as_secs() as f64)]);
let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION")); let version = format!("version=\"{}\"", env!("CARGO_PKG_VERSION"));
gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]); gauge(&mut out, "ipx_build_info", "The version running, as a label.", &[(&version, 1.0)]);
@@ -218,9 +220,9 @@ fn work(out: &mut String, st: &Stats, event_queue: usize, retries: usize, art_li
counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.", counter(out, "ipx_art_requests_total", "Artwork asked for: served from disk, or fetched from the publisher.",
&[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]); &[("result=\"hit\"", st.art_hits.load(Relaxed) as f64), ("result=\"miss\"", st.art_misses.load(Relaxed) as f64)]);
for (name, help, v) in [ for (name, help, v) in [
("ipx_feeds", "Feeds in the catalogue, as the healthcheck last counted.", &st.feeds), ("ipx_feeds", "Feeds in the catalogue, as last counted.", &st.feeds),
("ipx_downloads_pending", "Files a scan will download on its own, as the healthcheck last counted.", &st.pending), ("ipx_downloads_pending", "Files a scan will download on its own, as last counted.", &st.pending),
("ipx_files_downloaded", "Files on disk, as the healthcheck last counted.", &st.downloaded), ("ipx_files_downloaded", "Files on disk, as last counted.", &st.downloaded),
] { ] {
let v = v.load(Relaxed); let v = v.load(Relaxed);
if v >= 0 { if v >= 0 {
@@ -248,16 +250,6 @@ fn write(out: &mut String, kind: &str, name: &str, help: &str, samples: &[(&str,
} }
} }
/// The process's resident memory in bytes and its open files, from /proc; None elsewhere.
pub fn process() -> (Option<f64>, Option<f64>) {
let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
let kb = s.lines().find_map(|l| l.strip_prefix("VmRSS:"))?.trim().strip_suffix("kB")?.trim().parse::<f64>().ok()?;
Some(kb * 1024.0)
});
let fds = std::fs::read_dir("/proc/self/fd").ok().map(|d| d.count() as f64);
(rss, fds)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -293,13 +285,13 @@ mod tests {
assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}"); assert!(out.contains("ipx_worker_busy 0\n") && !out.contains("ipx_worker_job_seconds"), "{out}");
assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}"); assert!(out.contains("ipx_jobs_total{kind=\"scan\"} 1\n") && out.contains("# TYPE ipx_jobs_total counter"), "{out}");
assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n")); assert!(out.contains("ipx_event_queue 3\n") && out.contains("ipx_feed_retries 1\n"));
// The library's counts are left out until the healthcheck has counted it. // The library's counts are left out until the daemon has counted it.
assert!(!out.contains("ipx_feeds ")); assert!(!out.contains("ipx_feeds "));
} }
#[test] #[test]
fn the_process_reads_its_memory_and_files_on_linux() { fn the_process_reads_its_memory_and_files_on_linux() {
let (rss, fds) = process(); let (rss, fds) = ipx_directory::metrics::process();
assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0); assert!(rss.unwrap() > 0.0 && fds.unwrap() > 0.0);
} }
} }

View File

@@ -8,7 +8,7 @@ use axum::{
http::{StatusCode, header}, http::{StatusCode, header},
middleware::{self, Next}, middleware::{self, Next},
response::{ response::{
Html, IntoResponse, Redirect, Response, IntoResponse, Redirect, Response,
sse::{Event as SseEvent, Sse}, sse::{Event as SseEvent, Sse},
}, },
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
@@ -39,7 +39,6 @@ pub struct WebState {
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
Router::new() Router::new()
.route("/", get(index))
.route("/api/me", get(me).patch(patch_me)) .route("/api/me", get(me).patch(patch_me))
.route("/api/tokens", get(list_tokens).post(add_token)) .route("/api/tokens", get(list_tokens).post(add_token))
.route("/api/tokens/{id}", axum::routing::delete(remove_token)) .route("/api/tokens/{id}", axum::routing::delete(remove_token))
@@ -59,41 +58,32 @@ pub fn router(state: WebState) -> Router {
.route("/api/fetch", post(fetch_now)) .route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml)) .route("/api/opml", get(export_opml).post(import_opml))
.route("/api/settings", get(get_settings).patch(patch_settings)) .route("/api/settings", get(get_settings).patch(patch_settings))
.route("/api/directory-status", get(get_directory_status))
.route("/api/status", get(status)) .route("/api/status", get(status))
.route("/api/popular", get(get_popular)) .route("/api/popular", get(get_popular))
.route("/api/directory", get(get_directory)) .route("/api/directory", get(get_directory))
.route("/api/directory/{id}", get(get_listed)) .route("/api/directory/{id}", get(get_listed).delete(remove_listed))
.route("/api/directory/{id}/refresh", post(refresh_listed))
.route("/api/directory-admin", get(get_directory_admin))
.route("/api/directory-blocked/{id}", delete(unblock_listed))
.route("/api/directory-instances/{name}", delete(drop_instance))
.route("/api/popular/{id}", post(subscribe_popular)) .route("/api/popular/{id}", post(subscribe_popular))
.route("/api/users", get(list_users).post(add_user)) .route("/api/users", get(list_users).post(add_user))
.route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user))
.route("/api/logs", get(logs)) .route("/api/logs", get(logs))
.route("/api/client", post(client_event)) .route("/api/client", post(client_event))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media)) .route("/media/{id}", get(media))
.route("/media/{id}/deadair.m3u8", get(media_deadair)) .route("/media/{id}/deadair.m3u8", get(media_deadair))
.route("/media/{id}/deadair.mp3", get(media_piece)) .route("/media/{id}/deadair.mp3", get(media_piece))
.route("/media/{id}/deadair", get(media_save)) .route("/media/{id}/deadair", get(media_save))
.route("/api/art", get(art)) .route("/api/art", get(art))
.route("/api/page-check", get(page_check))
.route("/api/signin", get(signin))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn_with_state(state.clone(), auth))
// Signing in cannot require being signed in, so these sit outside the auth layer. // Signing in cannot require being signed in, so these sit outside the auth layer.
.route("/login", get(login_page))
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/api/login-check", get(login_check))
.route("/logo.svg", get(logo))
.route("/logo-dark.svg", get(logo_dark))
.route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon))
.route("/favicon-dark.png", get(favicon_dark))
.route("/apple-touch-icon.png", get(touch_icon))
// iOS asks for this one first when the site is added to a home screen (#109).
.route("/apple-touch-icon-precomposed.png", get(touch_icon))
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
.route("/hls.js", get(hls_js))
.route("/inter.woff2", get(inter))
.route_layer(middleware::from_fn(name_span)) .route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log)) .layer(middleware::from_fn(access_log))
.with_state(state) .with_state(state)
@@ -383,9 +373,15 @@ async fn me(
let sign_out = (by_proxy && !url.is_empty()).then_some(url); let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default(); let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000; let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000;
// The theme the account kept from before themes were each browser's: theme.ts takes it, once,
// into a browser that has none of its own yet.
let theme = match state.ctx.db.theme(user.id).await.unwrap_or_default() {
(Some(t), Some(m)) if theme_ok(&t, &m) => Some(serde_json::json!({ "name": t, "mode": m })),
_ => None,
};
Json(serde_json::json!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked, "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"deadair_saved": saved, "deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"),
})) }))
} }
@@ -582,142 +578,41 @@ async fn remove_user(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// The password form, except for someone the proxy vouches for: they are signed in already, and /// What Caddy asks before it serves the app or the admin page (#203): a signed-in person may have
/// the form only made it look as if they were not. /// the app, an admin the admin page; anyone else is sent where they belong. The auth layer has
async fn login_page(State(state): State<WebState>, req: Request) -> Response { /// already sent a browser that is not signed in to /login. The path is Caddy's
/// `X-Forwarded-Uri`; the page itself is Caddy's to serve.
async fn page_check(user: crate::db::User, headers: HeaderMap) -> Response {
let uri = headers.get("x-forwarded-uri").and_then(|v| v.to_str().ok()).unwrap_or("/");
match page_redirect(uri, user.is_admin) {
// The page sends its visitor back to the app; its script is refused outright.
Some(_) if uri.starts_with("/admin.js") => (StatusCode::FORBIDDEN, "only an admin").into_response(),
Some(to) => Redirect::to(to).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
}
}
/// The shared token in the address, `/?token=`, as Caddy passes it on (#203): the auth layer has
/// signed the browser in and set its cookie, and it goes on to the app without the token in view.
async fn signin(_user: crate::db::User) -> Redirect {
Redirect::to("/")
}
/// Where someone asking for `uri` goes instead, if not there: the admin page and its script are
/// sent to admins only, not just hidden from everyone else.
fn page_redirect(uri: &str, admin: bool) -> Option<&'static str> {
let path = uri.split(['?', '#']).next().unwrap_or("");
(!admin && (path == "/admin" || path.starts_with("/admin.") || path.starts_with("/admin/"))).then_some("/")
}
/// What Caddy asks before it serves the sign-in page: someone the proxy vouches for is signed in
/// already, and goes to the app, since the form only made it look as if they were not.
async fn login_check(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() { if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
return Redirect::to("/").into_response(); Redirect::to("/").into_response()
} else {
StatusCode::NO_CONTENT.into_response()
} }
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
.into_response()
}
/// The pages are checked on every visit, so a browser always has the one naming the current
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
/// changes its name in the page, and the browser fetches it.
const PAGE_CACHE: &str = "no-cache";
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
/// page needs its own before anyone has signed in, and neither holds anything private.
async fn app_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
}
/// The stylesheet the app and admin pages share, named by hash in each as the scripts are.
async fn app_css() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)],
include_str!(concat!(env!("OUT_DIR"), "/app.css")),
)
}
/// The cookie the page keeps its theme in, `<theme>.<mode>`, written by theme.ts.
const THEME_COOKIE: &str = "ipx_theme";
/// The theme this browser chose, from its cookie: per device, so a phone and a desktop signed in
/// as the same person can each have their own (issue #69). A browser without one yet gets the
/// theme the account kept from before, which theme.ts then writes into the cookie.
async fn page_theme(state: &WebState, user: &crate::db::User, headers: &HeaderMap) -> (Option<String>, Option<String>) {
if let Some((t, m)) = headers_cookie(headers, THEME_COOKIE).as_deref().and_then(|v| v.split_once('.')) {
return (Some(t.to_owned()), Some(m.to_owned()));
}
state.ctx.db.theme(user.id).await.unwrap_or_default()
}
/// The admin page and its script go to admins only: not just hidden from everyone else, never
/// sent. Anyone else asking for the page is sent back to the app.
async fn admin_page(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> Response {
if !user.is_admin {
return Redirect::to("/").into_response();
}
let theme = page_theme(&state, &user, &headers).await;
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
}
async fn admin_js(user: crate::db::User) -> Response {
if !user.is_admin {
return (StatusCode::FORBIDDEN, "only an admin").into_response();
}
script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response()
}
async fn login_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
}
/// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181): the page loads
/// it only there, by the name build.mjs gives it.
async fn hls_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/hls.js")))
}
fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
}
/// The logo the pages show, served once rather than inlined into each. The sign-in page shows
/// it, so it sits outside the auth layer with /login.
async fn logo() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../web/logo.svg"),
)
}
/// logo.svg recoloured for a dark page. The pages pick one or the other by their light or dark
/// mode.
async fn logo_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../web/logo-dark.svg"),
)
}
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
async fn icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/ipodderx-icon.png").as_slice(),
)
}
/// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
/// own; behind the auth layer it answered 401, and the tab stayed blank.
async fn favicon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/favicon.png").as_slice(),
)
}
/// web/logo-dark.svg at 128px, the tab's icon while the page is dark.
async fn favicon_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/favicon-dark.png").as_slice(),
)
}
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
/// a transparent icon's background black and cuts its own corners.
async fn touch_icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/apple-touch-icon.png").as_slice(),
)
}
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
/// Open Font License, is web/Inter-LICENSE.txt.
async fn inter() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "max-age=604800")],
include_bytes!("../web/InterVariable.woff2").as_slice(),
)
} }
fn constant_time_eq(a: &str, b: &str) -> bool { fn constant_time_eq(a: &str, b: &str) -> bool {
@@ -730,48 +625,6 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
// Built by build.rs from web/index.html and web/src, and minified: attribute values lose their // Built by build.rs from web/index.html and web/src, and minified: attribute values lose their
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them. // quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
const ADMIN_LINK: &str = "<a id=admin ";
// The logo's tooltip. Filled in here, not by build.mjs, because build.rs does not rerun when only
// Cargo.toml's version changes, and the page would go on naming the last release.
const VERSION_SLOT: &str = "iPX {version}";
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
/// script instead showed it for a moment on every load, until /api/me answered.
async fn index(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> impl IntoResponse {
let theme = page_theme(&state, &user, &headers).await;
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
}
const HTML_TAG: &str = "<html lang=en>";
/// The page for this person: their theme on its <html> tag, and the link to /admin only if they
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
/// showed it for a moment on every load, until /api/me answered (issue #29).
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
let mut page = with_theme(INDEX, theme).replacen(VERSION_SLOT, concat!("iPX ", env!("CARGO_PKG_VERSION")), 1);
if !admin
&& let Some(at) = page.find(ADMIN_LINK)
&& let Some(len) = page[at..].find("</a>")
{
page.replace_range(at..at + len + "</a>".len(), "");
}
page
}
/// A page with the account's theme on its <html> tag, so it is drawn in it from the first frame
/// on any browser.
fn with_theme(page: &str, theme: (Option<String>, Option<String>)) -> String {
let (Some(t), Some(m)) = theme else { return page.to_owned() };
if !theme_ok(&t, &m) {
return page.to_owned();
}
// data-choice is light, dark or auto; data-mode, what the CSS reads, is only known here for
// the first two. theme.ts works Auto out from the system.
let mode = if m == "auto" { String::new() } else { format!(" data-mode={m}") };
page.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1)
}
#[derive(Serialize)] #[derive(Serialize)]
struct FeedRow { struct FeedRow {
id: String, id: String,
@@ -815,6 +668,10 @@ struct FeedRow {
subscribers: i64, subscribers: i64,
/// Pinned to the top of your list. /// Pinned to the top of your list.
pinned: bool, pinned: bool,
/// Marked private: kept out of the directory (#205).
private: bool,
/// Private whatever it is marked, for a login or an address with a key in it.
private_by_address: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -866,6 +723,8 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
// A feed not scanned yet has no row: blank, as feed_summary gave it. // A feed not scanned yet has no row: blank, as feed_summary gave it.
let crate::db::FeedListing { summary: s, ttl_mins, blocked, unread } = listed.remove(id).unwrap_or_default(); let crate::db::FeedListing { summary: s, ttl_mins, blocked, unread } = listed.remove(id).unwrap_or_default();
out.push(FeedRow { out.push(FeedRow {
private: feed.private,
private_by_address: crate::directory::looks_private(&crate::config::Feed { private: false, ..feed.clone() }),
id: id.clone(), id: id.clone(),
url: feed.url.clone(), url: feed.url.clone(),
title: s.title, title: s.title,
@@ -933,135 +792,87 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
Ok(out) Ok(out)
} }
// ---- popular on this server ---- // ---- the directory (#205) ----
/// A feed that carries a credential is someone's paid or private subscription. Listing it /// A feed as the Directory draws it: never a URL, so a key in someone's feed address never
/// would let anyone signed in subscribe to it and read what they pay for. /// reaches the page, even though the directory itself has them.
///
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
/// through; a per-feed `unlisted` flag is the upgrade if that happens again.
fn looks_private(feed: &crate::config::Feed) -> bool {
if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() {
return true;
}
let Ok(u) = url::Url::parse(&feed.url) else { return true };
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
// long path segment alone proves nothing (acast's public show ids look the same). So a
// feed from one of them is private whatever its URL looks like. Supercast is why this
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
let host = u.host_str().unwrap_or("");
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|| !u.username().is_empty()
|| u.password().is_some()
|| u.query_pairs().any(|(k, _)| {
let k = k.to_ascii_lowercase();
["auth", "token", "key", "secret", "pass", "sig", "session", "user", "uid"]
.iter()
.any(|w| k.contains(w))
})
}
/// Services whose feeds are always one subscriber's own.
const PAID_HOSTS: &[&str] =
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
#[derive(Serialize)] #[derive(Serialize)]
struct PopularRow { struct PopularRow {
/// The directory's id for it, which its page and Subscribe go by.
id: String, id: String,
title: Option<String>, title: Option<String>,
image: Option<String>, image: Option<String>,
/// Everyone on every server that shares with the directory.
subscribers: i64, subscribers: i64,
/// Yours already. Everyone counts, you included, so your own feeds are listed too. /// Yours already.
subscribed: bool, subscribed: bool,
/// Apple's category, from the feed's own iTunes category or else the catalogue's, and the /// When it is yours, its id here, which opens it.
/// subcategory within it where there is one: the Directory browses them in that order. feed: Option<String>,
category: Option<String>, category: Option<String>,
subcategory: Option<String>, subcategory: Option<String>,
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
/// Directory's Podcasts and Blogs between them hold everything.
podcast: bool, podcast: bool,
} }
/// Every feed that may be listed, with everyone counted, you included, most subscribers fn dir_error(e: crate::directory::DirError) -> ApiError {
/// first. Popular is the top of it, the directory is all of it, and it is all that use crate::directory::DirError;
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the let status = match &e {
/// feeds inside it and never itself: both lists are for finding a show. DirError::NotConfigured => StatusCode::SERVICE_UNAVAILABLE,
/// The catalogue as others may see it. `everything` is the Directory: every feed, with or DirError::Refused(404, _) => StatusCode::NOT_FOUND,
/// without subscribers, so the ones an admin listed show before anyone takes them. Without it, // This server's token may not do it: the admin asked for something only rwa may (#206).
/// Popular: only what people subscribe to. DirError::Refused(403, _) => StatusCode::FORBIDDEN,
async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec<PopularRow>> { _ => StatusCode::BAD_GATEWAY,
let db = &state.ctx.db; };
// One pass for every feed's title, artwork and category, not three queries a feed. if !matches!(e, DirError::NotConfigured | DirError::Refused(404, _)) {
let mut listed = db.feed_list(user_id, None).await?; tracing::warn!(error = %e, "asking the directory failed");
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts().await?;
let media = db.media_feeds().await?;
let catalogue = crate::subscriptions(&state.ctx).await?;
let ok = listable(&catalogue);
let mut out = vec![];
for s in &catalogue {
let n = counts.get(&s.id).copied().unwrap_or(0);
if (n == 0 && !everything) || !ok.contains(s.id.as_str()) {
continue;
} }
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default(); ApiError { error: anyhow::Error::msg(e.to_string()), status }
let subscribed = mine.contains(&s.id);
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
let path = sum.category.or_else(|| s.cfg.category.clone()).map(|c| crate::feed::category_path(&c));
out.push(PopularRow {
id: s.id.clone(),
title: sum.title,
image: sum.image,
subscribers: n,
subscribed,
category: path.as_ref().map(|p| p.0.clone()),
subcategory: path.and_then(|p| p.1),
podcast: media.contains(&s.id),
});
}
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
Ok(out)
} }
/// The feeds the Directory may list: not an OPML or a Patreon creator itself, which is listed as /// The directory's listings as this person sees them: which are theirs already, matched by
/// the feeds in it, and neither the feed nor the group it is in private. Asked of one feed, without /// address, since the directory and this server name a feed differently.
/// building the whole Directory, by a feed's Directory page and by subscribing from it: building async fn rows_for(state: &WebState, user_id: i64, listed: Vec<ipx_directory::Listing>) -> Result<Vec<PopularRow>> {
/// it was nearly all of the 0.57 s a feed's page took (#160). let mine: std::collections::HashSet<String> =
fn listable(catalogue: &[crate::Sub]) -> std::collections::HashSet<&str> { state.ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
let by_id: std::collections::HashMap<&str, &crate::config::Feed> = let here: std::collections::HashMap<String, String> = crate::subscriptions(&state.ctx)
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect(); .await?
let is_folder: std::collections::HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect(); .into_iter()
catalogue .filter(|s| mine.contains(&s.id))
.iter() .filter_map(|s| ipx_directory::store::normalise(&s.cfg.url).map(|u| (u, s.id)))
// A feed inside an OPML that looks private is as private as the OPML. .collect();
.filter(|s| { Ok(listed
!is_folder.contains(s.id.as_str()) .into_iter()
&& !looks_private(&s.cfg) .map(|l| {
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f)) let feed = ipx_directory::store::normalise(&l.url).and_then(|u| here.get(&u).cloned());
PopularRow {
id: l.id,
title: l.title,
image: l.image,
subscribers: l.subscribers,
subscribed: feed.is_some(),
feed,
category: l.category,
subcategory: l.subcategory,
podcast: l.podcast,
}
}) })
.map(|s| s.id.as_str()) .collect())
.collect()
} }
async fn get_popular( async fn get_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id, false).await?; let listed = crate::directory::popular(&state.ctx).await.map_err(dir_error)?;
rows.truncate(10); Ok(Json(rows_for(&state, user.id, listed).await?))
Ok(Json(rows))
} }
/// Every feed that may be listed, A to Z, with the feeds inside an OPML in place of the OPML. /// Every feed in the directory, A to Z.
async fn get_directory( async fn get_directory(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id, true).await?; let listed = crate::directory::feeds(&state.ctx).await.map_err(dir_error)?;
rows.sort_by_key(sort_name); Ok(Json(rows_for(&state, user.id, listed).await?))
Ok(Json(rows))
} }
/// One of a listed feed's latest items, for its page in the Directory before anyone subscribes /// One of a listed feed's latest items, for its page in the Directory before anyone subscribes
@@ -1080,40 +891,28 @@ struct ListedItem {
struct Listed { struct Listed {
/// What the feed says it is (#130), sanitized as its items are. /// What the feed says it is (#130), sanitized as its items are.
description: Option<String>, description: Option<String>,
/// Why its last check failed, when it did (#142), in the words a feed's own page uses. /// Why its last check failed, when it did (#142), in the directory's words.
failing: Option<String>, failing: Option<String>,
items: Vec<ListedItem>, items: Vec<ListedItem>,
/// An admin here whose token may administer the directory, who sees Remove and Refresh (#206).
can_admin: bool,
} }
/// A feed's last error as its Directory page says it: explained once it has been failing a day, as /// A listed feed's description and latest items, from the directory, sanitized here: they are
/// the feed's own page does, or else that the check failed; never the error as it stands, which can /// the feed's own HTML, which the directory passes on as it was sent.
/// name the feed's address, and the Directory names none. A day, so that a site down for an hour,
/// or a resolver for a minute, is not called gone (#143).
fn failure_words(err: &str, failing_for: i64) -> String {
Some(err)
.filter(|_| failing_for >= FLAG_AFTER_SECS)
.and_then(crate::feed::explain_failure)
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
}
/// A listed feed's description and latest twenty items. Only a feed the Directory lists, so a
/// guessed id reaches nothing private, as `subscribe_popular` checks.
async fn get_listed( async fn get_listed(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<Listed>, ApiError> { ) -> Result<Json<Listed>, ApiError> {
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) { let (page, can_admin) = tokio::join!(crate::directory::page(&state.ctx, &id), async {
return Err(ApiError::not_found(format!("{id:?} is not in the directory"))); user.is_admin && crate::directory::can_admin(&state.ctx).await
} });
let order = crate::db::order_sql("published", "desc"); let page = page.map_err(dir_error)?;
let rows = state.ctx.db.entries_in(user.id, Some(&id), crate::db::Filter::parse("all"), None, 0, 20, &order).await?;
let mut sanitizer = feed_sanitizer(); let mut sanitizer = feed_sanitizer();
let description = state.ctx.db.feed_description(&id).await?.map(|d| clean_description(&mut sanitizer, &d, None)); let description = page.description.as_deref().map(|d| clean_description(&mut sanitizer, d, None));
let summary = state.ctx.db.feed_summary(&id).await?; let items = page
let failing_for = summary.error_since.map_or(0, |since| crate::db::now() - since); .items
let failing = summary.last_error.as_deref().map(|e| failure_words(e, failing_for));
let items = rows
.into_iter() .into_iter()
.map(|e| ListedItem { .map(|e| ListedItem {
description: e.description.as_deref().map(|d| clean_description(&mut sanitizer, d, e.link.as_deref())), description: e.description.as_deref().map(|d| clean_description(&mut sanitizer, d, e.link.as_deref())),
@@ -1124,27 +923,81 @@ async fn get_listed(
duration: e.duration, duration: e.duration,
}) })
.collect(); .collect();
Ok(Json(Listed { description, failing, items })) Ok(Json(Listed { description, failing: page.failing, items, can_admin }))
} }
fn sort_name(p: &PopularRow) -> String { // ---- administering the directory (#206): an admin here, and a token there that may ----
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
fn admin_only(user: &crate::db::User) -> Result<(), ApiError> {
if user.is_admin { Ok(()) } else { Err(ApiError::forbidden("only an admin administers the directory")) }
} }
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so async fn remove_listed(State(state): State<WebState>, user: crate::db::User, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
/// a guessed id cannot reach a private feed. admin_only(&user)?;
crate::directory::remove(&state.ctx, &id).await.map_err(dir_error)?;
tracing::info!(by = %user.name, feed = %id, "removed a feed from the directory");
Ok(StatusCode::NO_CONTENT)
}
async fn refresh_listed(State(state): State<WebState>, user: crate::db::User, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
admin_only(&user)?;
crate::directory::refresh(&state.ctx, &id).await.map_err(dir_error)?;
Ok(StatusCode::NO_CONTENT)
}
/// A blocked feed, by its title: a feed is often blocked because its address holds someone's
/// key, so the address stays in the directory.
#[derive(Serialize)]
struct BlockedRow {
id: String,
title: Option<String>,
at: i64,
}
async fn get_directory_admin(State(state): State<WebState>, user: crate::db::User) -> Result<Json<serde_json::Value>, ApiError> {
admin_only(&user)?;
let (instances, blocked) = tokio::join!(crate::directory::instances(&state.ctx), crate::directory::blocked(&state.ctx));
let blocked: Vec<BlockedRow> = blocked.map_err(dir_error)?.into_iter().map(|b| BlockedRow { id: b.id, title: b.title, at: b.at }).collect();
Ok(Json(serde_json::json!({ "instances": instances.map_err(dir_error)?, "blocked": blocked })))
}
async fn unblock_listed(State(state): State<WebState>, user: crate::db::User, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
admin_only(&user)?;
crate::directory::unblock(&state.ctx, &id).await.map_err(dir_error)?;
// Unblocked, it comes back with a snapshot that has it, and this one has not changed.
crate::directory::resend_now(&state.ctx);
tracing::info!(by = %user.name, feed = %id, "unblocked a feed in the directory");
Ok(StatusCode::NO_CONTENT)
}
async fn drop_instance(State(state): State<WebState>, user: crate::db::User, Path(name): Path<String>) -> Result<StatusCode, ApiError> {
admin_only(&user)?;
crate::directory::drop_instance(&state.ctx, &name).await.map_err(dir_error)?;
// When it was this server's own, its feeds come back now rather than in half an hour.
crate::directory::resend_now(&state.ctx);
tracing::info!(by = %user.name, instance = %name, "dropped a server's snapshot from the directory");
Ok(StatusCode::NO_CONTENT)
}
/// Subscribes by the directory's id, because the page never sees a URL: the address comes from
/// the directory, and goes through the same steps as Add feed.
async fn subscribe_popular( async fn subscribe_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) { let page = crate::directory::page(&state.ctx, &id).await.map_err(|e| match e {
return Err(ApiError::bad_request(format!("{id:?} is not in the directory"))); crate::directory::DirError::Refused(404, _) => ApiError::bad_request(format!("{id:?} is not in the directory")),
e => dir_error(e),
})?;
let (feed_id, existed) = crate::add_to_catalogue(&state.ctx, &page.listing.url, None, vec![]).await?;
let already = existed && state.ctx.db.subscription(user.id, &feed_id).await?.is_some();
state.ctx.db.subscribe(user.id, &feed_id).await?;
if !already {
explicit_on_add(&state, user.id, &feed_id, false).await?;
} }
state.ctx.db.subscribe(user.id, &id).await?; scan_soon(&state, Some(feed_id.clone())).await;
// A listed feed nobody took was checked once a day at most: read it now, not in an hour. Ok(Json(serde_json::json!({ "id": feed_id })))
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id })))
} }
/// Validates a replacement feed URL: present, parseable, and not already subscribed under /// Validates a replacement feed URL: present, parseable, and not already subscribed under
@@ -1218,18 +1071,6 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_failing_feed_says_why_in_the_directory_without_its_address() {
let day = FLAG_AFTER_SECS;
let nothing = "the feed's address answered with nothing at all";
assert_eq!(failure_words(nothing, day), "This address answers with nothing; the site may be gone.");
// Not before it has been failing a day, as on the feed's own page (#143).
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
// reqwest names the URL; the Directory names none.
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
assert_eq!(failure_words(raw, day), "Its last check failed.");
}
#[test] #[test]
fn a_relative_image_resolves_against_the_post() { fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer(); let mut b = feed_sanitizer();
@@ -1243,108 +1084,25 @@ mod tests {
} }
#[test] #[test]
fn only_an_admin_is_sent_the_admin_link() { fn only_an_admin_is_given_the_admin_page_or_its_script() {
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing. for uri in ["/admin", "/admin.js?v=abc", "/admin#log", "/admin.html"] {
assert!(page_for(true, (None, None)).contains(ADMIN_LINK)); assert_eq!(page_redirect(uri, false), Some("/"), "{uri}");
let page = page_for(false, (None, None)); assert_eq!(page_redirect(uri, true), None, "{uri}");
assert!(!page.contains(ADMIN_LINK) && !page.contains("href=/admin"), "the link is gone"); }
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays"); for uri in ["/", "/?feed=x", "/app.js?v=1", "/administer"] {
assert_eq!(page_redirect(uri, false), None, "{uri}");
}
} }
#[test] #[test]
fn the_logo_names_the_version() { fn only_a_plain_theme_name_is_handed_on() {
// If the minifier drifts from VERSION_SLOT, replacen matches nothing and says nothing. assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
let page = page_for(false, (None, None)); // theme.ts writes it into markup, so nothing else gets there.
assert!(!page.contains(VERSION_SLOT) && page.contains(concat!("iPX ", env!("CARGO_PKG_VERSION")))); assert!(!theme_ok("x onload=alert(1)", "dark"));
assert!(!theme_ok("modern", "dark\""));
} }
#[test]
fn the_page_arrives_in_the_theme_the_account_chose() {
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
// If the minifier ever writes the tag differently, HTML_TAG matches nothing, silently.
assert!(page("dracula", "dark").contains("<html lang=en data-theme=dracula data-choice=dark data-mode=dark>"));
assert!(page("nordic", "auto").contains("<html lang=en data-theme=nordic data-choice=auto>"));
// Whatever is in the column is written into markup, so only a plain name gets there.
assert!(!page("x onload=alert(1)", "dark").contains("onload"));
assert!(page("modern", "dark\"").contains("<html lang=en>"));
}
#[test]
fn a_feed_with_a_credential_is_never_popular() {
let f = |url: &str| crate::config::Feed {
url: url.into(),
folder: None,
group: None,
media_types: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
auto_download: true,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
category: None,
listed: false,
};
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
// Patreon's shape: the key is a query parameter.
assert!(looks_private(&f("https://www.patreon.com/rss/x?auth=abc123&show=2073588")));
assert!(looks_private(&f("https://example.com/rss?api_key=abc")));
// Paid-feed services put the key in the path; the host gives them away.
assert!(looks_private(&f("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx")));
assert!(looks_private(&f("https://someshow.supportingcast.fm/content/abc123.rss")));
// A long path segment alone is not a key: acast's public show ids look the same.
assert!(!looks_private(&f("https://feeds.acast.com/public/shows/0123456789abcdef01234567")));
assert!(looks_private(&f("https://ray:hunter2@example.com/rss")));
assert!(looks_private(&f("not a url")), "unparseable is not safe to list");
let mut basic = f("https://example.com/rss");
basic.username = Some("ray".into());
assert!(looks_private(&basic), "a feed with a login configured");
}
#[test]
fn the_directory_lists_feeds_not_their_groups_and_nothing_private() {
let sub = |id: &str, url: &str, group: Option<&str>| crate::Sub {
id: id.into(),
cfg: crate::config::Feed { group: group.map(Into::into), ..feed(url) },
managed: group.is_some(),
};
let catalogue = [
sub("plain", "https://example.com/rss", None),
sub("opml", "https://example.com/list.opml", None),
sub("kid", "https://kid.example/rss", Some("opml")),
sub("paid", "https://www.patreon.com/rss/x?auth=abc", None),
sub("paid-show", "https://www.patreon.com/rss/x?auth=abc&show=1", Some("paid")),
];
let mut got: Vec<&str> = listable(&catalogue).into_iter().collect();
got.sort();
assert_eq!(got, ["kid", "plain"], "the OPML as its feeds; a private feed and its shows never");
}
#[test]
fn a_requests_trace_is_the_pages_from_its_traceparent() {
use opentelemetry::trace::TraceContextExt;
let with = |v: &str| {
let mut h = HeaderMap::new();
h.insert("traceparent", v.parse().unwrap());
remote_parent(&h, None)
};
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(cx.span().span_context().is_remote());
// Anything else from the browser starts a trace of its own.
for bad in ["", "00-xyz-00f067aa0ba902b7-01", "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
assert!(with(bad).is_none(), "{bad}");
}
assert!(remote_parent(&HeaderMap::new(), None).is_none());
// From the query, where a beacon or Safari's player cannot send a header (#192).
let q = remote_parent(&HeaderMap::new(), Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"));
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
assert!(remote_parent(&HeaderMap::new(), Some("traceparent=nonsense")).is_none());
}
#[tokio::test] #[tokio::test]
async fn the_page_reports_only_what_it_is_meant_to() { async fn the_page_reports_only_what_it_is_meant_to() {
@@ -1386,7 +1144,7 @@ mod tests {
crate::config::Feed { crate::config::Feed {
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None, auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None, listed: false, password: None, password_env: None, category: None, listed: false, private: false,
} }
} }
@@ -1645,6 +1403,9 @@ struct FeedPatch {
deadair: Option<Option<bool>>, deadair: Option<Option<bool>>,
/// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182). /// The pause DeadAir leaves where it cuts, in ms: one of `deadair::PAUSES` (#182).
deadair_pause: Option<i64>, deadair_pause: Option<i64>,
/// Kept out of the directory (#205). Anyone subscribed may mark a feed private, since it is
/// most likely their own paid feed; only an admin may unmark it.
private: Option<bool>,
} }
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is /// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
@@ -1724,14 +1485,14 @@ async fn patch_feed(
// The rest describes the feed itself -- where its files land, its address, when it is // The rest describes the feed itself -- where its files land, its address, when it is
// polled -- and there is one of those however many people read it. // polled -- and there is one of those however many people read it.
let feed_level = let admin_only =
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some(); body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some() || body.private == Some(false);
if !feed_level { if !admin_only && body.private.is_none() {
return Ok(StatusCode::NO_CONTENT); return Ok(StatusCode::NO_CONTENT);
} }
if !user.is_admin { if admin_only && !user.is_admin {
return Err(ApiError::forbidden( return Err(ApiError::forbidden(
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them", "the feed's address, folder, schedule and category, and whether it may be shared, are the same for everyone, so only an admin changes them",
)); ));
} }
let mut cfg = state.ctx.edit_cfg().await; let mut cfg = state.ctx.edit_cfg().await;
@@ -1781,6 +1542,9 @@ async fn patch_feed(
if let Some(v) = body.category { if let Some(v) = body.category {
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
} }
if let Some(v) = body.private {
feed.private = v;
}
state.ctx.store_cfg(cfg).await?; state.ctx.store_cfg(cfg).await?;
if url_changed { if url_changed {
// Refreshing a rotated auth token is the common case; entries and download history // Refreshing a rotated auth token is the common case; entries and download history
@@ -2143,7 +1907,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
Err(e) => { Err(e) => {
let why = format!("{e:#}"); let why = format!("{e:#}");
// The access line says only that it failed, not which image or why (#169). // The access line says only that it failed, not which image or why (#169).
tracing::warn!(url = %q.u, error = %why, "could not fetch artwork"); tracing::warn!(url = %crate::feed::redact(&q.u), error = %crate::feed::redact_text(&why), "could not fetch artwork");
crate::art::fail(&q.u, &why); crate::art::fail(&q.u, &why);
(StatusCode::BAD_GATEWAY, why).into_response() (StatusCode::BAD_GATEWAY, why).into_response()
} }
@@ -2335,6 +2099,10 @@ struct Settings {
max_total_gb: f64, max_total_gb: f64,
max_age_days: u64, max_age_days: u64,
art_cache_mb: u64, art_cache_mb: u64,
directory_url: String,
/// Whether a token is set: the token itself never leaves the server.
directory_token_set: bool,
directory_share: bool,
} }
async fn get_settings(State(state): State<WebState>) -> Json<Settings> { async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
@@ -2348,6 +2116,9 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
max_total_gb: cfg.general.max_total_gb, max_total_gb: cfg.general.max_total_gb,
max_age_days: cfg.general.max_age_days, max_age_days: cfg.general.max_age_days,
art_cache_mb: cfg.general.art_cache_mb, art_cache_mb: cfg.general.art_cache_mb,
directory_url: cfg.general.directory_url.clone(),
directory_token_set: !cfg.general.directory_token.trim().is_empty(),
directory_share: cfg.general.directory_share,
}) })
} }
@@ -2359,6 +2130,11 @@ struct SettingsPatch {
max_total_gb: Option<f64>, max_total_gb: Option<f64>,
max_age_days: Option<u64>, max_age_days: Option<u64>,
art_cache_mb: Option<u64>, art_cache_mb: Option<u64>,
/// Empty for no directory.
directory_url: Option<String>,
/// Replaces the token; empty clears it.
directory_token: Option<String>,
directory_share: Option<bool>,
} }
async fn patch_settings( async fn patch_settings(
@@ -2398,10 +2174,43 @@ async fn patch_settings(
if let Some(v) = body.art_cache_mb { if let Some(v) = body.art_cache_mb {
cfg.general.art_cache_mb = v; cfg.general.art_cache_mb = v;
} }
if let Some(v) = body.directory_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://directory.example.net")));
}
cfg.general.directory_url = v;
}
if let Some(v) = body.directory_token {
cfg.general.directory_token = v.trim().to_owned();
}
if let Some(v) = body.directory_share {
cfg.general.directory_share = v;
}
state.ctx.store_cfg(cfg).await?; state.ctx.store_cfg(cfg).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// What the admin page says about the directory: who it takes this server's token to be, or why
/// it would not say, and how the last snapshot went.
async fn get_directory_status(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
if !user.is_admin {
return Err(ApiError::forbidden("only an admin sees the directory's settings"));
}
let shared = state.ctx.directory_shared.lock().unwrap().clone();
let me = crate::directory::me(&state.ctx).await;
Ok(Json(serde_json::json!({
"configured": crate::directory::configured(&state.ctx.cfg()),
"name": me.as_ref().ok().map(|m| m.name.clone()),
"access": me.as_ref().ok().map(|m| m.access),
"error": me.err().map(|e| e.to_string()),
"shared": shared,
})))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct LogQuery { struct LogQuery {
/// Highest seq the client already has; 0 means "give me the tail". /// Highest seq the client already has; 0 means "give me the tail".
@@ -2464,30 +2273,6 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} }
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
/// itself (#192).
fn remote_parent(headers: &HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
let v = match headers.get("traceparent") {
Some(h) => h.to_str().ok()?,
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
};
let mut parts = v.split('-');
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
if version != "00" || trace.len() != 32 || span.len() != 16 {
return None;
}
let sc = SpanContext::new(
TraceId::from_hex(trace).ok()?,
SpanId::from_hex(span).ok()?,
TraceFlags::SAMPLED,
true,
TraceState::default(),
);
sc.is_valid().then(|| opentelemetry::Context::new().with_remote_span_context(sc))
}
/// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing /// 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 /// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its
@@ -2528,7 +2313,7 @@ async fn access_log(req: Request, next: Next) -> Response {
http.response.status_code = tracing::field::Empty, http.response.status_code = tracing::field::Empty,
) )
}; };
if let Some(parent) = remote_parent(req.headers(), req.uri().query()) { if let Some(parent) = ipx_directory::otel::remote_parent(req.headers(), req.uri().query()) {
use tracing_opentelemetry::OpenTelemetrySpanExt; use tracing_opentelemetry::OpenTelemetrySpanExt;
let _ = span.set_parent(parent); let _ = span.set_parent(parent);
} }

View File

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

View File

@@ -1,34 +1,61 @@
# How it works # How it works
One binary, `ipx`. `ipx daemon` runs three things in one process: a scheduler, a Unix-socket Three services, each a container of its own with its own version and changelog (#207):
control server, and the web UI. Everything else is a CLI that either does the work itself or hands
it to a running daemon. | Service | What it is | Source | Image |
|---|---|---|---|
| ipx-web | Caddy: serves the page, a static site built from `web/`, and passes `/api` and `/media` to ipx on the same address. It asks ipx before it serves the app or the admin page (`/api/page-check`) | `web/`, `web/Caddyfile` | `Dockerfile.web` |
| ipx | The daemon and CLI, one binary. `ipx daemon` runs a scheduler, a Unix-socket control server and the HTTP API in one process; everything else is a CLI that either does the work itself or hands it to a running daemon | `crates/ipx` | `Dockerfile` |
| ipx-directory | The directory of public podcasts that ipx servers share. ipx asks it on its people's behalf; the page never talks to it ([directory.md](directory.md)) | `crates/directory` | `Dockerfile.directory` |
The Rust source is a Cargo workspace (#202); `crates/feed` (`ipx-feed`) fetches and parses feeds
for both Rust services. Versions: ipx's in `crates/ipx/Cargo.toml`, the directory's in
`crates/directory/Cargo.toml`, the page's in `package.json`; CLAUDE.md has the release steps.
## Traces and logs
A request is one trace (#213). The page starts one for each tap and sends `traceparent` with its
requests (#190); Caddy's span continues it and passes it on to ipx; ipx's request span, its steps
and its calls to the directory follow, and the directory's request span joins it. Scans,
downloads, DeadAir's work, shares with the directory and the directory's refresher rounds are
traces of their own. All three log one JSON object a line, for Loki; ipx's lines carry their trace
id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web] metrics_bind`,
#194). The exporter and the `traceparent` handling both Rust services use are in
`crates/directory/src/otel.rs`.
## Modules ## Modules
| File | Responsibility | What it replaced in the Python | | File | Responsibility | What it replaced in the Python |
|---|---|---| |---|---|---|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` | | `crates/ipx/src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` | | `crates/ipx/src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` | | `crates/ipx/src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — | | `crates/ipx/src/entity.rs` | The tables, as SeaORM entities: the schema | — |
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` | | `crates/feed/src/lib.rs` | Conditional GET, RSS/Atom/OPML parsing, `url_looks_private` | `FeedData.__getFeed/__getEntries` |
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` | | `crates/ipx/src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 | | `crates/ipx/src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
| `src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` | | `crates/ipx/src/retention.rs` | Quota and age sweeps | `iPXQuotaManager.py` |
| `src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout | | `crates/ipx/src/ipc.rs` | Event and command types, the socket server | `printMSG` on stdout |
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — | | `crates/ipx/src/auth.rs` | Argon2id hashing, session tokens, header names | — |
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — | | `crates/ipx/src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — | | `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
| `crates/directory/src/lib.rs` | The directory's HTTP API, its token check, the refresher | — |
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
| `crates/directory/src/otel.rs` | Traces for both Rust services: the exporter, `traceparent` in and out | — |
| `web/index.html` | The app's markup | — | | `web/index.html` | The app's markup | — |
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — | | `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/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/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — | | `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` | — |
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — | | `web/boot.js` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
build needs node and `npm ci` run once. ipx-web rebuilt**, not ipx, and building it needs node and `npm ci` run once. ipx serves the API
and the files, and no page.
## A scan ## A scan
@@ -70,7 +97,7 @@ entry_state user_id, feed_id, guid, read, flagged, position
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5. before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever Schema changes: the tables are the entities in `crates/ipx/src/entity.rs`, and `Db::open` creates whatever
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
never alters a table that exists, so a new column on one needs its own `ALTER` in never alters a table that exists, so a new column on one needs its own `ALTER` in
`create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is, `create_missing`, looked for first and run only when it is missing, as `feeds.description`'s is,
@@ -120,8 +147,8 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
| Route | | | Route | |
|---|---| |---|---|
| `GET /` | the app | | `GET /api/page-check`, `GET /api/login-check`, `GET /api/signin` | for Caddy (#203): whether the visitor may have the page in `X-Forwarded-Uri` (`204`), or where to send them; signing in with `?token=` |
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in | | `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in; `/api/me` also gives the version and the account's theme from before |
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe | | `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe | | `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) | | `GET /api/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
@@ -132,9 +159,12 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | | | `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/directory/{id}` | a listed feed's description, why its last check failed when it did (in words, never its error, which can name its address), and latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists | | `GET /api/directory/{id}` | a feed's page from the directory service (#205), by the directory's id: its description and latest twenty items, sanitized here, and why its last check failed, in words; never a file or its address |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id | | `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the directory service's ten most subscribed and every feed it lists, A to Z, asked by ipx with its token (directory id, title, art, subscribers across every sharing server, whether it is yours and its id here, category, whether it carries audio or video; never a URL); subscribe by the directory's id, which takes the feed's address from the directory. `503` with no directory set up |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write | | `DELETE /api/directory/{id}`, `POST /api/directory/{id}/refresh` | admin-only, and the directory refuses unless this server's token is `rwa` (#206): remove and block a feed; check it now. `GET /api/directory/{id}` says `can_admin` when both hold |
| `GET /api/directory-admin`, `DELETE /api/directory-blocked/{id}`, `DELETE /api/directory-instances/{name}` | admin-only, `rwa` (#206): the servers sharing (name, feeds, subscribers) and the blocked feeds (id, title, when; never the address, often why it was blocked); unblock; drop a server's snapshot. Unblocking or dropping sends this server's snapshot again at once |
| `GET /api/directory-status` | admin-only: the name and access the directory gives this server's token, or why it refused, and how the last snapshot went |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write. The directory's token is written, never read back: `directory_token_set` says whether there is one |
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
| `GET /api/events` | SSE, the same broadcast the socket carries | | `GET /api/events` | SSE, the same broadcast the socket carries |
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor | | `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |

View File

@@ -13,12 +13,12 @@ processes must never download the same thing. `--local` forces the work to happe
| `ipx list` | Subscriptions and their state | | `ipx list` | Subscriptions and their state |
| `ipx status` | Counts: feeds, pending, downloaded | | `ipx status` | Counts: feeds, pending, downloaded |
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL | | `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` puts it in the Directory for anyone to subscribe to instead, and keeps it there when its last subscriber leaves; `--category` files it under one of the Directory's categories. Run for a feed already in the catalogue, these list it or set its category | | `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 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 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 reap [--dry-run]` | Run retention now |
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI | | `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI | | `ipx daemon [--web ADDR]` | Scheduler, control socket and the HTTP API (the page is ipx-web's) |
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they `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 then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
@@ -71,7 +71,7 @@ stops the next scan fetching it again.
## The daemon ## The daemon
```sh ```sh
ipx daemon # scheduler + socket + web UI ipx daemon # scheduler + socket + HTTP API
ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run ipx daemon --web 0.0.0.0:8099 # override the configured bind for one run
``` ```

View File

@@ -140,7 +140,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
schedule = "every 6h" # overrides [general] for this feed schedule = "every 6h" # overrides [general] for this feed
media_types = ["audio"] # overrides [general] for this feed media_types = ["audio"] # overrides [general] for this feed
category = "Technology" # the Directory's, if the feed names none category = "Technology" # the Directory's, if the feed names none
listed = true # in the Directory with no subscribers listed = true # shared with the directory with no subscribers
username = "ray" # HTTP basic auth username = "ray" # HTTP basic auth
password_env = "IPX_ATP_PASS" # preferred over a literal `password` password_env = "IPX_ATP_PASS" # preferred over a literal `password`
``` ```
@@ -150,9 +150,14 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md). and are the same for everyone. See [users.md](users.md).
**The Directory** lists every feed in the catalogue, whether or not anyone subscribes to it yet. **The Directory** is the directory service's ([directory.md](directory.md)), which an admin sets
To put one there for others to find, `ipx add --list --category News <url>`: it subscribes no one, up in Admin, Directory: its address, this server's token and whether to share. They are server
and a listed feed stays when its last subscriber leaves, where another is dropped. A feed nobody settings, kept in the database (`directory_url`, `directory_token`, `directory_share`), not here.
With sharing on, this server sends it every feed in the catalogue that is not private, with how
many people here subscribe, and the `category` above for a feed that names none. To offer one with
nobody subscribed, `ipx add --list --category News <url>`: it subscribes no one, and a listed feed
stays in the catalogue, and so in what is shared, when its last subscriber leaves, where another
is dropped. A feed nobody
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue. new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
@@ -172,3 +177,17 @@ and they are re-derived on every scan. Editing one in the UI promotes it to a ca
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view | | `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too | | `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches | | `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
## ipx-web
The page's container reads only its environment (`web/Caddyfile`):
| Variable | Effect |
|---|---|
| `IPX_UPSTREAM` | Where ipx is, `host:port`; `ipx:8099` by default |
| `WEB_BIND` | Where to listen; `:8080` by default |
| `WEB_ROOT` | The built page; `/srv` in the image |
| `TUNNEL_PEER` | The one address whose `Cf-Access-*` headers are passed on to ipx: where cloudflared's requests come from. Unset passes none. See [sso.md](sso.md) |
| `WEB_TRACING` | `on` sends a span for each request to the collector the `OTEL_*` variables name (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, `OTEL_SERVICE_NAME`), continuing the page's trace into ipx. Off by default |
ipx-directory's settings are in [directory.md](directory.md).

147
docs/directory.md Normal file
View File

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

View File

@@ -18,7 +18,7 @@ real setup and pointed at the wrong address.
``` ```
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect) browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
─► tunnel "rays-unraid" (the cloudflared container on Tower) ─► tunnel "rays-unraid" (the cloudflared container on Tower)
─► http://192.168.1.130:8099 ─► ipx ─► http://192.168.1.130:8099, ipx-web (Caddy) ─► ipx
``` ```
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
@@ -38,7 +38,7 @@ request it forwards through the tunnel, and ipx signs that person in.
enabled = true enabled = true
bind = "0.0.0.0:8099" bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email" trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"] trusted_proxies = ["127.0.0.1", "::1", "192.168.64.10"] # ipx-web; see "Behind ipx-web"
access_team = "rays-sdf1.cloudflareaccess.com" access_team = "rays-sdf1.cloudflareaccess.com"
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f" access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
auto_create_users = true auto_create_users = true
@@ -50,7 +50,7 @@ The last two turn on the token check described under [Verifying Cloudflare's tok
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`. out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/ipx/compose.yaml
restart ipx`. restart ipx`.
### What was missing ### What was missing
@@ -65,22 +65,34 @@ Cloudflare and Authentik were already right. Three things on the ipx side were n
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty `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. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
### The address to trust, and why it is 192.168.16.1 ### The address to trust, and why it is 192.168.64.1
`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker `cloudflared` runs in its own container and reaches ipx-web (before #203, ipx) through the host's published port. Docker
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's (iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
requests arrive from the **gateway of ipx's own network**, `content_default`: requests arrive from the **gateway of ipx's own network**, `ipx_default`:
```sh ```sh
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' docker network inspect ipx_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
``` ```
That was measured, not assumed. ipx does not log where a request came from, so the addresses were That was measured, not assumed. ipx does not log where a request came from, so the addresses were
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp` read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
lists them in hex.) lists them in hex.)
If the `content` project's network is ever recreated, its gateway can change. Check it again, and The `ipx` project's compose file pins that network's subnet to `192.168.64.0/20`, so its gateway
update `trusted_proxies` to match. stays `192.168.64.1` when the network is recreated. It was `192.168.16.1`, `content_default`'s, while
ipx was part of the `content` project. If the subnet is ever changed, update `TUNNEL_PEER` in the
compose file to match (before #203, `trusted_proxies`).
### Behind ipx-web (#203)
With the page served by `ipx-web` (Caddy), cloudflared reaches Caddy instead, still through the
host's port 8099, so its requests still arrive from `192.168.64.1`, at Caddy now. Caddy passes the
`Cf-Access-*` headers on only from that address (`TUNNEL_PEER`) and strips them from everyone else,
the boundary `trusted_proxies` drew before. ipx then sees every request come from Caddy, so
`trusted_proxies` names Caddy's address on `ipx_default`, pinned in the compose file
(`192.168.64.10`), instead of the gateway. The token check is unchanged: a header Caddy passes on
still needs a valid `Cf-Access-Jwt-Assertion`.
### Names ### Names
@@ -173,10 +185,12 @@ itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-user
## How this is secured ## How this is secured
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the **The header is only passed on from `TUNNEL_PEER`, and only believed from `trusted_proxies`.**
request falls through to a session cookie or the shared token. That is the whole security boundary. Caddy strips `Cf-Access-*` from every request that does not come from `TUNNEL_PEER`, and ipx
ignores the header from anyone but Caddy, its one trusted proxy; such a request falls through to a
session cookie or the shared token. That is the whole security boundary.
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower With the tunnel reaching ipx-web through the host's port, `192.168.64.1` means **any container on Tower
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
itself, arrive under their own addresses and cannot set the header; the checks above show both itself, arrive under their own addresses and cannot set the header; the checks above show both
sides. Never list a LAN address or range: anyone there could then send sides. Never list a LAN address or range: anyone there could then send

View File

@@ -70,8 +70,10 @@ list with their own read state. Unsubscribing removes it from their list alone;
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
re-subscribing does not pull the back catalogue again. re-subscribing does not pull the back catalogue again.
**Directory** sits at the top of the feed list, above your own feeds. It opens on the ten feeds **Directory** sits at the top of the feed list, above your own feeds. It shows the directory
with the most subscribers on this server, you included, ranked, then the categories, each a tile service an admin has set up ([directory.md](directory.md)), which iPX servers share, and says so
when there is none. It opens on the ten feeds with the most subscribers on every server sharing
with it, you included, ranked, then the categories, each a tile
showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds, showing a few of its shows' covers. A category has a page of its own: its most subscribed feeds,
then all of them as a grid of cover art, with its subcategories along the top (Tech News under then all of them as a grid of cover art, with its subcategories along the top (Tech News under
News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search News, Video Games under Leisure) and a choice of order. See all lists every feed, and the search
@@ -81,7 +83,7 @@ Blogs (the rest). A show's category is the one it gives itself in iTunes, or for
none, one an admin or iPX picked for it. Your own feeds are marked Subscribed. none, one an admin or iPX picked for it. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL, want. Anything private is left out: a feed someone marked Private in its settings, a login configured for the feed, credentials in its URL,
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
@@ -99,7 +101,7 @@ removed there, so there is always someone who can manage the rest.
## Admin ## Admin
The first account is an admin. An admin can change global settings (scanning interval, quota, The first account is an admin. An admin can change global settings (scanning interval, quota,
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account retention, media types, download folder, the directory), a feed's URL, folder and schedule, and who has an account
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
gets the Settings and Log buttons hidden and a `403` if they ask anyway. gets the Settings and Log buttons hidden and a `403` if they ask anyway.

View File

@@ -1,12 +1,15 @@
"""The iPX dashboard in Grafana, from Loki (the container's log, shipped by Alloy) and Tempo. """The iPX dashboard in Grafana: the three containers of the Arcane project ipx (ipx, ipx-web,
ipx-directory) and Tower under them, from Prometheus, Loki (their logs, shipped by Alloy) and Tempo.
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The Grafana reads that file on its own within a minute; edits made in Grafana are refused. Rows go
Daemon row reads ipx's /metrics from Prometheus (job ipx, #194); the rest read the fields of top-down as a request does: an overview, Tower, then each container (ipx-web, ipx's API, its
ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or ipc.rs log_event worker and its process, the directory), then traces and logs. Prometheus scrapes ipx's /metrics
has to be matched here. `dashboard.py queries` prints each (job ipx, #194) and Tower's node exporter (job node); nothing scrapes the containers themselves,
query, to try against Loki. so ipx-web and the directory are read from their logs. The log panels read fields: ipx's
(web.rs access_log, ipc.rs log_event), the directory's and Caddy's; renaming one has to be
matched here. `dashboard.py queries` prints each Loki query, to try against Loki.
""" """
import json, sys import json, sys
@@ -22,11 +25,22 @@ EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"' HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"' BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"' TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
TRACES = '{resource.service.name="ipx" && resource.deployment.environment.name="production"' # Every production span: ipx-web's, ipx's and the directory's, one trace from the page to the
# directory where a request goes that far (#213). A daemon run by hand has no such tag.
TRACES = '{resource.deployment.environment.name="production"'
def status(field): # The directory's JSON log (#204): a line a request (method, path, status, duration_ms, token), a
return f'max(max_over_time({EV} | ev = "status" | unwrap {field} [10m]))' # line a snapshot (instance, accepted, refused_*), a line a refresher round (checked) and one a
# failed feed check (feed, error).
DIR = '{container="ipx-directory"} | json | __error__=""'
DIR_HTTP = '{container="ipx-directory"} |= "ipx_directory::http" | json | __error__=""'
# ipx-web's Caddy access log (#203, #210): request.uri, status, duration in seconds. The page and
# its files only: /api and /media are ipx's, and in its own log already.
WEB = ('{container="ipx-web"} |= "http.log.access" | json | __error__=""'
' | request_uri !~ "/(api|media)/.*" | request_remote_ip != "127.0.0.1"') # not its healthcheck
# Less what Caddy says at every start, by design: no admin endpoint, and no HTTPS or HTTP/3
# behind the tunnel; and its goodbye on a restart.
OTHERS_BAD = ('{container=~"ipx-web|ipx-directory"} | json | __error__="" | level =~ "(?i)warn|error"'
' | msg !~ "admin endpoint disabled|automatic HTTPS is completely disabled|HTTP/[23] skipped.*|exiting; byeee.*"')
QUERIES = {} QUERIES = {}
@@ -37,8 +51,12 @@ pid = 0
def panel(kind, title, w, h, x, targets, **extra): def panel(kind, title, w, h, x, targets, **extra):
global pid global pid
pid += 1 pid += 1
sources = {t.get("datasource", LOKI)["uid"] for t in targets}
# A panel with queries on more than one data source must be Mixed, or Grafana runs them all on
# the panel's and Downloads, Loki's and Prometheus's, failed (#226).
ds = {"type": "datasource", "uid": "-- Mixed --"} if len(sources) > 1 else targets[0].get("datasource", LOKI)
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h}, p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
"datasource": targets[0].get("datasource", LOKI), "targets": targets} "datasource": ds, "targets": targets}
p.update(extra) p.update(extra)
panels.append(p) panels.append(p)
return p return p
@@ -68,9 +86,10 @@ def row(title):
y += 1 y += 1
def stat(title, expr, x, unit="short", color="blue", thresholds=None, desc=None): def stat(title, target, x, unit="short", color="blue", thresholds=None, desc=None):
"""target: loki(..., instant=True) or prom(...)."""
steps = thresholds or [{"color": color, "value": None}] steps = thresholds or [{"color": color, "value": None}]
return panel("stat", title, 4, 4, x, [loki(expr, instant=True)], description=desc or "", return panel("stat", title, 4, 4, x, [target], description=desc or "",
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"}, fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []}, "thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False}, options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
@@ -97,41 +116,78 @@ def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
fieldConfig={"defaults": {}, "overrides": []}) fieldConfig={"defaults": {}, "overrides": []})
# ---- Now GOOD_BAD = [{"color": "green", "value": None}, {"color": "orange", "value": 1}]
row("Now") # Caddy's line for each request, what kind it is by its address.
stat("Feeds", status("feeds"), 0, desc="From the healthcheck's status answer, every 30 seconds.") WEB_ALL = ('{container="ipx-web"} |= "http.log.access" | json | __error__="" | request_remote_ip != "127.0.0.1"'
stat("Waiting to download", status("pending"), 4) ' | label_format kind=`{{ if hasPrefix "/api/" .request_uri }}API{{ else if hasPrefix "/media/" .request_uri }}'
stat("Downloaded", status("downloaded"), 8, color="green") 'media{{ else }}page and files{{ end }}`')
stat("Feed failures", f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', 12, LOG_OPTS = {"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True}
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}],
desc="Failed feed checks in the time range.")
stat("Download failures", f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', 16, def bar_overrides(*names):
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}]) return [{"matcher": {"id": "byName", "options": n},
stat("Warnings and errors", f'sum(count_over_time({BAD} [$__range])) or vector(0)', 20, "properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}]) {"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}
for n in names]
# ---- Overview: is each part up, and is anything wrong
row("Overview")
stat("ipx", prom('max(up{job="ipx"})'), 0, thresholds=[{"color": "red", "value": None}, {"color": "green", "value": 1}],
desc="Prometheus reaching ipx's /metrics: 1 is up. Its healthcheck asks the daemon and the database too.")["fieldConfig"]["defaults"]["mappings"] = [
{"type": "value", "options": {"0": {"text": "down"}, "1": {"text": "up"}}}]
stat("Feeds", prom('max(ipx_feeds{job="ipx"})'), 4)
stat("Waiting to download", prom('max(ipx_downloads_pending{job="ipx"})'), 8)
stat("Since the last scan", prom('max(ipx_last_scan_age_seconds{job="ipx"})'), 12, unit="s",
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 600}, {"color": "red", "value": 1800}],
desc="Over ten minutes means the worker may be stuck; the healthcheck still passes.")
stat("Page and API errors", loki(f'sum(count_over_time({WEB_ALL} | status >= 500 [$__range])) or vector(0)', instant=True), 16,
thresholds=GOOD_BAD, desc="Answers of 500 and up from ipx-web, for the page or passed on from ipx: 502 is ipx not answering.")
stat("Warnings and errors", loki(f'sum(count_over_time({BAD} [$__range])) + sum(count_over_time({OTHERS_BAD} [$__range])) or vector(0)', instant=True), 20,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}],
desc="All three containers, in the time range.")
y += 4
stat("Feed failures", loki(f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', instant=True), 0,
thresholds=GOOD_BAD, desc="Failed feed checks in the time range.")
stat("Download failures", loki(f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', instant=True), 4,
thresholds=GOOD_BAD)
stat("Downloaded", prom('max(ipx_files_downloaded{job="ipx"})'), 8, color="green")
stat("ipx running for", prom('max(ipx_uptime_seconds{job="ipx"})'), 12, unit="s")
stat("Directory: feeds shared", loki('sum(max by (instance) (max_over_time(' + DIR + ' | message="snapshot" | unwrap accepted [6h])))', instant=True), 16,
desc="The feeds each server's latest snapshot gave, added up.")
stat("Directory: refused as private", loki(f'sum(sum_over_time({DIR} | message="snapshot" | unwrap refused_private [$__range])) or vector(0)', instant=True), 20,
thresholds=[{"color": "green", "value": None}, {"color": "red", "value": 1}],
desc="Feeds a server sent that the directory caught as private. Not 0 means a server's own check missed one.")
y += 4 y += 4
# ---- Scans # ---- Tower: the host all three run on, from the node exporter
row("Scans and downloads") row("Tower")
ts("New items found", [loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', legend="new items")], ts("Load", [prom('node_load1{job="node"}', legend="1 minute"), prom('node_load15{job="node"}', ref="B", legend="15 minutes")],
0, bars=True) 0, w=8, desc="Twelve threads. Load far above that with little CPU used is processes waiting on a disk or the user shares (shfs).")
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"), ts("CPU", [prom('sum by (mode) (rate(node_cpu_seconds_total{job="node", mode=~"user|system|iowait"}[$__rate_interval]))', legend="{{mode}}")],
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed")], 8, w=8, stack=True, desc="Cores busy, by kind. Much system time with little user time is the kernel, often FUSE (shfs).")
12, bars=True) ts("Memory available", [prom('node_memory_MemAvailable_bytes{job="node"}', legend="available")], 16, w=8, unit="bytes")
y += 8 y += 8
ts("Bytes downloaded", [loki(f'sum(sum_over_time({EV} | ev="download_done" | unwrap bytes [$__interval]))', legend="bytes")], ts("Disk busy", [prom('rate(node_disk_io_time_seconds_total{job="node", device=~"nvme.*|md.*"}[$__rate_interval])', legend="{{device}}")],
0, unit="bytes", bars=True) 0, w=12, unit="percentunit", desc="nvme1n1 is /mnt/fast (appdata, Postgres's data); md* are the array's disks behind /mnt/user (downloads).")
ts("Feeds checked per scan", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked")], ts("Network", [prom('sum(rate(node_network_receive_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', legend="in"),
12, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.") prom('sum(rate(node_network_transmit_bytes_total{job="node", device!~"lo|veth.*|docker.*|br-.*"}[$__rate_interval]))', ref="B", legend="out")],
y += 8 12, w=12, unit="Bps")
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
y += 8 y += 8
# ---- Web # ---- ipx-web: Caddy, the way in. Serves the page itself, passes /api and /media to ipx
row("Web") row("ipx-web")
ts("Requests by kind", [loki(f'sum by (kind) (count_over_time({WEB_ALL} [$__interval]))', legend="{{kind}}")],
0, w=8, bars=True, stack=True, desc="Everything through Caddy, from the LAN and the tunnel. The API and media go on to ipx.")
ts("Page and files by status", [loki(f'sum by (status) (count_over_time({WEB} [$__interval]))', legend="{{status}}")],
8, w=8, bars=True, stack=True, desc="What Caddy served itself. A page answered 401 or 403 is ipx refusing it in /api/page-check.")
ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duration [$__interval]) by ()', legend="95th percentile"),
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
y += 8
# ---- 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", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.") 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"), ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
@@ -140,13 +196,57 @@ ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms
12, unit="ms") 12, unit="ms")
y += 8 y += 8
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)], table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
0, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": 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("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}]) 8, w=8, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
ts("Open connections", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="requests in flight"),
prom('max(ipx_event_listeners{job="ipx"})', ref="B", legend="live-update listeners"),
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
16, w=8, desc="Each open page holds one live-update listener. Missed updates mean a page fell behind and reloads its lists.")
y += 8 y += 8
# ---- Daemon, from Prometheus: what the daemon holds between requests (#194) # ---- ipx: the worker, one job at a time: scans, downloads, DeadAir
row("Daemon") row("ipx: worker")
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, bars=True, stack=True)
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
y += 8
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"),
prom('ipx_downloads_pending{job="ipx"}', ref="B", legend="downloads"),
prom('max(ipx_feed_retries{job="ipx"})', ref="C", legend="feeds to retry a lookup"),
prom('max(ipx_deadair_backlog{job="ipx"})', ref="D", legend="files for DeadAir")],
0, w=8, desc="A queue that only grows means the worker is stuck or behind.")
ts("Feeds checked and new items", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked"),
loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', ref="B", legend="new items")],
8, w=8, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed"),
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="at once"),
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
16, w=8, bars=True)
y += 8
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
0, w=8, unit="Bps")
ts("Feeds fetching at once", [prom('max(ipx_scan_feeds_fetching{job="ipx"})', legend="fetching")], 8, w=8)
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for"),
prom('max(ipx_deadair_playing{job="ipx"})', ref="B", legend="playing through DeadAir")],
16, w=8, unit="s", desc="How long DeadAir has been on the file it is analysing; a few seconds each.")["fieldConfig"]["overrides"] = [
{"matcher": {"id": "byName", "options": "playing through DeadAir"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]}]
y += 8
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
y += 8
# ---- ipx: the process, what it holds between requests and jobs (#194)
row("ipx: process")
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not # Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
# pressure; drawn as a line of its own it read as the pool maxing out (#199). # pressure; drawn as a line of its own it read as the pool maxing out (#199).
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"), ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
@@ -157,42 +257,13 @@ ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx"
{"matcher": {"id": "byName", "options": "open"}, {"matcher": {"id": "byName", "options": "open"},
"properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}}, "properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}},
{"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}] {"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}]
ts("Waiting", [prom('ipx_worker_queue{job="ipx"}', legend="commands for the worker"), ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 8, w=8, unit="bytes")
prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks for a thread"), ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
prom('ipx_downloads_pending{job="ipx"}', ref="C", legend="downloads")],
8, w=8, desc="Scans and downloads run one at a time; a queue that only grows means the worker is stuck.")
ts("Listeners and tasks", [prom('ipx_event_listeners{job="ipx"}', legend="live-update listeners"),
prom('ipx_runtime_tasks{job="ipx"}', ref="B", legend="runtime tasks"),
prom('ipx_deadair_playing{job="ipx"}', ref="C", legend="playing through DeadAir")],
16, w=8)
y += 8 y += 8
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 0, w=12, unit="bytes") ts("Runtime", [prom('ipx_runtime_tasks{job="ipx"}', legend="tasks"),
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 12, w=12) prom('ipx_runtime_queue{job="ipx"}', ref="B", legend="tasks waiting for a thread"),
y += 8 prom('ipx_runtime_workers{job="ipx"}', ref="C", legend="threads")],
# Jobs and queues (#195) 0, w=12, desc="Tasks waiting for a thread for long means something is blocking the runtime.")
ts("Worker", [prom('max by (kind) (ipx_worker_job_seconds{job="ipx"})', legend="on a {{kind}} for"),
prom('max(ipx_last_scan_age_seconds{job="ipx"})', ref="B", legend="since the last scan finished")],
0, w=8, unit="s", desc="A job's time that only climbs, or no scan for over 10 minutes, means the worker is stuck; the healthcheck still passes.")
ts("Jobs done", [prom('sum by (kind) (increase(ipx_jobs_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
8, w=8, bars=True, stack=True)
ts("Time on jobs", [prom('sum by (kind) (increase(ipx_job_seconds_total{job="ipx"}[$__rate_interval]))', legend="{{kind}}")],
16, w=8, unit="s", bars=True, stack=True, desc="Seconds the worker spent on each kind of job in each interval.")
y += 8
ts("In flight", [prom('max(ipx_http_requests_in_flight{job="ipx"})', legend="web requests"),
prom('max(ipx_scan_feeds_fetching{job="ipx"})', ref="B", legend="feeds fetching"),
prom('max(ipx_downloads_active{job="ipx"})', ref="C", legend="downloads"),
prom('2 - max(ipx_torrent_slots_free{job="ipx"})', ref="D", legend="torrents")],
0, w=8)
ts("Download speed", [prom('sum(rate(ipx_download_bytes_total{job="ipx"}[$__rate_interval]))', legend="downloading")],
8, w=8, unit="Bps")
ts("Backlogs", [prom('max(ipx_deadair_backlog{job="ipx"})', legend="DeadAir to look at"),
prom('max(ipx_feed_retries{job="ipx"})', ref="B", legend="feeds to retry a lookup"),
prom('max(ipx_event_queue{job="ipx"})', ref="C", legend="live updates unread"),
prom('sum(increase(ipx_events_missed_total{job="ipx"}[$__rate_interval]))', ref="D", legend="live updates missed")],
16, w=8)
y += 8
ts("DeadAir on a file", [prom('max(ipx_deadair_busy_seconds{job="ipx"})', legend="for")], 0, w=12, unit="s",
desc="How long DeadAir has been on the file it is analysing; a few seconds each.")
ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"), ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_limit_bytes{job="ipx"})', legend="full"),
prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / ' prom('sum(increase(ipx_art_requests_total{job="ipx", result="hit"}[$__rate_interval])) / '
'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"), 'sum(increase(ipx_art_requests_total{job="ipx"}[$__rate_interval]))', ref="B", legend="hit rate"),
@@ -200,32 +271,64 @@ ts("Artwork", [prom('max(ipx_art_cache_bytes{job="ipx"}) / max(ipx_art_cache_lim
12, w=12, unit="percentunit", 12, w=12, unit="percentunit",
desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: " desc="Full sits near 100% once the cache has filled, since each sweep trims it to fit. Hit rate is the share served from disk: "
"the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the " "the rest was fetched from the publisher. Evicted, on the right, is images each sweep threw out to stay under the "
"limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = [ "limit: many at every sweep, with a falling hit rate, means the limit is too small.")["fieldConfig"]["overrides"] = bar_overrides("images evicted")
{"matcher": {"id": "byName", "options": "images evicted"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"},
{"id": "custom.drawStyle", "value": "bars"}, {"id": "custom.fillOpacity", "value": 60}]}]
y += 8 y += 8
# ---- Traces # ---- ipx-directory: public at directory.ipxreader.app, shared by ipx servers (#204)
row("ipx-directory")
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
desc="ipx sends one when what it shares changes, and every half hour (#211).")
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
desc="Each feed every six hours, 25 at a time.")
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
thresholds=GOOD_BAD, desc="Mostly the publishers': a feed gone or moved.")
stat("Requests", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)
stat("Requests refused", loki(f'sum(count_over_time({DIR_HTTP} | status =~ "401|403" [$__range])) or vector(0)', instant=True), 16,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 100}],
desc="Without a token, or with one that has not the access asked for. Now and then is someone trying a public address.")
stat("Slowest request", loki(f'max(max_over_time({DIR_HTTP} | unwrap duration_ms [$__range])) or vector(0)', instant=True), 20, unit="ms")
y += 4
ts("Requests by token", [loki(f'sum by (status, token) (count_over_time({DIR_HTTP} [$__interval]))', legend="{{status}} {{token}}")],
0, bars=True, stack=True)
ts("Feed checks over time", [loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__interval]))', legend="checked"),
loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__interval]))', ref="B", legend="failed")],
12, bars=True, desc="A burst follows a snapshot with new feeds.")
y += 8
table("Failing feeds in the directory", [loki(f'sum by (feed, error) (count_over_time({DIR} | message="feed check failed" [$__range]))', instant=True)],
0, rename={"feed": "Feed id", "error": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Snapshots by server", [loki(f'sum by (instance) (count_over_time({DIR} | message="snapshot" [$__range]))', instant=True)],
12, rename={"instance": "Token", "Value": "Snapshots"}, sort=[{"displayName": "Snapshots", "desc": True}])
y += 8
# The directory's own /metrics, on a port apart from the tunnel's (#224).
ts("Directory memory", [prom('directory_process_resident_bytes{job="ipx-directory"}', legend="resident")], 0, unit="bytes",
desc="mimalloc gives back what is freed, so this follows what is in use: a rise that never comes down is a leak.")
ts("Feed bytes fetched", [prom('rate(ipx_feed_bytes_total{job="ipx"}[$__rate_interval])', legend="ipx"),
prom('rate(directory_feed_bytes_total{job="ipx-directory"}[$__rate_interval])', ref="B", legend="directory")],
12, unit="Bps", desc="Feeds are read whole, so this is what scans and checks allocate; set it beside memory.")
y += 8
# ---- Traces: all three send them to Tempo (#213)
row("Traces") row("Traces")
for x, title, q in [(0, "Recent traces", TRACES + "}"), for x, title, q in [(0, "Recent", TRACES + "}"),
(12, "Slow traces (over 2s)", TRACES + " && duration > 2s}")]: (8, "Slow (over 2s)", TRACES + " && duration > 2s}"),
panel("table", title, 12, 10, x, (16, "Failed", TRACES + " && status = error}")]:
panel("table", title, 8, 10, x,
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50, [{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
"tableType": "traces"}], "tableType": "traces"}],
fieldConfig={"defaults": {}, "overrides": []}) fieldConfig={"defaults": {}, "overrides": []})
y += 10 y += 10
# ---- Log # ---- Logs
row("Log") row("Logs")
panel("logs", "Warnings and errors", 24, 10, 0, [loki(BAD + TEXT)], panel("logs", "ipx: warnings and errors", 12, 10, 0, [loki(BAD + TEXT)], options=LOG_OPTS)
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True}) panel("logs", "ipx-web and ipx-directory: warnings and errors", 12, 10, 12,
[loki(OTHERS_BAD + ' | line_format "{{.container}} {{.level}}: {{.message}}{{.msg}}"')], options=LOG_OPTS)
y += 10 y += 10
panel("logs", "Log", 24, 12, 0, panel("logs", "ipx log", 24, 12, 0,
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"' [loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)], ' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
description="Without the event stream's requests, not-due skips and healthcheck status calls.", description="Without the event stream's requests, not-due skips and healthcheck status calls.", options=LOG_OPTS)
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True}) y += 12
dash = { dash = {
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39, "uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,

View File

@@ -1,5 +1,6 @@
{ {
"name": "ipx-web", "name": "ipx-web",
"version": "1.0.2",
"private": true, "private": true,
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.", "description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
"scripts": { "scripts": {

View File

@@ -26,10 +26,29 @@ module.exports = defineConfig({
reuseExistingServer: false, reuseExistingServer: false,
stdout: 'ignore', stdout: 'ignore',
}, },
{
// The directory the Directory shows (#205), on SQLite in the scratch directory.
command: `cargo build -q -p ipx-directory && exec ./target/debug/ipx-directory --config ${setup.root}/directory.toml`,
url: `${setup.DIRECTORY}/healthz`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'ignore',
env: { DIRECTORY_DATABASE_URL: `sqlite://${setup.root}/directory.db?mode=rwc` },
},
{
// The frontend as production serves it (#203): built from web/, served by Caddy on the
// suite's port, the API passed to the daemon behind it.
command: `node web/build.mjs ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`,
url: 'http://127.0.0.1:8791/favicon.png',
reuseExistingServer: false,
timeout: 60_000,
stdout: 'ignore',
env: { WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root },
},
{ {
// Build first so the tests always run against current source. // Build first so the tests always run against current source.
command: 'cargo build -q && exec ./target/debug/ipx daemon', command: 'cargo build -q && exec ./target/debug/ipx daemon',
port: 8791, port: 8796,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 180_000, timeout: 180_000,
stdout: 'pipe', stdout: 'pipe',

46
scripts/release.sh Executable file
View File

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

7
site/_headers Normal file
View File

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

655
site/api.mjs Normal file
View File

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

14
site/build.sh Executable file
View File

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

24
site/docs.mjs Normal file
View File

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

212
site/gen.mjs Normal file
View File

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

1
site/gitea.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 2.0 KiB

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

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

View File

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

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

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

139
site/index.html Normal file
View File

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

BIN
site/shots/directory.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
site/shots/episodes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
site/shots/feeds.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
site/shots/player.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

192
site/site.css Normal file
View File

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

1
site/wave.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -8,8 +8,7 @@
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure // It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own // the build, not ipx), serves generated feeds from this process, starts a daemon on its own
// config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script. // config and data under /tmp/ipx-load, wiped first, seeds listeners, then runs each k6 script.
// While each runs, `ipx status` -- the Docker healthcheck -- is run every second, and the run // While each runs, `ipx status` is run every second, and the run fails if any answer takes 5s.
// fails if any answer takes as long as the healthcheck's 5s timeout.
// //
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the // The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in // database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in

View File

@@ -68,6 +68,18 @@ for (const [name, fn] of drive) {
} }
} }
// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet
// minute, so a refresh hours later is not filed under the last tap.
if (PAGE === 'index.html') {
const id = () => ctx.traceparent().split('-')[1];
vm.runInContext('Date.now = (n => () => n)(1e12)', ctx);
const first = id();
vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx);
if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); }
vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx);
if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); }
}
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it // theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find. // touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']); const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);

132
tests/page-unit.js Normal file
View File

@@ -0,0 +1,132 @@
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
// this tests the small pieces it is made of, in a second, with nothing running.
//
// node --test tests/page-unit.js
//
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
// a second <script> on a page would, so `run` evaluates an expression there.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { makeContext, vm } = require('./dom-stub.js');
const { buildPage } = require('../web/build.mjs');
const { html, js } = buildPage('index.html');
const { ctx } = makeContext(html, js);
vm.createContext(ctx);
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
const run = expr => vm.runInContext(expr, ctx);
// Time as the page sees it, for the helpers that count from now.
const NOW = 1_800_000_000;
run(`Date.now = () => ${NOW * 1000}`);
test('esc makes text safe to put in markup and attributes', () => {
assert.equal(run(`esc('<a href="x">Tom & Jerry\\'s</a>')`), '&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/a&gt;');
assert.equal(run('esc(null)'), '');
assert.equal(run('esc(undefined)'), '');
});
test('clock reads seconds as a player does', () => {
assert.equal(run('clock(0)'), '0:00');
assert.equal(run('clock(65)'), '1:05');
assert.equal(run('clock(3661)'), '1:01:01');
assert.equal(run('clock(59.9)'), '0:59');
assert.equal(run('clock(-5)'), '0:00');
assert.equal(run('clock(undefined)'), '0:00');
});
test('mb sizes a file in the unit that reads best', () => {
assert.equal(run('mb(0)'), '');
// An article's small image is not "0 KB" or "0 MB".
assert.equal(run('mb(100)'), '1 KB');
assert.equal(run('mb(2048)'), '2 KB');
assert.equal(run('mb(5 * 1048576)'), '5 MB');
assert.equal(run('mb(1.5 * 1073741824)'), '1.5 GB');
});
test('ago says how long since, and never for nothing', () => {
assert.equal(run('ago(0)'), 'never');
assert.equal(run(`ago(${NOW - 10})`), '1m ago');
assert.equal(run(`ago(${NOW - 45 * 60})`), '45m ago');
assert.equal(run(`ago(${NOW - 5 * 3600})`), '5h ago');
assert.equal(run(`ago(${NOW - 3 * 86400})`), '3d ago');
// Past a month, a date: whatever the locale, it names the year.
assert.match(run(`ago(${NOW - 90 * 86400})`), /2026/);
});
test('due says how long until a feed is checked', () => {
assert.equal(run(`due(${NOW - 1})`), 'due now');
assert.equal(run(`due(${NOW + 10})`), 'in 1m');
assert.equal(run(`due(${NOW + 90 * 60})`), 'in 2h');
assert.equal(run(`due(${NOW + 3 * 86400})`), 'in 3d');
});
test('DeadAir savings read as time', () => {
assert.equal(run('spanText(40)'), '40 s');
assert.equal(run('spanText(12 * 60 + 5)'), '12 min');
assert.equal(run('spanText(3 * 3600 + 12 * 60)'), '3 h 12 min');
assert.equal(run('shorter(45)'), '45 s shorter');
assert.equal(run('shorter(150)'), '3 min shorter');
});
test('initials and plural', () => {
assert.equal(run(`initials('The Verge')`), 'TV');
assert.equal(run(`initials('accidental tech podcast')`), 'AT');
assert.equal(run(`initials('')`), '?');
assert.equal(run(`initials('!!!')`), '?');
assert.equal(run(`plural(1, 'feed')`), '1 feed');
assert.equal(run(`plural(0, 'feed')`), '0 feeds');
assert.equal(run(`plural(2, 'feed')`), '2 feeds');
});
test("kindOf names a file by its type, or else its address's extension", () => {
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
assert.equal(kind({ mime: 'image/png' }), 'image');
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
assert.equal(kind({ mime: 'application/pdf' }), 'pdf');
assert.equal(kind({ mime: 'application/x-bittorrent' }), 'torrent');
assert.equal(kind({ url: 'https://x/ep.MP3?token=1' }), 'mp3');
assert.equal(kind({ url: 'https://x/download' }), 'file');
assert.equal(kind({ url: 'https://x/a.toolongext' }), 'file');
assert.equal(kind({}), 'file');
});
test('an item without a title is named by its file', () => {
const name = e => run(`entryName(${JSON.stringify(e)})`);
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
{ text: 'My Show 101', derived: true });
// A name that does not decode is shown as it is, not dropped.
assert.deepEqual({ ...name({ enclosures: [{ url: 'https://x/100%.mp3' }] }) }, { text: '100%', derived: true });
});
test('the directory sorts by name as people read, and by subscribers', () => {
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
['C', 'A', 'B']);
});
test('feeds with something unread go first, in the order they came', () => {
const ids = run(`[{id:'a',unread:0},{id:'b',unread:3},{id:'c',unread:0},{id:'d',unread:1}].sort(unreadFirst).map(f => f.id)`);
assert.deepEqual([...ids], ['b', 'd', 'a', 'c']);
});
test('words for filters split on commas, blanks dropped', () => {
assert.deepEqual([...run(`splitWords(' one, two words ,, three ')`)], ['one', 'two words', 'three']);
assert.deepEqual([...run(`splitWords('')`)], []);
});
test("a file plays from DeadAir's playlist when it has one", () => {
assert.equal(run(`mediaSrc({ id: 7, deadair: null })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7 })`), '/media/7');
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
});
test('a Patreon feed is told by its address', () => {
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
assert.equal(run('isPatreon(null)'), false);
});

View File

@@ -90,15 +90,19 @@ test('each browser keeps its own theme, in a cookie, not on the account', async
expect(cookie?.value).toBe('flatremix.light'); expect(cookie?.value).toBe('flatremix.light');
expect(patched, 'nothing sent to the account').toBe(false); expect(patched, 'nothing sent to the account').toBe(false);
// This browser: the server reads the cookie and draws the page in it from the first frame. // This browser: boot.js reads the cookie in <head> and draws the page in it from the first
const res = await page.reload(); // frame, before the app's script has run (#203).
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light'); const firstFrame = () => document.documentElement.dataset;
await page.reload({ waitUntil: 'commit' });
await page.waitForFunction(() => document.body);
expect(await page.evaluate(firstFrame)).toMatchObject({ theme: 'flatremix', choice: 'light', mode: 'light' });
// Another browser, the same account: not this one's theme. // Another browser, the same account: not this one's theme.
const other = await browser.newContext(); const other = await browser.newContext();
const p2 = await other.newPage(); const p2 = await other.newPage();
const res2 = await p2.goto(`/?token=${TOKEN}`); await p2.goto(`/?token=${TOKEN}`);
expect(await res2.text()).not.toContain('data-theme=flatremix'); await expect(p2.locator('#feedlist')).toBeVisible();
expect((await p2.evaluate(firstFrame)).theme).not.toBe('flatremix');
await other.close(); await other.close();
}); });
@@ -583,8 +587,9 @@ test('a second person has their own feeds and their own read state', async ({ br
await expect(page.locator('#modalCard')).toContainText('Subscriptions'); await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this'); await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardx').click(); await page.locator('#modalCard .cardx').click();
await expect(page.locator('#admin')).toHaveCount(0); // The page is the same for everyone now (#203): the link is there, and hidden from anyone /api/me
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin'); // does not call an admin.
await expect(page.locator('#admin')).toBeHidden();
// Asking for it anyway goes back to the app, and its script is refused. // Asking for it anyway goes back to the app, and its script is refused.
await page.goto('/admin'); await page.goto('/admin');
await expect(page).toHaveURL(/:8791\/$/); await expect(page).toHaveURL(/:8791\/$/);
@@ -821,7 +826,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
expect(xml).toContain('http://127.0.0.1:8792/two.xml'); expect(xml).toContain('http://127.0.0.1:8792/two.xml');
}); });
test('the Directory lists what everyone here reads, the most subscribed first, but never a private feed', async ({ browser, page }) => { test('the Directory lists what servers share, the most subscribed first, but never a private feed', async ({ browser, page }) => {
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const setup = require('./global-setup'); const setup = require('./global-setup');
const env = { const env = {
@@ -863,11 +868,15 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
const top = await (await piper.request.get('/api/popular')).json(); const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase()); const names = dir.map(p => (p.title || p.id).toLowerCase());
expect(names).toEqual([...names].sort()); expect(names).toEqual([...names].sort());
// The directory's ids, not this server's: it names a feed by its address (#205).
const ids = dir.map(p => p.id); const ids = dir.map(p => p.id);
expect(ids).not.toContain('test-subscriptions'); const titles = dir.map(p => p.title);
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio'])); const byTitle = t => dir.find(p => p.title === t);
expect(titles).not.toContain('Test Subscriptions');
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
expect(top.every(t => ids.includes(t.id))).toBe(true); expect(top.every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show'); expect(titles).not.toContain('Paid Show');
expect(ids).not.toContain('test-show');
await expect(chart).toHaveCount(top.length); await expect(chart).toHaveCount(top.length);
await expect(chart.first().locator('.no')).toHaveText('1'); await expect(chart.first().locator('.no')).toHaveText('1');
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`); await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
@@ -875,8 +884,8 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
// Then the categories, as tiles, each a page of its own. // Then the categories, as tiles, each a page of its own.
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) }); const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
const tiles = piper.locator('#dtiles .tile'); const tiles = piper.locator('#dtiles .tile');
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' }); expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false }); expect(byTitle('Picture Blog')).toMatchObject({ podcast: false });
await cat('Technology').click(); await cat('Technology').click();
await expect(piper.locator('#directory h2')).toHaveText('Technology'); await expect(piper.locator('#directory h2')).toHaveText('Technology');
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length); await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
@@ -927,7 +936,7 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused(); await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
await expect(tiles).toHaveCount(dir.length); await expect(tiles).toHaveCount(dir.length);
// Only a listed feed has one, and it carries no file or its address. // Only a listed feed has one, and it carries no file or its address.
const preview = await (await piper.request.get('/api/directory/test-show')).text(); const preview = await (await piper.request.get(`/api/directory/${byTitle('Test Show').id}`)).text();
expect(preview).toContain('First Episode'); expect(preview).toContain('First Episode');
expect(JSON.parse(preview).description).toBe('A fixture feed.'); expect(JSON.parse(preview).description).toBe('A fixture feed.');
expect(preview).not.toContain('.mp3'); expect(preview).not.toContain('.mp3');
@@ -948,17 +957,20 @@ test('the Directory lists what everyone here reads, the most subscribed first, b
await piper.locator('#addFeed').click(); await piper.locator('#addFeed').click();
await expect(piper.locator('#nurl')).toBeVisible(); await expect(piper.locator('#nurl')).toBeVisible();
await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0); await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0);
// Nor a folder: a feed's folder is its title (#201).
await expect(piper.locator('#nfolder')).toHaveCount(0);
await piper.locator('#modalCard button[title="Cancel"]').click(); await piper.locator('#modalCard button[title="Cancel"]').click();
const row = async () => const row = async () =>
(await (await piper.request.get('/api/directory')).json()).find(p => p.id === 'test-show'); (await (await piper.request.get('/api/directory')).json()).find(p => p.title === 'Test Show');
const before = await row(); const before = await row();
expect(before.subscribed).toBe(false); expect(before).toMatchObject({ subscribed: false, feed: null });
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click(); await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 }); await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber. // Everyone counts, you included: it stays listed, marked as yours by the id it has here, with
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 }); // one more subscriber once this server has shared again.
await expect.poll(row, { timeout: 20_000 }).toMatchObject({ subscribed: true, feed: 'test-show', subscribers: before.subscribers + 1 });
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200); expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200);
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible(); await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
@@ -998,19 +1010,20 @@ test('ipx add and rm while the daemon runs reach it, and its next change keeps t
const setup = require('./global-setup'); const setup = require('./global-setup');
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` }; const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' }); const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' });
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.id); // By title: the directory names a feed by its address, not by its id here (#205).
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
// A change of the daemon's own: it writes the whole catalogue from the copy it holds, which // A change of the daemon's own: it writes the whole catalogue from the copy it holds, which
// wrote a removed feed back and dropped an added one when that copy was old. // wrote a removed feed back and dropped an added one when that copy was old.
const daemonWrites = category => page.evaluate(c => const daemonWrites = category => page.evaluate(c =>
api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category); api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category);
const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1]; const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1];
await expect.poll(listed).toContain(id); await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
await daemonWrites('Comedy'); await daemonWrites('Comedy');
expect(await listed()).toContain(id); await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
ipx('rm', id); ipx('rm', id);
await expect.poll(listed).not.toContain(id); await expect.poll(listed, { timeout: 20_000 }).not.toContain('Command Line Show');
await daemonWrites(null); await daemonWrites(null);
expect(await listed()).not.toContain(id); expect(await listed()).not.toContain(id);
}); });
@@ -1297,11 +1310,14 @@ test('an API token acts as its owner until it is revoked', async ({ page, reques
test('an admin can give a blog its Directory category', async ({ page }) => { test('an admin can give a blog its Directory category', async ({ page }) => {
const patch = (id, category) => page.evaluate(([id, category]) => const patch = (id, category) => page.evaluate(([id, category]) =>
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]); api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id); // By title: the directory names a feed by its address, not by its id here (#205). It hears of
// a category given here with this server's next snapshot, sent as soon as one is saved.
const listed = async title => (await page.evaluate(() => api('/api/directory'))).find(p => p.title === title);
await patch('picture-blog', 'Visual Arts'); await patch('picture-blog', 'Visual Arts');
// Apple's subcategory, under its category, as the Directory browses them (#118). // Apple's subcategory, under its category, as the Directory browses them (#118).
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' }); await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
// A category's subcategories are on its own page, as Apple's directory has them. // A category's subcategories are on its own page, as Apple's directory has them.
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) }); const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
await page.locator('#feedlist .place', { hasText: 'Directory' }).click(); await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
@@ -1312,10 +1328,116 @@ test('an admin can give a blog its Directory category', async ({ page }) => {
await expect(page.locator('#dtiles .tile')).toHaveCount(1); await expect(page.locator('#dtiles .tile')).toHaveCount(1);
// A feed's own iTunes category wins over one given here. // A feed's own iTunes category wins over one given here.
await patch('test-show', 'Comedy'); await patch('test-show', 'Comedy');
expect((await listed('test-show')).category).toBe('Technology'); expect((await listed('Test Show')).category).toBe('Technology');
for (const id of ['picture-blog', 'test-show']) await patch(id, null); for (const id of ['picture-blog', 'test-show']) await patch(id, null);
expect((await listed('picture-blog')).category).toBeNull(); await expect.poll(async () => (await listed('Picture Blog')).category, { timeout: 20_000 }).toBeNull();
});
test('the admin page sets up the directory, and a feed marked private leaves it (#205)', async ({ page }) => {
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
await page.goto('/admin#directory');
await expect(page.locator('#durl')).toHaveValue('http://127.0.0.1:8795');
// The token is set and never shown: the field is empty, and says so.
await expect(page.locator('#dtoken')).toHaveValue('');
await expect(page.locator('#dtoken')).toHaveAttribute('placeholder', /^Set\./);
const settings = await page.evaluate(() => api('/api/settings'));
expect(settings.directory_token_set).toBe(true);
expect(JSON.stringify(settings)).not.toContain('ipxd_');
await expect(page.locator('#dstatus')).toContainText('Connected as test, which may browse, share and administer the directory.');
await expect(page.locator('#dstatus')).toContainText(/Shared \d+ feeds? at/);
// A wrong token says so, in words.
await page.locator('#dtoken').fill('not-the-token');
await page.locator('#dsave').click();
await expect(page.locator('#dstatus')).toContainText('does not accept this server');
await page.locator('#dtoken').fill(require('./global-setup').DIRECTORY_TOKEN);
await page.locator('#dsave').click();
await expect(page.locator('#dstatus')).toContainText('Connected as test');
// Marked private in its settings, a feed is out of the directory with the next snapshot.
await page.goto('/');
await page.locator('.feed', { hasText: 'Multi Show' }).click();
await page.locator('#content .acts [data-a="settings"]').click();
const priv = page.locator('#sprivate');
await expect(priv).not.toBeChecked();
await priv.check({ force: true });
await page.locator('#ssave').click();
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Multi Show');
// Its address shows nothing, so only the mark keeps it out; and an admin can take it off.
const row = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'multi-show'));
expect(row).toMatchObject({ private: true, private_by_address: false });
await page.evaluate(() => api('/api/feeds/multi-show', { method: 'PATCH', body: JSON.stringify({ private: false }) }));
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
// A feed whose address carries a key is private whatever it is marked.
const paid = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'paid-show'));
if (paid) expect(paid.private_by_address).toBe(true);
});
test('an admin removes a feed from the directory, and unblocks it (#206)', async ({ page, browser }) => {
const all = () => page.evaluate(() => api('/api/directory'));
const listed = async () => (await all()).map(p => p.title);
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
const id = (await all()).find(p => p.title === 'Aardvark Radio').id;
// Someone who is not an admin is neither offered it nor allowed it.
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
try {
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
} catch (e) {
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
}
const ctx = await browser.newContext();
const piper = await ctx.newPage();
await piper.goto('/login');
await piper.locator('#name').fill('piper');
await piper.locator('#pw').fill('piperpassword');
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toBeVisible();
expect((await (await piper.request.get(`/api/directory/${id}`)).json()).can_admin).toBe(false);
expect((await piper.request.delete(`/api/directory/${id}`)).status()).toBe(403);
expect((await piper.request.post(`/api/directory/${id}/refresh`)).status()).toBe(403);
expect((await piper.request.get('/api/directory-admin')).status()).toBe(403);
expect((await piper.request.delete('/api/directory-instances/test')).status()).toBe(403);
// Made an admin, and subscribed to none of it, piper opens its page and is offered both. The
// admin here subscribes to every fixture, so their tiles open their own feeds instead.
const users = await page.evaluate(() => api('/api/users'));
const pid = users.find(u => u.name === 'piper').id;
const setAdmin = admin => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]);
await setAdmin(true);
try {
await piper.goto('/');
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await piper.locator('#epSearch').fill('aardvark');
await piper.locator('#dtiles .tile', { hasText: 'Aardvark Radio' }).locator('.txt').click();
await expect(piper.locator('.show h2')).toHaveText('Aardvark Radio');
await piper.locator('.show [data-a="drefresh"]').click();
await expect(piper.locator('.toast', { hasText: 'Checked' })).toBeVisible();
await expect(piper.locator('.show [data-a="dremove"]')).toHaveCount(1);
piper.once('dialog', d => d.accept());
await piper.locator('.show [data-a="dremove"]').click();
await expect(piper.locator('.show h2')).toHaveCount(0);
expect(await listed()).not.toContain('Aardvark Radio');
} finally {
await setAdmin(false);
await ctx.close();
}
// Admin, Directory lists the servers sharing and what is blocked, by title, never its address.
await page.goto('/admin#directory');
await expect(page.locator('#dadmin .urow[data-name="test"]')).toContainText(/\d+ feeds? · \d+ subscribers?/);
const blocked = page.locator('#dadmin .urow[data-id]', { hasText: 'Aardvark Radio' });
await expect(blocked).toBeVisible();
expect(JSON.stringify(await page.evaluate(() => api('/api/directory-admin')))).not.toMatch(/https?:/);
// Unblocked, it is back with the snapshot ipx sends straight away, unchanged as it is.
await blocked.locator('[data-a="unblock"]').click();
await expect(blocked).toHaveCount(0);
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
}); });
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => { test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
@@ -1386,6 +1508,8 @@ test('/api/status gives a dashboard the counts, with the shared token as a cooki
const s = await r.json(); const s = await r.json();
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number'); for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
expect(s.version).toMatch(/^\d+\.\d+\.\d+/); expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
// The logo names ipx's version and the page's, each its own (#207).
await expect(page.locator('#applogo')).toHaveAttribute('title', new RegExp(`^iPX ${s.version}, page \\d+\\.\\d+\\.\\d+`));
const none = await page.request.get('/api/status', { headers: { cookie: '' } }); const none = await page.request.get('/api/status', { headers: { cookie: '' } });
expect(none.status()).toBe(401); expect(none.status()).toBe(401);
}); });
@@ -1573,15 +1697,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => { test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' }); const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true)); // A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
// went by; the spinner comes once the check has taken a moment (#232).
const atOnce = await page.evaluate(() => {
setScanning('test-show', true);
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
});
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
await expect(row).toHaveClass(/\bscanning\b/); await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden(); await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false)); await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/); await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible(); await expect(row.locator('.badge')).toBeVisible();
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
// scan's end, or the end of a scan of one other feed, leaves it turning.
const spins = () => document.body.classList.contains('scan-any');
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
return a && document.body.classList.contains('scan-any'); })).toBe(true);
await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await page.evaluate(spins)).toBe(false);
// Checking every feed says so on the rows, not in a toast (issue #37). // Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click(); expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
await page.waitForTimeout(1500); 'turning the moment it is pressed').toBe(true);
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0); await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
}); });
@@ -1642,20 +1781,28 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
]); ]);
}); });
test('a refresh button turns while what it checks is being checked', async ({ page }) => { test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName); const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
const btn = '.fhead [data-a=scan] .i'; const btn = '.fhead [data-a=scan] .i';
await expect(page.locator(btn)).toBeVisible(); await expect(page.locator(btn)).toBeVisible();
expect(await spin(btn)).toBe('none'); expect(await spin(btn)).toBe('none');
await page.evaluate(() => setScanning('test-show', true)); // Pressed: this feed's button turns, the toolbar's does not (#78, #233).
await page.evaluate(() => spinStart('test-show'));
expect(await spin(btn)).toBe('ipxspin'); expect(await spin(btn)).toBe('ipxspin');
expect(await spin('#scanAll .i')).toBe('ipxspin'); expect(await spin('#scanAll .i')).toBe('none');
// Another feed being checked turns the toolbar's, not this page's. // Feeds being checked one by one no longer start or stop it (#231).
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); }); await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
expect(await spin(btn)).toBe('ipxspin');
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
expect(await spin(btn)).toBe('ipxspin');
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
expect(await spin(btn)).toBe('none'); expect(await spin(btn)).toBe('none');
// The toolbar's turns for a scan of every feed, until that scan ends.
await page.evaluate(() => spinStart(null));
expect(await spin('#scanAll .i')).toBe('ipxspin'); expect(await spin('#scanAll .i')).toBe('ipxspin');
await page.evaluate(() => setScanning('multi-show', false)); await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await spin('#scanAll .i')).toBe('none'); expect(await spin('#scanAll .i')).toBe('none');
}); });
@@ -1789,6 +1936,36 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
await page.locator('#ssave').click(); await page.locator('#ssave').click();
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual); await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
// Someone else on the same show at the usual pause, at the same address at the same time: one
// file on disk, a playlist each, and neither kept by a cache to be handed to the other.
{
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
try { execFileSync('./target/debug/ipx', ['user', 'add', 'quinn'], { input: 'quinnpassword', env }); }
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
const ctx = await page.context().browser().newContext();
const quinn = await ctx.newPage();
await quinn.goto('/login');
await quinn.locator('#name').fill('quinn');
await quinn.locator('#pw').fill('quinnpassword');
await quinn.locator('button[type=submit]').click();
await expect(quinn.locator('#feedlist')).toBeVisible();
await quinn.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
const enc = (await entry()).enclosures[0].id;
const fetchList = async p => { const r = await p.request.get(`/media/${enc}/deadair.m3u8`);
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
const length = l => l.body.match(/#EXTINF:([\d.]+)/g).reduce((s, x) => s + Number(x.slice(8)), 0);
expect([mine.status, theirs.status]).toEqual([200, 200]);
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
expect(mine.body).not.toBe(theirs.body);
expect(length(theirs), 'the usual pause cuts more than the longer one').toBeLessThan(length(mine));
await quinn.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
await ctx.close();
}
// Saved without its dead air too (#186): smaller than the original, named to say so. // Saved without its dead air too (#186): smaller than the original, named to say so.
const saved = await page.evaluate(async id => { const saved = await page.evaluate(async id => {
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]); const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);

View File

@@ -6,6 +6,9 @@ const os = require('os');
const root = path.join(os.tmpdir(), 'ipx-ui-test'); const root = path.join(os.tmpdir(), 'ipx-ui-test');
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
// The suite's own directory service (#205), and this daemon's token for it, which may do anything.
const DIRECTORY = 'http://127.0.0.1:8795';
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts // Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
// webServer *before* globalSetup, so a config written there does not exist yet when the // webServer *before* globalSetup, so a config written there does not exist yet when the
@@ -29,13 +32,17 @@ download_dir = "${path.join(root, 'downloads')}"
socket = "${path.join(root, 'ipx.sock')}" socket = "${path.join(root, 'ipx.sock')}"
schedule = "every 60m" schedule = "every 60m"
max_new_per_check = 1 max_new_per_check = 1
directory_url = "${DIRECTORY}"
directory_token = "${DIRECTORY_TOKEN}"
directory_share = true
[torrent] [torrent]
enabled = false enabled = false
[web] [web]
enabled = true enabled = true
bind = "127.0.0.1:8791" # Behind Caddy, which serves the pages on 8791 and passes the API here (#203).
bind = "127.0.0.1:8796"
token = "${TOKEN}" token = "${TOKEN}"
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1. # The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
trusted_header = "X-Test-User" trusted_header = "X-Test-User"
@@ -64,7 +71,16 @@ auto_download = false
[feeds.paid-show] [feeds.paid-show]
url = "http://127.0.0.1:8792/paid.xml?auth=secret123" url = "http://127.0.0.1:8792/paid.xml?auth=secret123"
auto_download = false auto_download = false
`);
fs.writeFileSync(path.join(root, 'directory.toml'), `
bind = "127.0.0.1:8795"
database_url_env = "DIRECTORY_DATABASE_URL"
[[tokens]]
name = "test"
access = "rwa"
token_sha256 = "${require('crypto').createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
`); `);
} }
module.exports = { prepare, root, TOKEN }; module.exports = { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN };

View File

@@ -5,7 +5,9 @@
"target": "es2022", "target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"], "lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true, "noEmit": true,
"strict": false, // Strict throughout (#235, #236). $ assumes the element is there: the page check proves the
// ones wired at load exist, and a lookup that may find nothing says so with ?.
"strict": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["web/src/*.ts"] "include": ["web/src/*.ts"]

55
web/CHANGELOG.md Normal file
View File

@@ -0,0 +1,55 @@
# Changelog: ipx-web
The page, and the container that serves it (Caddy). ipx's changelog is
[crates/ipx/CHANGELOG.md](../crates/ipx/CHANGELOG.md), which was the page's too until 0.12.0, and the
directory's [crates/directory/CHANGELOG.md](../crates/directory/CHANGELOG.md).
Its version is in `package.json`, and a release is tagged `web-vX.Y.Z`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.2] - 2026-10-15
### Fixed
- The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233).
- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232).
### Security
- The ipx-web image no longer carries curl, which Caddy's base image shipped and nothing used: 8.22.0 there is affected by CVE-2026-92392 (#237).
## [1.0.1] - 2026-10-13
### Fixed
- A live update or a list refresh that fails because the network is gone (a laptop asleep) is no longer reported as an error, an error page from Cloudflare no longer becomes a request's message, and requests a minute or more after a tap start a trace of their own instead of joining that tap's (#219).
## [1.0.0] - 2026-10-12
### Added
- With a directory token that may administer it, an admin has Check now and Remove from the directory on a Directory feed's page, and Servers and Blocked lists in Admin, Directory (#206).
- The page has a version of its own, which the logo's tooltip shows beside ipx's (#207).
- The page is served by its own container, ipx-web (Caddy), which passes the API to ipx on the same address, so a change to the page deploys without rebuilding ipx (#203).
- Admin, Directory sets the directory's address and this server's token, and whether to share, and says how the last share went (#205).
- A feed's settings have a Private switch, which keeps it out of the directory; on and locked for a feed whose address or login shows it is private (#205).
- ipx-web logs one line a request, without the request's headers or a sign-in token in the address (#210).
- ipx-web sends a trace span for each request when `WEB_TRACING=on`, continuing the page's trace into ipx (#213).
### Changed
- The Directory says when no directory is set up, and its counts are of everyone on every sharing server (#205).
- Add a feed no longer asks for a folder; a feed's folder is its title (#201).
### Fixed
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...main
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0

119
web/Caddyfile Normal file
View File

@@ -0,0 +1,119 @@
# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin,
# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it.
# IPX_UPSTREAM where ipx is, host:port (ipx:8099)
# WEB_BIND where to listen (:8080)
# WEB_TRACING on: a span for each request to OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_SERVICE_NAME), continuing the page's traceparent
# and passing it to ipx, so one trace runs browser, Caddy, ipx (#213). Off: none,
# and no export failing every few seconds where there is nothing to send to.
# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's
# requests come from. Nobody else's are, so nobody else can claim to be signed in
# by Cloudflare. Unset passes none.
{
admin off
auto_https off
# Everything else Caddy logs, its errors among them, which carry the request too: the same
# filter as the access log's below.
log default {
output stderr
format filter {
wrap json
fields {
request>uri query {
delete token
}
request>headers delete
}
}
}
}
(tracing-on) {
tracing {
span ipx-web
}
}
(tracing-off) {
}
http://{$WEB_BIND::8080} {
import tracing-{$WEB_TRACING:off}
root * {$WEB_ROOT:/srv}
# One JSON line a request on stdout, for Loki and the dashboard's Page row (#210). Not the
# shared token a ?token= link carries, nor the request's headers, which hold the session
# cookie and Cloudflare Access's token.
log {
output stdout
format filter {
wrap json
fields {
request>uri query {
delete token
}
request>headers delete
}
}
}
# Before anything else, so no page or API call sees them: only ipx sets these.
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
request_header @forged -Cf-Access-Authenticated-User-Email
request_header @forged -Cf-Access-Jwt-Assertion
@api path /api/* /media/*
handle @api {
# Live updates are a stream: flushed as they come, never buffered.
reverse_proxy {$IPX_UPSTREAM:ipx:8099} {
flush_interval -1
}
}
# Signing in with the shared token in the address (?token=): ipx sets the session cookie and
# sends the browser on to the page without it.
@tokened {
path / /admin
query token=*
}
handle @tokened {
rewrite * /api/signin?{query}
reverse_proxy {$IPX_UPSTREAM:ipx:8099}
}
# The app and the admin page go to whoever ipx says may have them: a browser not signed in is
# sent to /login, anyone but an admin asking for /admin to the app.
@page path / /index.html /admin /admin.html /admin.js
handle @page {
forward_auth {$IPX_UPSTREAM:ipx:8099} {
uri /api/page-check
}
rewrite / /index.html
rewrite /admin /admin.html
header Cache-Control no-cache
file_server
}
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
@login path /login /login.html
handle @login {
forward_auth {$IPX_UPSTREAM:ipx:8099} {
uri /api/login-check
}
rewrite * /login.html
header Cache-Control no-cache
file_server
}
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
# keeps them a year; the icons a day, under names that do not change.
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
header @hashed Cache-Control "public, max-age=31536000, immutable"
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
header /favicon.ico Content-Type image/png
@icons path *.png *.svg *.ico *.woff2
header @icons Cache-Control "public, max-age=86400"
handle {
file_server
}
}

View File

@@ -9,9 +9,10 @@
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png"> <link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css"> <link rel="stylesheet" data-src="app.css">
<script src="/boot.js"></script>
</head> </head>
<body class="adminpage"> <body class="adminpage">
<!-- The server sends this page, and its script, to admins only. --> <!-- Caddy sends this page, and its script, to admins only: it asks ipx first (/api/page-check). -->
<header id="topbar"> <header id="topbar">
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a> <a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26"> <img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
@@ -20,12 +21,14 @@
<nav class="tabs" id="atabs"> <nav class="tabs" id="atabs">
<a href="#server" data-t="server">Server</a> <a href="#server" data-t="server">Server</a>
<a href="#accounts" data-t="accounts">Accounts</a> <a href="#accounts" data-t="accounts">Accounts</a>
<a href="#directory" data-t="directory">Directory</a>
<a href="#log" data-t="log">Log</a> <a href="#log" data-t="log">Log</a>
</nav> </nav>
</header> </header>
<main class="wrap plain" id="admin"> <main class="wrap plain" id="admin">
<section id="server" hidden></section> <section id="server" hidden></section>
<section id="accounts" hidden></section> <section id="accounts" hidden></section>
<section id="directory" hidden></section>
<section id="log" hidden></section> <section id="log" hidden></section>
</main> </main>
<div id="toasts"></div> <div id="toasts"></div>

View File

@@ -888,8 +888,13 @@ body.adminpage #topbar{position:sticky;top:0;z-index:5}
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650} body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
body.adminpage #topbar .logo{border-radius:6px} body.adminpage #topbar .logo{border-radius:6px}
body.adminpage #topbar > a.btn{text-decoration:none} body.adminpage #topbar > a.btn{text-decoration:none}
/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head
script marks an admin's from the last visit before the first frame. */
html:not([data-admin]) #topbar a#admin{display:none}
body.adminpage #admin{max-width:780px;margin:0 auto} body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px} body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */
body.adminpage #dadmin h2{margin-top:32px}
body.adminpage #log{max-width:none} body.adminpage #log{max-width:none}
.urow{padding:9px 0;border-bottom:1px solid var(--line)} .urow{padding:9px 0;border-bottom:1px solid var(--line)}
.toolbar .grow{flex:1;min-width:150px;max-width:320px} .toolbar .grow{flex:1;min-width:150px;max-width:320px}
@@ -1324,6 +1329,9 @@ body:has(#player.on) #status{padding-bottom:3px}
/* Seven to a row with no room to spare at 390px: below that an icon button narrows rather than /* Seven to a row with no room to spare at 390px: below that an icon button narrows rather than
the row breaking, which left Unsubscribe alone on a second row (#146). */ the row breaking, which left Unsubscribe alone on a second row (#146). */
.acts .btn.ico{flex:0 1 44px;min-width:0;padding:0} .acts .btn.ico{flex:0 1 44px;min-width:0;padding:0}
/* A Directory feed's few buttons have room, and nothing sizes their row: allowed to shrink,
they went to 18px (#212). */
.show .acts .btn.ico{flex:0 0 44px}
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */ /* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
.tabs{flex:1 1 100%;border-radius:10px;padding:2px} .tabs{flex:1 1 100%;border-radius:10px;padding:2px}

6
web/boot.js Normal file
View File

@@ -0,0 +1,6 @@
// Runs in <head>, before the first frame (#203): this browser's theme, from its cookie, and
// whether the last visit here was an admin's, so the link to Admin is there from the start. The
// server wrote both into the page while it served it; the page is a static file now.
(()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie);
if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];}
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();

View File

@@ -1,12 +1,12 @@
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified // Builds the frontend (#203): each page's TypeScript from web/src, types stripped and minified
// by swc into a script of its own (app.js, login.js), and the page minified, its // by swc into a script of its own (app.js, login.js), the page minified, its <script data-src>
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs // pointing at that script, and the icons, logos and typeface beside them. The result, web/dist,
// include_str!s the results, so the binary still carries everything and nothing is served // is a static site: Caddy serves it (web/Caddyfile, Dockerfile.web) and passes the API to ipx.
// from disk.
// //
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the // The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
// server lets a browser keep that for a year without asking again. A changed script is a new // server lets a browser keep that for a year without asking again. A changed script is a new
// URL, and the page, which the browser checks on every visit, is what carries it. // URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
// server ignores the query.
// //
// node web/build.mjs [out-dir] default out-dir: web/dist // node web/build.mjs [out-dir] default out-dir: web/dist
import crypto from 'node:crypto'; import crypto from 'node:crypto';
@@ -17,6 +17,8 @@ import swc from '@swc/core';
import html from '@swc/html'; import html from '@swc/html';
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
// The page's own version (package.json), given to its script in place of __WEB_VERSION__.
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
// The files are one script, concatenated in this order, not modules: they share one top-level // The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it. // scope, as the single inline script did, and code that runs at load needs what came before it.
@@ -57,7 +59,8 @@ export function buildStyle({ minify = true } = {}) {
export function buildPage(name, { minify = true } = {}) { export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name]; const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n') const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_URL); .replaceAll('__HLS_JS__', HLS_URL)
.replaceAll('__WEB_VERSION__', WEB_VERSION);
const js = swc.transformSync(src, { const js = swc.transformSync(src, {
filename: name + '.ts', filename: name + '.ts',
jsc: { jsc: {
@@ -81,7 +84,7 @@ export function buildPage(name, { minify = true } = {}) {
// The icons are named by their contents too. They are kept a day under a fixed name, and a new // The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the // logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one. // query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g, out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`); (_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
if (!minify) return { html: out, js, script }; if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true }); const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
@@ -95,6 +98,16 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle()); fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'hls.js'), HLS); fs.writeFileSync(path.join(out, 'hls.js'), HLS);
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
// first when the site is added to a home screen (#109). /icon.png is the 2004 icon, which
// nothing here shows any more but the classic theme.
for (const [from, to] of [
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
for (const name of Object.keys(PAGES)) { for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name); const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html); fs.writeFileSync(path.join(out, name), html);

View File

@@ -9,13 +9,14 @@
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png"> <link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css"> <link rel="stylesheet" data-src="app.css">
<script src="/boot.js"></script>
</head> </head>
<body> <body>
<header id="topbar"> <header id="topbar">
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button> <button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item. <!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
A phone hides the item group, which it has no table for. --> A phone hides the item group, which it has no table for. -->
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span> <span id="applogo" title="iPX"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
<div class="tgroup"> <div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button> <button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button> <button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>

View File

@@ -7,7 +7,7 @@
let me: {name: string} | null = null; let me: {name: string} | null = null;
api('/api/me').then(u => { me = u; }).catch(() => {}); api('/api/me').then(u => { me = u; }).catch(() => {});
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView}; const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, directory: drawDirectorySettings, log: drawLogView};
function showSection(){ function showSection(){
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server'; const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
@@ -56,24 +56,128 @@ async function drawServer(){
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`; <div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
$('#gsave').onclick = async () => { $<HTMLButtonElement>('#gsave').onclick = async () => {
try{ try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0, max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0, max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})}); art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0)})});
toast('Settings saved'); toast('Settings saved');
}catch(e){ toast(e.message, true); } }catch(e){ toast(why(e), true); }
};
}
/* ---------------- directory ---------------- */
// The directory service the Directory shows (#205): where it is, this server's token for it, and
// whether to share. The token is sent, never shown: the server says only whether one is set.
const ACCESS: Record<string, string> = {ro: 'browse', rw: 'browse and share', rwa: 'browse, share and administer the directory'};
async function drawDirectorySettings(){
const box = $('#directory');
const g = await api('/api/settings');
box.innerHTML = `<h2>Directory</h2>
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share.
Without one, people add feeds by their address.</p>
<div class="field"><label for="durl">Directory address</label>
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
<div class="field"><label for="dtoken">Access token</label>
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>
<div id="dadmin"></div>`;
const save = async (patch: Record<string, unknown>, said: string) => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
toast(said);
drawDirectorySettings();
}catch(e){ toast(why(e), true); }
};
$<HTMLButtonElement>('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it.
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
save(patch, 'Directory settings saved');
};
const clear = $<HTMLButtonElement>('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
drawDirectoryStatus();
}
async function drawDirectoryStatus(){
const el = $('#dstatus');
let s;
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = why(e); return; }
if(!el.isConnected) return;
const lines: string[] = [];
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
else if(s.error) lines.push(esc(s.error));
else lines.push(`Connected as <b>${esc(s.name)}</b>, which may ${esc(ACCESS[s.access] || s.access)}.`);
const sh = s.shared;
if(sh){
const when = new Date(sh.at * 1000).toLocaleString();
lines.push(sh.error ? `Sharing failed at ${esc(when)}: ${esc(sh.error)}`
: `Shared ${plural(sh.accepted, 'feed')} at ${esc(when)}.` +
(sh.refused_private ? ` The directory refused ${plural(sh.refused_private, 'feed')} as private.` : '') +
(sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : ''));
}
el.innerHTML = lines.join('<br>');
if(s.access === 'rwa') drawDirectoryAdmin();
}
/// What a token that may administer the directory can change from here (#206): the servers
/// sharing with it, and the feeds removed from it.
async function drawDirectoryAdmin(){
const box = $('#dadmin');
let a: DirAdmin;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
if(!box.isConnected) return;
box.innerHTML = `<h2>Servers</h2>
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its
feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.</p>
${a.instances.length ? a.instances.map(i => `<div class="inline urow" data-name="${esc(i.name)}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(i.name)}</b>
<small style="display:block;color:var(--faint)">${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
<button class="btn ico danger" data-a="drop" title="Drop ${esc(i.name)}'s feeds" aria-label="Drop ${esc(i.name)}'s feeds">${ICON.trash}</button></div>`).join('')
: '<p class="hint">No server is sharing yet.</p>'}
<h2>Blocked</h2>
<p class="hint">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
${a.blocked.length ? a.blocked.map(b => `<div class="inline urow" data-id="${esc(b.id)}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(b.title || 'Untitled')}</b>
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="hint">Nothing is blocked.</p>'}`;
box.onclick = async (ev: MouseEvent) => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return;
const row = b.closest<HTMLElement>('.urow');
try{
if(b.dataset.a === 'drop'){
const name = row!.dataset.name!;
if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return;
await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'});
toast(`Dropped ${name}'s snapshot`);
}else if(b.dataset.a === 'unblock'){
await api(`/api/directory-blocked/${encodeURIComponent(row!.dataset.id!)}`, {method: 'DELETE'});
toast('Unblocked. It comes back with the next snapshot that has it');
}
drawDirectoryAdmin();
}catch(e){ toast(why(e), true); }
}; };
} }
/* ---------------- accounts ---------------- */ /* ---------------- accounts ---------------- */
async function drawAccounts(){ async function drawAccounts(){
const box = $('#accounts'); const box = $('#accounts');
const users = await api('/api/users') || []; const users: Account[] = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2> box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}"> ${users.map(u => `<div class="inline urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b> <div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
@@ -91,44 +195,44 @@ async function drawAccounts(){
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in <span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div> through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`; <div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => { const change = async (u: Account, opts: RequestInit) => {
try{ try{
await api(`/api/users/${u.id}`, opts); await api(`/api/users/${u.id}`, opts);
// Demoting yourself takes this page away; go back to the app rather than stay on a page // Demoting yourself takes this page away; go back to the app rather than stay on a page
// the server no longer answers. Only on success: a refusal's toast has to stay readable. // the server no longer answers. Only on success: a refusal's toast has to stay readable.
if(u.name === me?.name){ location.href = '/'; return; } if(u.name === me?.name){ location.href = '/'; return; }
}catch(e){ toast(e.message, true); } }catch(e){ toast(why(e), true); }
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
}; };
for(const row of $$('#accounts [data-id]')){ for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id); const u = users.find(x => String(x.id) === row.dataset.id)!;
$('[data-a="admin"]', row).onchange = e => $('[data-a="admin"]', row).onchange = (e: Event) =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).checked})});
$('[data-a="rm"]', row).onclick = () => { $('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'}); change(u, {method: 'DELETE'});
}; };
} }
$('#uadd').onclick = async () => { $<HTMLButtonElement>('#uadd').onclick = async () => {
try{ try{
await api('/api/users', {method: 'POST', body: JSON.stringify({ await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#uadmin').checked})});
toast('Added'); drawAccounts(); toast('Added'); drawAccounts();
}catch(e){ toast(e.message, true); } // keep what was typed }catch(e){ toast(why(e), true); } // keep what was typed
}; };
} }
/* ---------------- log ---------------- */ /* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all'; let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every // Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it. // command in and every event out, whatever sent it.
const LOG_TABS = { const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
all: null, all: null,
daemon: t => t === 'ipx::io', daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan', scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http', web: t => t === 'ipx::http',
}; };
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){ function drawLogView(){
logSeq = 0; logLines = []; logSeq = 0; logLines = [];
@@ -155,14 +259,14 @@ function drawLogView(){
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`; what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => { for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t; logTab = b.dataset.t!;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog(); drawLog();
}; };
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; $<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); }; $<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l => $<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#logcopy'));
pollLog(); pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000); if(!logTimer) logTimer = setInterval(pollLog, 2000);
} }
@@ -177,7 +281,7 @@ async function pollLog(){
}else if(!logLines.length){ drawLog(); } }else if(!logLines.length){ drawLog(); }
}catch(e){ }catch(e){
const box = $('#logbox'); const box = $('#logbox');
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`; if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(why(e))}</p>`;
} }
} }
function visibleLog(){ function visibleLog(){
@@ -190,7 +294,7 @@ function visibleLog(){
} }
function drawLog(){ function drawLog(){
const box = $('#logbox'); if(!box) return; const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked; const follow = $<HTMLInputElement>('#logfollow')?.checked;
const rows = visibleLog(); const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => { box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString(); const t = new Date(l.ts * 1000).toLocaleTimeString();

View File

@@ -9,39 +9,41 @@ function closeModal(){
$('#modal').classList.remove('on'); $('#modal').classList.remove('on');
} }
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#modal').onclick=(e: MouseEvent)=>{ if((e.target as Element).id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{ $<HTMLButtonElement>('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3> openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss"> <div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div> <span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
<div class="field"><label>Keywords (optional, comma separated)</label> <div class="field"><label>Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div> <input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label> <label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`); <button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
$('#nurl').focus(); $<HTMLInputElement>('#nurl').focus();
$('#nsave').onclick=async()=>{ $<HTMLButtonElement>('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return; const url=$<HTMLInputElement>('#nurl').value.trim(); if(!url) return;
$('#nsave').disabled=true; $('#nsave').title='Adding…'; $<HTMLButtonElement>('#nsave').disabled=true; $<HTMLButtonElement>('#nsave').title='Adding…';
try{ try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked, url, allow_explicit:$<HTMLInputElement>('#nexp').checked,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); keywords:$<HTMLInputElement>('#nkw').value.split(',').map((s: string)=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id); await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; } }catch(e){ toast(why(e),true); $<HTMLButtonElement>('#nsave').title='Add feed'; $<HTMLButtonElement>('#nsave').disabled=false; }
}; };
}; };
// The rows carry an id, never a URL, so a key in someone's feed address never reaches this page. // The rows come from the directory through iPX (#205), and carry its id, never a URL, so a key in
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>'; // someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing in the directory yet. Feeds people subscribe to show up here once their servers share them.</p>';
// Why the directory could not be shown: none set up, or it did not answer. In iPX's words.
let dirError='';
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The /// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its /// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
/// page in the Directory, to see what it is before you take it (#128). /// page in the Directory, to see what it is before you take it (#128).
function listedFeed(p,cls,rank?: number){ function listedFeed(p: DirFeed,cls: string,rank?: number){
const el=document.createElement('div'); const el=document.createElement('div');
el.className=cls+(p.subscribed?' mine':''); el.className=cls+(p.subscribed?' mine':'');
el.tabIndex=0; el.dataset.id=p.id; el.tabIndex=0; el.dataset.id=p.id;
@@ -58,49 +60,51 @@ function listedFeed(p,cls,rank?: number){
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>` (p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`); :`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
if(p.subscribed){ el.onclick=()=>selectFeed(p.id); return el; } // Yours has an id here as well as the directory's, and opens by it.
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); }; el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well. // Its own click, not the tile's as well.
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); }; $('[data-a="sub"]',el).onclick=(ev: Event)=>{ ev.stopPropagation(); subscribeListed(p); };
return el; return el;
} }
async function subscribeListed(p){ async function subscribeListed(p: DirFeed){
try{ try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`); toast(`Subscribed to ${NAME(p)}`);
await loadFeeds(true); selectFeed(p.id); await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
} }
// Where you are in the Directory: what kind of feed, which category and subcategory, in what // Where you are in the Directory: what kind of feed, which category and subcategory, in what
// order. Out here because a search redraws the page, and the kind and order hold across visits. // order. Out here because a search redraws the page, and the kind and order hold across visits.
let dirKind='All', dirCat=null, dirSub=null, dirSort='az', dirRows=[]; let dirKind='All', dirCat: string|null=null, dirSub: string|null=null, dirSort='az', dirRows: DirFeed[]=[];
// The feed whose page is open over all that, by id; going back leaves the rest as it was. // The feed whose page is open over all that, by id; going back leaves the rest as it was.
let dirShow=null; let dirShow: string|null=null;
// The two pages that are not a category: every feed, and the feeds that name none. A category is // The two pages that are not a category: every feed, and the feeds that name none. A category is
// Apple's or one an admin typed, neither of which starts with a colon. // Apple's or one an admin typed, neither of which starts with a colon.
const EVERY=':every', OTHER=':other'; const EVERY=':every', OTHER=':other';
const NAME=p=>(p.title||p.id); const NAME=(p: {title: string|null, id: string})=>(p.title||p.id);
const byName=(a,b)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true}); const byName=(a: DirFeed,b: DirFeed)=>NAME(a).localeCompare(NAME(b),undefined,{sensitivity:'base',numeric:true});
const bySubs=(a,b)=>b.subscribers-a.subscribers||byName(a,b); const bySubs=(a: DirFeed,b: DirFeed)=>b.subscribers-a.subscribers||byName(a,b);
/// The grid's orders. It opens A to Z, as the server sends it. /// The grid's orders. It opens A to Z, as the server sends it.
const DIR_SORTS={ const DIR_SORTS: Record<string,[string,(a: DirFeed,b: DirFeed)=>number]>={
subscribers:['Most subscribers',bySubs], subscribers:['Most subscribers',bySubs],
az:['Name, A to Z',byName], az:['Name, A to Z',byName],
za:['Name, Z to A',(a,b)=>byName(b,a)], za:['Name, Z to A',(a: DirFeed,b: DirFeed)=>byName(b,a)],
}; };
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; const KINDS: Record<string,(p: DirFeed)=>boolean>={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'}; const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blog'};
/// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the /// The Directory, as Apple's is laid out: it opens on the most subscribed feeds and the
/// categories, and a category has a page of its own, its subcategories along the top of its /// categories, and a category has a page of its own, its subcategories along the top of its
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across /// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
/// every page, and the search box looks through the lot. One fetch draws all of it. /// every page, and the search box looks through the lot. One fetch draws all of it.
async function renderDirectory(url,box){ async function renderDirectory(url: string,box: HTMLElement){
// Each visit starts at the front, as going back to a tab does. // Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null; dirCat=null; dirSub=null; dirShow=null;
try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; } dirError='';
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=why(e); }
drawDirectory(box); drawDirectory(box);
return dirRows.length; return dirRows.length;
} }
@@ -108,29 +112,29 @@ async function renderDirectory(url,box){
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn. /// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
function drawDirectory(box=$('#directory'), focus?: string){ function drawDirectory(box=$('#directory'), focus?: string){
if(!box) return; if(!box) return;
if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; } if(!dirRows.length){ box.innerHTML=dirError?`<p class="hint">${esc(dirError)}</p>`:NONE_LISTED; return; }
// Only a choice when the server has both kinds. // Only a choice when the server has both kinds.
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs); const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All'; if(!both) dirKind='All';
const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind]; const rows=dirRows.filter(KINDS[dirKind]), word=KIND_WORD[dirKind];
const count=n=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`; const count=(n: number)=>`${n.toLocaleString()} ${word}${n===1?'':'s'}`;
const of=c=>p=>c===EVERY||(c===OTHER?!p.category:p.category===c); const of=(c: string)=>(p: DirFeed)=>c===EVERY||(c===OTHER?!p.category:p.category===c);
// A kind can leave a category with nothing in it; that goes back to the front. // A kind can leave a category with nothing in it; that goes back to the front.
if(dirCat&&!rows.some(of(dirCat))) dirCat=null; if(dirCat&&!rows.some(of(dirCat))) dirCat=null;
const catName=c=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c; const catName=(c: string)=>c===EVERY?`All ${word}s`:c===OTHER?'Other':c;
const top=list=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10); const top=(list: DirFeed[])=>list.filter(p=>p.subscribers).sort(bySubs).slice(0,10);
const btn=(k,v,label,on)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`; const btn=(k: string,v: string,label: string,on: boolean)=>`<button type="button" data-${k}="${esc(v)}" aria-pressed="${on}"${on?' class="on"':''}>${label}</button>`;
// back: where its button goes, '' for the front; none on the front itself. // back: where its button goes, '' for the front; none on the front itself.
const head=(title,sub,back?)=>`<div class="dhead">${back==null?'': const head=(title: string,sub: string,back?: string)=>`<div class="dhead">${back==null?'':
`<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`} `<button type="button" class="dback" data-go="${esc(back)}">${ICON.left}${esc(back?catName(back):'Directory')}</button>`}
<div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${ <div class="dtitle"><h2>${esc(title)}</h2>${both?`<div class="tabs" role="group" aria-label="Kind">${
Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div> Object.keys(KINDS).map(k=>btn('kind',k,k,k===dirKind)).join('')}</div>`:''}</div>
<p class="sub">${esc(sub)}</p></div>`; <p class="sub">${esc(sub)}</p></div>`;
const sec=(title,body,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`; const sec=(title: string,body: string,extra='')=>`<section class="dsec">${title?`<div class="dsech"><h3>${esc(title)}</h3>${extra}</div>`:''}${body}</section>`;
const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${ const tools=(pills='')=>`<div class="dtools">${pills||'<span class="grow"></span>'}<select id="dirsort" aria-label="Sort">${
Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`; Object.entries(DIR_SORTS).map(([k,[label]])=>`<option value="${k}"${k===dirSort?' selected':''}>${label}</option>`).join('')}</select></div>`;
const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>'; const q=S.q.trim(), grid='<div class="tiles" id="dtiles"></div>', board='<div class="chart" id="dchart"></div>';
let html, chart=[], tiles=null; let html: string, chart: DirFeed[]=[], tiles: DirFeed[]|null=null;
const show=dirShow&&dirRows.find(p=>p.id===dirShow); const show=dirShow&&dirRows.find(p=>p.id===dirShow);
if(show){ if(show){
const n=show.subscribers; const n=show.subscribers;
@@ -138,7 +142,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${ <div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
// Its category's page is one click away from here. // Its category's page is one click away from here.
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''} show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''} ${n?`<p class="sub">${n} ${n===1?'person subscribes':'people subscribe'}</p>`:''}
<p class="sub dfail" id="dfail" hidden></p> <p class="sub dfail" id="dfail" hidden></p>
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div> <div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>` <div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
@@ -150,21 +154,21 @@ function drawDirectory(box=$('#directory'), focus?: string){
html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid; html=head(`Results for “${q}”`,tiles.length?count(tiles.length):`No ${word} here has that in its name.`,dirCat||'')+tools()+grid;
}else if(!dirCat){ }else if(!dirCat){
chart=top(rows); chart=top(rows);
const cats=new Map<string,any[]>(); const cats=new Map<string,DirFeed[]>();
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); } for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c)!.push(p); }
// Other, the feeds with no category, goes last. // Other, the feeds with no category, goes last.
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b)); const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
html=head('Directory',`${count(rows.length)} on this server. Private feeds are never listed.`) html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
+(chart.length?sec('Most subscribed',board):'') +(chart.length?sec('Most subscribed',board):'')
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`, +sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c)!.length),cats.get(c)!)).join('')}</div>`,
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`); `<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
}else{ }else{
const inCat=rows.filter(of(dirCat)); const inCat=rows.filter(of(dirCat));
const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter(Boolean))].sort(); const subs=dirCat===EVERY?[]:[...new Set(inCat.map(p=>p.subcategory).filter((s): s is string=>!!s))].sort();
if(!subs.includes(dirSub)) dirSub=null; if(!dirSub||!subs.includes(dirSub)) dirSub=null;
if(dirCat!==EVERY) chart=top(inCat); if(dirCat!==EVERY) chart=top(inCat);
tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub); tiles=inCat.filter(p=>!dirSub||p.subcategory===dirSub);
const pill=(v,label,n)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v); const pill=(v: string,label: string,n: number)=>btn('sub',v,`${esc(label)}<small>${n}</small>`,(dirSub||'')===v);
html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'') html=head(catName(dirCat),dirCat===OTHER?`${count(inCat.length)} that name no category`:count(inCat.length),'')
+(chart.length?sec('Most subscribed',board):'') +(chart.length?sec('Most subscribed',board):'')
// Its own heading only when the chart is above it, to say where one list ends. // Its own heading only when the chart is above it, to say where one list ends.
@@ -174,25 +178,25 @@ function drawDirectory(box=$('#directory'), focus?: string){
box.innerHTML=html; box.innerHTML=html;
if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); } if(show){ $('.show [data-a="sub"]',box).onclick=()=>subscribeListed(show); fillShow(box,show); }
const c=$('#dchart',box); const c=$('#dchart',box);
if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p,i)=>c.appendChild(listedFeed(p,'rank',i+1))); } if(c){ c.style.setProperty('--rows',String(Math.ceil(chart.length/2))); chart.forEach((p: DirFeed,i: number)=>c.appendChild(listedFeed(p,'rank',i+1))); }
const t=$('#dtiles',box); const t=$('#dtiles',box);
if(t) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile')); if(t&&tiles) for(const p of tiles.sort(DIR_SORTS[dirSort][1])) t.appendChild(listedFeed(p,'tile'));
box.onclick=ev=>{ box.onclick=(ev: MouseEvent)=>{
const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]'); const b=(ev.target as Element).closest<HTMLElement>('[data-go],[data-kind],[data-sub],[data-back]');
if(!b) return; if(!b) return;
// Back from a feed's page, onto the tile or row it was opened from. // Back from a feed's page, onto the tile or row it was opened from.
if(b.dataset.back!=null){ const id=dirShow; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); } if(b.dataset.back!=null){ const id=dirShow??''; dirShow=null; return drawDirectory(box,`[data-id="${CSS.escape(id)}"]`); }
if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); } if(b.dataset.kind!=null){ dirKind=b.dataset.kind; return drawDirectory(box,`[data-kind="${CSS.escape(dirKind)}"]`); }
if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); } if(b.dataset.sub!=null){ dirSub=b.dataset.sub||null; return drawDirectory(box,`[data-sub="${CSS.escape(b.dataset.sub)}"]`); }
// Going anywhere ends a search. // Going anywhere ends a search.
if(S.q){ S.q=''; $('#epSearch').value=''; } if(S.q){ S.q=''; $<HTMLInputElement>('#epSearch').value=''; }
const from=dirCat; const from=dirCat;
dirCat=b.dataset.go||null; dirSub=null; dirShow=null; dirCat=b.dataset.go||null; dirSub=null; dirShow=null;
$('#content').scrollTop=0; $('#content').scrollTop=0;
// Into a page, onto its way back; back out, onto the tile you went in by. // Into a page, onto its way back; back out, onto the tile you went in by.
drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined); drawDirectory(box,dirCat?'.dback':from?`[data-go="${CSS.escape(from)}"]:not(.dback)`:undefined);
}; };
const s=$('#dirsort',box); const s=$<HTMLSelectElement>('#dirsort',box);
if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); }; if(s) s.onchange=()=>{ dirSort=s.value; drawDirectory(box,'#dirsort'); };
if(focus) $(focus,box)?.focus(); if(focus) $(focus,box)?.focus();
} }
@@ -200,11 +204,11 @@ function drawDirectory(box=$('#directory'), focus?: string){
/// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the /// A listed feed's page, filled in: what it says it is, its first lines unless you ask for the
/// rest, and its latest items, each one's title, opening its post on the publisher's site where /// rest, and its latest items, each one's title, opening its post on the publisher's site where
/// it has one, its first lines, and when and how long. /// it has one, its first lines, and when and how long.
async function fillShow(box,p){ async function fillShow(box: HTMLElement,p: DirFeed){
const list=$('#dshow',box), about=$('#dabout',box); const list=$('#dshow',box), about=$('#dabout',box);
let got; let got: {description: string|null, failing: string|null, items: ListedItem[], can_admin: boolean};
try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); } try{ got=await api(`/api/directory/${encodeURIComponent(p.id)}`); }
catch(e){ list.innerHTML=`<p class="hint">${esc(e.message)}</p>`; return; } catch(e){ list.innerHTML=`<p class="hint">${esc(why(e))}</p>`; return; }
// Gone back, or on to another, while it loaded. // Gone back, or on to another, while it loaded.
if(dirShow!==p.id||!list.isConnected) return; if(dirShow!==p.id||!list.isConnected) return;
// Some feeds give their title again as their description, which says nothing new. // Some feeds give their title again as their description, which says nothing new.
@@ -221,6 +225,25 @@ async function fillShow(box,p){
} }
// A broken feed says so, or it reads as a quiet one (#142). // A broken feed says so, or it reads as a quiet one (#142).
if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; } if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; }
// An admin whose token may administer the directory (#206); once, as Refresh fills it again.
const acts=$('.show .acts',box);
if(got.can_admin&&!$('[data-a="dremove"]',acts)){
acts.insertAdjacentHTML('beforeend',
`<button class="btn ico" data-a="drefresh" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>`
+`<button class="btn ico danger" data-a="dremove" title="Remove from the directory" aria-label="Remove from the directory">${ICON.trash}</button>`);
$('[data-a="drefresh"]',acts).onclick=async()=>{
try{ await api(`/api/directory/${encodeURIComponent(p.id)}/refresh`,{method:'POST'}); toast('Checked'); fillShow(box,p); }
catch(e){ toast(why(e),true); }
};
$('[data-a="dremove"]',acts).onclick=async()=>{
if(!confirm(`Remove ${NAME(p)} from the directory? It is blocked, so no server's snapshot brings it back. Admin, Directory can unblock it.`)) return;
try{ await api(`/api/directory/${encodeURIComponent(p.id)}`,{method:'DELETE'}); }
catch(e){ return toast(why(e),true); }
toast(`Removed ${NAME(p)} from the directory`);
dirRows=dirRows.filter(r=>r.id!==p.id); dirShow=null;
drawDirectory(box);
};
}
const items=got.items; const items=got.items;
list.innerHTML=items.length?items.map(e=>{ list.innerHTML=items.length?items.map(e=>{
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500); const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
@@ -228,13 +251,12 @@ async function fillShow(box,p){
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>` return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
+(text?`<p>${esc(text)}</p>`:'') +(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>'; +(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
}).join(''):`<p class="hint">Nothing read from it yet.${p.subscribers||got.failing?'' }).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';
:' A feed nobody here subscribes to is checked once a day.'}</p>`;
} }
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed /// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
/// fanned in the corner, so each one is coloured by what is in it, as Apple's are. /// fanned in the corner, so each one is coloured by what is in it, as Apple's are.
function catTile(c,name,count,rows){ function catTile(c: string,name: string,count: string,rows: DirFeed[]){
// Covers before initials, which are all Other had; the likeliest first, and first is in front: // Covers before initials, which are all Other had; the likeliest first, and first is in front:
// the fan is drawn back to front. // the fan is drawn back to front.
const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse(); const art=[...rows].sort((a,b)=>Number(!a.image)-Number(!b.image)||bySubs(a,b)).slice(0,3).reverse();
@@ -243,20 +265,20 @@ function catTile(c,name,count,rows){
} }
/// The Directory and Currently Listening open in the main pane, as the original's Directory did. /// The Directory and Currently Listening open in the main pane, as the original's Directory did.
async function renderListed(v){ async function renderListed(v: {title: string, icon: string, url?: string, blurb?: string}){
const box=$('#content'); const box=$('#content');
box.classList.add('plain'); box.classList.add('plain');
$('#tbRemove').disabled=true; $<HTMLButtonElement>('#tbRemove').disabled=true;
syncTools(null); syncTools(null);
if(v===VIEWS[':directory']){ if(v===VIEWS[':directory']){
$('#epSearch').placeholder='Find a feed…'; $<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>'; box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
$('#count').textContent=v.title; $('#count').textContent=v.title;
const n=await renderDirectory(v.url,$('#directory')); const n=await renderDirectory(v.url!,$('#directory'));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
return; return;
} }
$('#epSearch').placeholder='Search items…'; $<HTMLInputElement>('#epSearch').placeholder='Search items…';
box.innerHTML=` box.innerHTML=`
<div class="fhead slim"> <div class="fhead slim">
<div class="art">${v.icon}</div> <div class="art">${v.icon}</div>
@@ -265,21 +287,21 @@ async function renderListed(v){
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`; <div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title; $('#count').textContent=v.title;
// The search box looks through these too, title and text, as it does a feed's items (#127). // The search box looks through these too, title and text, as it does a feed's items (#127).
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url,$('#listening',box)); const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
} }
/// Currently Listening: episodes you started and have not finished, across every feed you /// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to /// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse. The one in the player pauses instead. /// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){ async function renderListening(url: string,box: HTMLElement){
let rows=[]; let rows: Entry[]=[];
try{ rows=(await api(url)).entries||[]; }catch{} try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>` box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>'; :'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){ for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves. // Carries its episode, for paintListenRow to repaint as the player moves.
const el: HTMLDivElement & {entry?: any}=document.createElement('div'); const el: HTMLDivElement & {entry?: Entry}=document.createElement('div');
el.className='childrow'; el.className='childrow';
el.entry=e; el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
@@ -297,8 +319,8 @@ async function renderListening(url,box){
} }
/// One row's time left, progress and play button, taken from the player when it is the one in it. /// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){ function paintListenRow(el: HTMLElement & {entry?: Entry}){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; const e=el.entry!, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then. // Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
// The player's own length first: a feed's can be minutes out. // The player's own length first: a feed's can be minutes out.
@@ -313,32 +335,32 @@ function paintListenRow(el){
} }
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. /// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
function syncListening(){ function syncListening(){
for(const el of $$('#listening .childrow')) for(const el of $$<HTMLElement & {entry?: Entry}>('#listening .childrow'))
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
} }
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every /// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go. /// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){ async function forget(e: Entry){
// Closed without saving first, or the player's next save would put it straight back. // Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } if(player.guid===e.guid){ player.guid=null; $<HTMLButtonElement>('#pclose').click(); }
try{ try{
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
{method:'POST',body:JSON.stringify({secs:0})}); {method:'POST',body:JSON.stringify({secs:0})});
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
if(S.feed===':listening') renderListed(VIEWS[':listening']); if(S.feed===':listening') renderListed(VIEWS[':listening']);
} }
// The toolbar acts on whatever is selected: the feed on the left, the item in the table. // The toolbar acts on whatever is selected: the feed on the left, the item in the table.
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; $<HTMLButtonElement>('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; $<HTMLButtonElement>('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $<HTMLButtonElement>('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; $<HTMLButtonElement>('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT; let searchT: number;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT); $<HTMLInputElement>('#epSearch').oninput=(ev: Event)=>{ clearTimeout(searchT);
// The Directory looks through what it already has; Currently Listening and the rest ask the // The Directory looks through what it already has; Currently Listening and the rest ask the
// server (#126, #127). // server (#126, #127).
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; searchT=setTimeout(()=>{ S.q=(ev.target as HTMLInputElement).value; S.offset=0;
if(S.feed===':directory'){ dirShow=null; drawDirectory(); } if(S.feed===':directory'){ dirShow=null; drawDirectory(); }
else if(S.feed===':listening') renderListed(VIEWS[':listening']); else if(S.feed===':listening') renderListed(VIEWS[':listening']);
else loadEntries(); },250); }; else loadEntries(); },250); };
@@ -346,13 +368,13 @@ $('#epSearch').oninput=ev=>{ clearTimeout(searchT);
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){ function toggleGroup(id: string){
expanded.has(id) ? expanded.delete(id) : expanded.add(id); expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds(); renderFeeds();
} }
let globalMax = 3; let globalMax = 3;
function due(ts){ function due(ts: number){
const d = ts - Date.now()/1000; const d = ts - Date.now()/1000;
if(d <= 0) return 'due now'; if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m'; if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
@@ -363,7 +385,7 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does /// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an /// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only. /// admin is sent (issue #19); this used to hold them too, shown to admins only.
function deadSavedText(me){ function deadSavedText(me: Me|null){
const s=me&&me.deadair_saved||0; const s=me&&me.deadair_saved||0;
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.` return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.'; :'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
@@ -407,45 +429,45 @@ async function prefsModal(){
<div class="field"><label>Feeds are checked every</label> <div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`); ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
$('#gopml').onclick=opmlModal; $<HTMLButtonElement>('#gopml').onclick=opmlModal;
drawTokens(); drawTokens();
$('#tokadd').onclick=async()=>{ $<HTMLButtonElement>('#tokadd').onclick=async()=>{
const name=$('#tokname').value.trim(); if(!name){ $('#tokname').focus(); return; } const name=$<HTMLInputElement>('#tokname').value.trim(); if(!name){ $<HTMLInputElement>('#tokname').focus(); return; }
try{ try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})}); const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name})});
$('#tokname').value=''; $<HTMLInputElement>('#tokname').value='';
await drawTokens(); await drawTokens();
// Shown this once: only its hash is kept, so a lost token is revoked and made again. // Shown this once: only its hash is kept, so a lost token is revoked and made again.
$('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token, $('#tokens').insertAdjacentHTML('afterbegin',`<div class="field" id="toknew"><span class="hint">Your new token,
${esc(t.name)}. Copy it now: it is not shown again.</span> ${esc(t.name)}. Copy it now: it is not shown again.</span>
<div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}"> <div class="inline"><input type="text" id="tokval" readonly value="${esc(t.token)}">
<button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`); <button class="btn ico" id="tokcopy" title="Copy" aria-label="Copy">${ICON.copy}</button></div></div>`);
$('#tokcopy').onclick=()=>copyText(t.token,$('#tokcopy')); $<HTMLButtonElement>('#tokcopy').onclick=()=>copyText(t.token,$<HTMLButtonElement>('#tokcopy'));
$('#tokval').select(); $<HTMLInputElement>('#tokval').select();
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
}; };
$('#sblock').onchange=async e=>{ $<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
const blocked=splitWords(e.target.value); const blocked=splitWords((e.target as HTMLInputElement).value);
try{ try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})}); await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
if(S.me) S.me.blocked=blocked; if(S.me) S.me.blocked=blocked;
toast('Saved'); await loadFeeds(true); loadEntries(); toast('Saved'); await loadFeeds(true); loadEntries();
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
}; };
} }
/// Your API tokens, each with when it was made and last used, and a button to revoke it. /// Your API tokens, each with when it was made and last used, and a button to revoke it.
async function drawTokens(){ async function drawTokens(){
const box=$('#tokens'); if(!box) return; const box=$('#tokens'); if(!box) return;
const list=await api('/api/tokens').catch(()=>[]); const list: {id: number, name: string, created: number, last_used: number|null}[]=await api('/api/tokens').catch((): []=>[]);
box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b> box.innerHTML=list.map(t=>`<div class="tokrow"><span><b>${esc(t.name)}</b>
<span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span> <span class="hint">made ${dateOf(t.created)}, last used ${ago(t.last_used)}</span></span>
<button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join(''); <button class="btn ico" data-tok="${t.id}" title="Revoke ${esc(t.name)}" aria-label="Revoke ${esc(t.name)}">${ICON.trash}</button></div>`).join('');
for(const b of $$('[data-tok]',box)) b.onclick=async()=>{ for(const b of $$('[data-tok]',box)) b.onclick=async()=>{
try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); } try{ await api(`/api/tokens/${b.dataset.tok}`,{method:'DELETE'}); toast('Revoked'); drawTokens(); }
catch(err){ toast(err.message,true); } catch(err){ toast(why(err),true); }
}; };
} }
@@ -464,37 +486,37 @@ function chipsHTML(words: string[], input: string){
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`; placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
} }
/// The words in a chip box, with whatever is typed and not yet a chip. /// The words in a chip box, with whatever is typed and not yet a chip.
function chipWords(box){ function chipWords(box: ParentNode){
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)]; return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($<HTMLInputElement>('.chipin',box).value)];
} }
function wireChips(box){ function wireChips(box: HTMLElement){
const input=$('.chipin',box); const input=$<HTMLInputElement>('.chipin',box);
// The hint only in an empty box: beside chips it reads as one more. // The hint only in an empty box: beside chips it reads as one more.
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; }; const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
const add=()=>{ const add=()=>{
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase()); const have=$$('.chip',box).map(c=>c.dataset.w!.toLowerCase());
for(const w of splitWords(input.value)){ for(const w of splitWords(input.value)){
if(have.includes(w.toLowerCase())) continue; if(have.includes(w.toLowerCase())) continue;
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase()); input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
} }
input.value=''; hint(); input.value=''; hint();
}; };
input.onkeydown=e=>{ input.onkeydown=(e: KeyboardEvent)=>{
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); } if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); } else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
}; };
// Typed and left without Enter is still meant: Save takes it either way. // Typed and left without Enter is still meant: Save takes it either way.
input.onblur=add; input.onblur=add;
box.onclick=e=>{ box.onclick=(e: MouseEvent)=>{
const x=e.target.closest('.chipx'); const x=(e.target as Element).closest('.chipx');
if(x){ x.parentElement.remove(); hint(); } if(x){ x.parentElement!.remove(); hint(); }
input.focus(); input.focus();
}; };
} }
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare /// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
/// box whose blank, number and 0 were explained in bold small print. /// box whose blank, number and 0 were explained in bold small print.
function settingsModal(f, newUrl?: string){ function settingsModal(f: Feed, newUrl?: string){
const kids=S.feeds.filter(c=>c.group===f.id).length; const kids=S.feeds.filter(c=>c.group===f.id).length;
const isGroup=kids>0; const isGroup=kids>0;
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null; const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
@@ -502,7 +524,7 @@ function settingsModal(f, newUrl?: string){
const name=f.title||f.id; const name=f.title||f.id;
// Blank follows the server's default; 0 is every item (#166). // Blank follows the server's default; 0 is every item (#166).
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest'; let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3); let newest=f.max_new_per_check&&f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
const whose=isGroup const whose=isGroup
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.` ? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
: parent : parent
@@ -540,6 +562,13 @@ function settingsModal(f, newUrl?: string){
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div> <div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along <p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
with what Settings hides everywhere.</p></div>`} with what Settings hides everywhere.</p></div>`}
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
<p class="cap">${f.private_by_address
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
:f.private&&!admin
?'Never shared with the directory. Only an admin can share it again.'
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
</div> </div>
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset'); <button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
@@ -547,7 +576,7 @@ function settingsModal(f, newUrl?: string){
// What only matters while it downloads on its own folds away, out of reach, when it does not. // What only matters while it downloads on its own folds away, out of reach, when it does not.
const deps=$('#sdeps'), inner=$('.fdepsin',deps); const deps=$('#sdeps'), inner=$('.fdepsin',deps);
inner.inert=!f.auto_download; inner.inert=!f.auto_download;
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; }; $<HTMLInputElement>('#sauto').onchange=(e: Event)=>{ deps.classList.toggle('off',!(e.target as HTMLInputElement).checked); inner.inert=!(e.target as HTMLInputElement).checked; };
// The download limit, said as what it does. A number in a box hid that it is a window on the // The download limit, said as what it does. A number in a box hid that it is a window on the
// newest items, not a pace through the back catalogue (#97). // newest items, not a pace through the back catalogue (#97).
@@ -562,7 +591,7 @@ function settingsModal(f, newUrl?: string){
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}` ? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
: `The server's default: downloads every item${each}, the whole back catalogue.`; : `The server's default: downloads every item${each}, the whole back catalogue.`;
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest; const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
let bars=[]; let bars: HTMLElement[]=[];
const draw=(words: boolean)=>{ const draw=(words: boolean)=>{
// The newest items with a file, as hold_back counts them: one whose files a filter all // The newest items with a file, as hold_back counts them: one whose files a filter all
// skipped holds no place. // skipped holds no place.
@@ -576,21 +605,21 @@ function settingsModal(f, newUrl?: string){
} }
if(!words) return; if(!words) return;
$('#swcap').innerHTML=caption(); $('#swcap').innerHTML=caption();
const n=$('#smax'); const n=$<HTMLInputElement>('#smax');
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); }; if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
}; };
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); }; for(const r of $$<HTMLInputElement>('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
// Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand. // Breathing room only matters with DeadAir on: greyed out, not hidden, so it can be seen beforehand.
$('#sdead').onchange=e=>{ $('#sdpause').disabled=!e.target.checked; }; $<HTMLInputElement>('#sdead').onchange=(e: Event)=>{ $<HTMLInputElement>('#sdpause').disabled=!(e.target as HTMLInputElement).checked; };
const pause=$('#sdpause'), pauseText=()=>{ const s=`${pause.value/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); }; const pause=$<HTMLInputElement>('#sdpause'), pauseText=()=>{ const s=`${Number(pause.value)/1000} s`; $('#sdpauseout').textContent=s; pause.setAttribute('aria-valuetext',s); };
pause.oninput=pauseText; pauseText(); pause.oninput=pauseText; pauseText();
draw(true); draw(true);
// The feed's own newest items, each as tall as it is long, so the strip reads as this show. // The feed's own newest items, each as tall as it is long, so the strip reads as this show.
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{ if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
const items=page?.entries||[]; const items: Entry[]=page?.entries||[];
const strip=$('#sepwin'); if(!items.length||!strip) return; const strip=$('#sepwin'); if(!items.length||!strip) return;
const longest=Math.max(1,...items.map(e=>e.duration||0)); const longest=Math.max(1,...items.map(e=>e.duration||0));
strip.innerHTML=items.map((e,i)=>{ strip.innerHTML=items.map((e: Entry,i: number)=>{
const file=e.enclosures.some(x=>x.state!=='skipped'); const file=e.enclosures.some(x=>x.state!=='skipped');
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60; const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`; return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
@@ -605,62 +634,65 @@ function settingsModal(f, newUrl?: string){
if(x<=b.getBoundingClientRect().right) break; if(x<=b.getBoundingClientRect().right) break;
} }
newest=Math.max(1,n); newest=Math.max(1,n);
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); } if(mode!=='newest'){ mode='newest'; $<HTMLInputElement>('[name=swin][value=newest]').checked=true; draw(true); }
else { $('#smax').value=String(newest); draw(false); } else { $<HTMLInputElement>('#smax').value=String(newest); draw(false); }
}; };
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); }; strip.onpointerdown=(e: PointerEvent)=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); }; strip.onpointermove=(e: PointerEvent)=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
}).catch(()=>{}); }).catch(()=>{});
wireChips($('#skw')); wireChips($('#skw'));
if($('#sblock')) wireChips($('#sblock')); if($('#sblock')) wireChips($('#sblock'));
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); $<HTMLButtonElement>('#scopy').onclick=()=>copyText($<HTMLInputElement>('#surl').value,$<HTMLButtonElement>('#scopy'));
// Opened to follow a feed that moved: the new address is what there is to look at. // Opened to follow a feed that moved: the new address is what there is to look at.
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); } if(newUrl){ $<HTMLInputElement>('#surl').scrollIntoView({block:'center'}); $<HTMLInputElement>('#surl').focus(); }
$('#ssave').onclick=async()=>{ $<HTMLButtonElement>('#ssave').onclick=async()=>{
try{ try{
const patch: Record<string, unknown>={ const patch: Record<string, unknown>={
keywords:chipWords($('#skw')), keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest, max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked, auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
deadair_pause:Number($('#sdpause').value)}; deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock')); if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
const priv=$<HTMLInputElement>('#sprivate');
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
// The address is the same for everyone: an admin's to change, for a feed that moved, and the // The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all. // API refuses it from anyone else. The folder and category are not this dialog's at all.
if(admin) patch.url=$('#surl').value.trim(); if(admin) patch.url=$<HTMLInputElement>('#surl').value.trim();
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved. It applies from the next check.'); closeModal(); toast('Saved. It applies from the next check.');
await loadFeeds(true); renderFeed(); loadEntries(); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
}; };
} }
function downloadLatestModal(f){ function downloadLatestModal(f: Feed){
openModal(`<h3>Download latest items</h3> openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label> <div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5"> <input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div> <span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`); <button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
$('#dgo').onclick=async()=>{ $<HTMLButtonElement>('#dgo').onclick=async()=>{
const n=Number($('#dcount').value)||5; const n=Number($<HTMLInputElement>('#dcount').value)||5;
try{ try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`, const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})}); {method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`); closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
}; };
} }
function removeFeed(f){ function removeFeed(f: Feed){
openModal(`<h3>Unsubscribe?</h3> openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else <p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
reading it keeps it, along with their own read state. reading it keeps it, along with their own read state.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p> Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`); <button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
$('#rgo').onclick=async()=>{ $<HTMLButtonElement>('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null; closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed(); await loadFeeds(); if(!S.feeds.length) renderFeed();
@@ -679,11 +711,11 @@ function opmlModal(){
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div> <textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`); <button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
$('#oimp').onclick=async()=>{ $<HTMLButtonElement>('#oimp').onclick=async()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing // A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported. // the picker lets go of it on this side too, whether it was refused or imported.
const pick=$('#opmlFile'), file=pick.files[0]; const pick=$<HTMLInputElement>('#opmlFile'), file=pick.files![0];
const xml=file ? await file.text() : $('#opmlText').value; const xml=file ? await file.text() : $<HTMLTextAreaElement>('#opmlText').value;
const letGo=()=>{ pick.value=''; }; const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word. // A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){ if(!/<opml[\s>]/i.test(xml)){
@@ -692,33 +724,44 @@ function opmlModal(){
try{ try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})}); const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true); letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ letGo(); toast(e.message,true); } }catch(e){ letGo(); toast(why(e),true); }
}; };
} }
// No toast: the spinners on the rows being checked say it (issue #37). // No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } async function scanAll(){
$('#scanAll').onclick=scanAll; spinStart(null);
$('#prefs').onclick=prefsModal; try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
catch(e){ spinEnd(); throw e; }
}
$<HTMLButtonElement>('#scanAll').onclick=scanAll;
$<HTMLButtonElement>('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
// the proxy still vouches for them. /api/me says where, when that is the case. // the proxy still vouches for them. /api/me says where, when that is the case.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; }; $<HTMLButtonElement>('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{ api('/api/me').then(u=>{
S.me=u; S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':''); $('#who').textContent=u.name+(u.admin?' · admin':'');
// ipx's version and the page's, each released on its own.
$('#applogo').title=`iPX ${u.version}, page __WEB_VERSION__`;
// Whether the link to Admin shows, now and before the first frame of the next visit.
document.documentElement.toggleAttribute('data-admin',!!u.admin);
try{ localStorage.setItem('ipx.admin',u.admin?'1':'0'); }catch{}
// A browser with no theme of its own takes the one the account kept from before, once.
if(u.theme&&!/(^|; )ipx_theme=/.test(document.cookie)) setTheme(u.theme.name,u.theme.mode,true);
}).catch(()=>{}); }).catch(()=>{});
$('#feedFilter').oninput=renderFeeds; $<HTMLInputElement>('#feedFilter').oninput=renderFeeds;
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a // The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too. // folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
$('#feedlist').onkeydown=ev=>{ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
const row=ev.target.closest('[data-id]'); if(!row) return; const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
const id=row.dataset.id; const id=row.dataset.id!;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click(); if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&& else if(row.classList.contains('group')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id); (ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return; else return;
ev.preventDefault(); ev.stopPropagation(); ev.preventDefault(); ev.stopPropagation();
}; };
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $<HTMLButtonElement>('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false); $('#scrim').onclick=()=>nav(false);

View File

@@ -1,10 +1,10 @@
/* ---------------- live events ---------------- */ /* ---------------- live events ---------------- */
let sse; let sse: EventSource;
/// The feed list and the open list, taken again from the server. The events keep both current, /// The feed list and the open list, taken again from the server. The events keep both current,
/// and one missed is a count wrong until a reload: reading an item sends none to your other /// and one missed is a count wrong until a reload: reading an item sends none to your other
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while /// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
/// the app is in the background, when everything is missed (#168). /// the app is in the background, when everything is missed (#168).
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{ const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
loadFeeds(true).catch(()=>{}); loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries().catch(()=>{}); if(S.feed) loadEntries().catch(()=>{});
},300); }; })(); },300); }; })();
@@ -14,10 +14,11 @@ function connect(){
sse=new EventSource('/api/events'); sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody. // Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); // Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=id=>S.feeds.some(f=>f.id===id); const mine=(id: string)=>S.feeds.some(f=>f.id===id);
sse.onmessage=m=>{ sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){
@@ -25,7 +26,7 @@ function connect(){
// Only the row actually downloading. Without the enclosure id this used to paint // Only the row actually downloading. Without the enclosure id this used to paint
// every pending bar at once, so adding a feed looked like it was fetching the lot. // every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`); const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); } if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`); $('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
} }
else if(ev.ev==='download_done'){ else if(ev.ev==='download_done'){
@@ -65,9 +66,11 @@ function connect(){
// every open page reloaded the whole list each time (#103). // every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the // Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// page reloads; reload the list here when a scan synced an OPML if that ever matters. // page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
else if(ev.ev==='error') spinEnd();
}; };
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
} }
connect(); connect();
loadFeeds(); loadFeeds().catch(()=>{});

View File

@@ -3,16 +3,16 @@ function renderFeed(){
paintScanning(); // another feed's page may be the one open now paintScanning(); // another feed's page may be the one open now
const box=$('#content'); const box=$('#content');
box.classList.remove('plain'); box.classList.remove('plain');
const v=VIEWS[S.feed]; const v=VIEWS[S.feed ?? ''];
if(v&&v.url) return renderListed(v); if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed); const f=v?null:S.feeds.find(x=>x.id===S.feed);
$('#tbRemove').disabled=!f; $<HTMLButtonElement>('#tbRemove').disabled=!f;
syncTools(null); syncTools(null);
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; } if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):v.title; const name=f?(f.title||f.id):v.title;
$('#epSearch').placeholder=`Search ${name}…`; $<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[]; const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; } if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0); const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
box.innerHTML = (f ? ` box.innerHTML = (f ? `
<div class="fhead slim"> <div class="fhead slim">
@@ -61,7 +61,7 @@ function renderFeed(){
if(f) pane.className='one'; if(f) pane.className='one';
pane.innerHTML='<div id="list">'+sortHead()+ pane.innerHTML='<div id="list">'+sortHead()+
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>'; '<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort)); $$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort!));
box.appendChild(pane); box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%'); pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
dragSplit(pane); dragSplit(pane);
@@ -69,9 +69,9 @@ function renderFeed(){
S.sel=null; S.sel=null;
showDetail(null); showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{ $$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0; S.filter=b.dataset.f!; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{} try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries(); renderFeed(); loadEntries();
}); });
@@ -89,7 +89,7 @@ function sortHead(){
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`; `${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>'; }).join('')+'<span></span></div>';
} }
function sortBy(col){ function sortBy(col: string){
const first=['published','size','kept'].includes(col)?'desc':'asc'; const first=['published','size','kept'].includes(col)?'desc':'asc';
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first}; S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{} try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
@@ -99,9 +99,9 @@ function sortBy(col){
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps /// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath. /// every action a normal feed has -- it is still an ordinary feed entry underneath.
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is. // A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||''); const isPatreon=(f: {url: string}|null)=>/patreon\.com\//.test(f&&f.url||'');
function renderGroup(f,kids){ function renderGroup(f: Feed,kids: Feed[]){
const unread=kids.reduce((n,c)=>n+c.unread,0); const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0); const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length; const gone=kids.filter(c=>c.orphaned).length;
@@ -132,10 +132,10 @@ function renderGroup(f,kids){
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span> <span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
</div> </div>
<div class="childlist" id="kidlist"></div>`; <div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a!,f));
wireFailBanner($('#content'),f); wireFailBanner($('#content'),f);
const draw=()=>{ const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase(); const q=($<HTMLInputElement>('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML=''; const box=$('#kidlist'); box.innerHTML='';
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst); const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; } if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
@@ -154,26 +154,30 @@ function renderGroup(f,kids){
box.appendChild(el); box.appendChild(el);
} }
}; };
$('#kidSearch').oninput=draw; $<HTMLInputElement>('#kidSearch').oninput=draw;
draw(); draw();
} }
async function feedAction(a,f){ async function feedAction(a: string,f: Feed){
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='scan'){
spinStart(f.id);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
catch(e){ spinEnd(); throw e; }
}
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f); if(a==='rm') removeFeed(f);
if(a==='pin'){ if(a==='pin'){
try{ try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
await loadFeeds(true); renderFeed(); await loadFeeds(true); renderFeed();
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
} }
if(a==='settings') settingsModal(f); if(a==='settings') settingsModal(f);
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]')); if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);
} }
/// All Subscriptions' own buttons: a feed's, across every feed you read. /// All Subscriptions' own buttons: a feed's, across every feed you read.
async function allAction(a){ async function allAction(a: string){
if(a==='scanall') return scanAll(); if(a==='scanall') return scanAll();
if(a==='readall'){ if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0); const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
@@ -183,7 +187,7 @@ async function allAction(a){
try{ try{
const r=await api('/api/read-all',{method:'POST'}); const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); } }catch(e){ toast(why(e),true); }
} }
} }

View File

@@ -2,7 +2,7 @@
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just /// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each. /// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0; let rowsQueued=0;
function patchFeed(row){ function patchFeed(row: Feed){
const i=S.feeds.findIndex(f=>f.id===row.id); const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row; if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); }); if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
@@ -19,58 +19,75 @@ async function loadFeeds(keepSel?: boolean){
// brand new account) leaves S.feed alone, so the empty state's own message shows instead. // brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){ if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed)); const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all'); selectFeed(known ? S.feed! : ':all');
} }
} }
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the // An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half. // server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0); const unreadFirst=(a: Feed,b: Feed)=>Number(b.unread>0)-Number(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds: // The original's source list opened with these, above the feeds. They are places, not feeds:
// an id starting with ':' can never be a feed's, since feed ids are slugs. // an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={ const VIEWS: Record<string,{title: string, icon: string, url?: string, blurb?: string}>={
// The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124). // The Directory holds the most subscribed feeds too, which had a place of their own, Popular (#124).
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'}, ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50', ':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder /// Feeds being checked right now, from the event stream, and since when: their rows, and the row
/// holding one, carry a spinner. /// of a folder holding one, carry a spinner.
const scanning=new Set<string>(); const scanning=new Map<string, number>();
/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are
/// checked in a few milliseconds, and each count blinked out and back as its feed went by.
const SPIN_AFTER=300;
function setScanning(id: string, on: boolean){ function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id); if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id);
paintScanning(); paintScanning();
} }
function paintScanning(){ function paintScanning(){
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)!>=SPIN_AFTER;
for(const row of $$('#feedlist .feed')){ for(const row of $$('#feedlist .feed')){
const id=row.dataset.id; const id=row.dataset.id!;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
row.classList.toggle('scanning',on); row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
} }
// The refresh buttons turn while what they check is being checked (#78). On <body>, because // A feed's button turns on its own page only, wherever the person has gone since pressing it.
// a feed's page is redrawn as its items come in and would take a class on the button with it. document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); }
document.body.classList.toggle('scan-this',busy(S.feed)); /// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id))); /// because a feed's page is redrawn as its items come in and would take a class on the button
/// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
/// between one feed and the next, many times a second (#231).
let spinFeed: string|null=null;
function spinStart(feed: string|null){
if(feed===null) document.body.classList.add('scan-any');
else { spinFeed=feed; paintScanning(); }
}
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
/// (an error ended the job) stops them all.
function spinEnd(done?: {only: string|null, forced: boolean}){
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); }
} }
function renderFeeds(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$<HTMLInputElement>('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; const list=$('#feedlist'); const top=list.scrollTop;
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on. // Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]'); const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]); const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
const done=()=>{ const done=()=>{
list.scrollTop=top; list.scrollTop=top;
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`); const row=back&&list.querySelector<HTMLElement>(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).focus(); if(row) (back[1]&&$('.chev',row)||row as HTMLElement).focus();
}; };
// The images already drawn, to go into the rows that replace theirs. Made anew, every icon in // The images already drawn, to go into the rows that replace theirs. Made anew, every icon in
// the list went blank and loaded again, once a frame through a check of every feed, as each // the list went blank and loaded again, once a frame through a check of every feed, as each
// feed's new row came in (#134). Matched by their markup, so a changed one is made anew. // feed's new row came in (#134). Matched by their markup, so a changed one is made anew.
const drawn=new Map<string,HTMLImageElement[]>(); const drawn=new Map<string,HTMLImageElement[]>();
for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k).push(img); } for(const img of $$<HTMLImageElement>('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); }
list.innerHTML=''; list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0); const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div'); const places=document.createElement('div');
@@ -91,9 +108,9 @@ function renderFeeds(){
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
// go first: a folder with its feeds under it, a feed from inside one lifted out of it. // go first: a folder with its feeds under it, a feed from inside one lifted out of it.
const byId=Object.fromEntries(shown.map(f=>[f.id,f])); const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned); const inside=(f: Feed)=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group])); const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[]; const order: [Feed, number][]=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){ for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]); order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching // A subscription can hold dozens of feeds, so a folder starts closed. Searching
@@ -110,14 +127,14 @@ function renderFeeds(){
// A subscription holds no entries itself, so its counts are the sum of what is inside -- // A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing. // taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id); const mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0); const sum=(k: 'unread'|'entries'|'downloaded')=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')] ? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded]; : [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself // A group's own row has no error of its own worth mentioning if the OPML itself
// reads fine; it is failing when any feed inside it is. The feed's own page says what // reads fine; it is failing when any feed inside it is. The feed's own page says what
// went wrong (failBannerHTML); the list only has to make it findable. // went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error; const bad=(c: Feed)=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const nbad=mine.filter(bad).length; const nbad=mine.filter(bad).length;
const el=document.createElement('div'); const el=document.createElement('div');
@@ -143,15 +160,15 @@ function renderFeeds(){
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); }; el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); } for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el); list.appendChild(el);
} }
paintScanning(); paintScanning();
done(); done();
} }
function selectFeed(id){ function selectFeed(id: string){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; S.feed=id; S.offset=0; S.sel=null; S.q=''; $<HTMLInputElement>('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{} try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries(); renderFeeds(); renderFeed(); loadEntries();
} }
@@ -159,15 +176,15 @@ function selectFeed(id){
/// A failing feed's error, in plain words with something to do about it, once `failing` is /// A failing feed's error, in plain words with something to do about it, once `failing` is
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in /// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
/// src/feed.rs). Anything else still shows the raw error, as before. /// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){ function failBannerHTML(f: Feed){
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)} if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${ <button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`; f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`; if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
return ''; return '';
} }
function wireFailBanner(box,f){ function wireFailBanner(box: ParentNode,f: Feed){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f); const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url); const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing?.new_url??undefined);
} }

View File

@@ -70,7 +70,7 @@ document.addEventListener('touchstart', ev => {
// back button is never swallowed with it. // back button is never swallowed with it.
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault(); if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
} }
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0}; else if(list && list.scrollTop <= 0 && !VIEWS[S.feed ?? '']?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
}, {passive: false}); }, {passive: false});
document.addEventListener('touchmove', ev => { document.addEventListener('touchmove', ev => {

View File

@@ -6,7 +6,7 @@ async function loadEntries(append?: boolean){
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir}); const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
if(S.q) p.set('q',S.q); if(S.q) p.set('q',S.q);
const asked=performance.now(), seq=++loadSeq; const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}` const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// Only the latest list counts: marking everything read reloads the All tab, and clicking // Only the latest list counts: marking everything read reloads the All tab, and clicking
// Unread straight after could have that answer land last and replace the Unread one. // Unread straight after could have that answer land last and replace the Unread one.
@@ -14,14 +14,14 @@ async function loadEntries(append?: boolean){
S.total=r.total; S.total=r.total;
for(const e of r.entries){ for(const e of r.entries){
const w=readWrites.get(readKey(e)); const w=readWrites.get(readKey(e));
if(w && !(w.done<asked)) e.read=w.read; if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
} }
let entries = append ? S.entries.concat(r.entries) : r.entries; let entries = append ? S.entries.concat(r.entries) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread // A background scan finishing refreshes the list from the server, which -- on the Unread
// tab -- would drop the item you have open the moment reading it took it off the filter. // tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it. // Keep it until you pick a different one; the next refresh after that no longer protects it.
// The items turned past on the way to it are kept too, so a swipe back still finds them. // The items turned past on the way to it are kept too, so a swipe back still finds them.
const kept=e=>e.guid===S.sel||turned.has(e.guid); const kept=(e: Entry)=>e.guid===S.sel||turned.has(e.guid);
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e)); if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(!append && S.sel){ if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid)); const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
@@ -32,7 +32,7 @@ async function loadEntries(append?: boolean){
} }
function renderEntries(){ function renderEntries(){
const box=$('#eps'); if(!box) return; const box=$('#eps'); if(!box) return;
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed]; const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0); const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent= $('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`; `${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
@@ -53,7 +53,7 @@ function renderEntries(){
box.appendChild(b); box.appendChild(b);
} }
} }
function epEl(e){ function epEl(e: Entry){
// An item may carry several files. The row summarises the one you would act on -- // An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how // the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all. // many others there are; the pane below lists them all.
@@ -78,7 +78,7 @@ function epEl(e){
num&&`<span>${num}</span>`, num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`, left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`, others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`, enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair_pending&&DEAD_PENDING, enc&&enc.deadair_pending&&DEAD_PENDING,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`, enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div> ].filter(Boolean).join('<span class="dot"></span>')}</div>
@@ -97,13 +97,13 @@ function epEl(e){
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''} ${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`; </div>`;
el.onclick=()=>selectEntry(e); el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)}); $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)});
return el; return el;
} }
/// Whether the browser can play it. Having a file is not the same as being playable: /// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG /// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player. /// is just a broken player.
function isPlayable(x){ function isPlayable(x: Enclosure|undefined){
if(!x || !x.path) return false; if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase(); const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true; if(m.startsWith('audio/')||m.startsWith('video/')) return true;
@@ -114,7 +114,7 @@ function isPlayable(x){
} }
/// What an enclosure is, for a row that is not media: "image", "pdf", "document". /// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){ function kindOf(enc: {mime?: string|null, url?: string}){
const m=(enc.mime||'').toLowerCase(); const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image'; if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video'; if(m.startsWith('video/')) return 'video';
@@ -128,9 +128,9 @@ function kindOf(enc){
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when /// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon /// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip. /// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){ function kindIcon(enc: Enclosure){
const k=kindOf(enc); const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file; const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`] const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`] : enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k]; : ['',k];
@@ -138,7 +138,7 @@ function kindIcon(enc){
} }
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; } function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; }; const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional, /// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's /// and Scripting News titles almost none of its posts) the opening of its text, then its file's
@@ -147,24 +147,24 @@ const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached /// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded" /// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
/// read "2010)Recorded". /// read "2010)Recorded".
const plainText=(html?: string)=>html const plainText=(html?: string|null)=>html
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'') ? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
.replace(/\s+/g,' ').trim() : ''; .replace(/\s+/g,' ').trim() : '';
function entryName(e): {text: string, derived: boolean}{ function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false}; if(e.title&&e.title.trim()) return {text:e.title, derived:false};
const words=plainText(e.description); const words=plainText(e.description);
if(words){ if(words){
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words; const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
return {text:cut, derived:true}; return {text:cut, derived:true};
} }
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,''); const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } } if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true}; return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
} }
/// Selecting an item shows it in the pane below, rather than expanding the row. /// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone. /// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){ function swapRow(e: Entry){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`); const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); } if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
} }
@@ -172,9 +172,9 @@ function swapRow(e){
/// Read and unread as this page last set them, and when the server had it. A list asked for /// Read and unread as this page last set them, and when the server had it. A list asked for
/// before then answers with the old state: it put the dot back on an item just read, until /// before then answers with the old state: it put the dot back on an item just read, until
/// the next refresh took it off again (loadEntries). /// the next refresh took it off again (loadEntries).
const readWrites=new Map(); const readWrites=new Map<string, {read: boolean, done?: number}>();
const readKey=e=>e.feed_id+'\n'+e.guid; const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
function setRead(e,read){ function setRead(e: Entry,read: boolean){
e.read=read; e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w); const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
@@ -184,7 +184,7 @@ function setRead(e,read){
/// Opening an item is reading it. The row is redrawn where it stands rather than the list /// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab. /// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){ function markRead(e: Entry){
if(e.read) return; if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); }); setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
} }
@@ -193,7 +193,7 @@ function markRead(e){
/// in the list until the reader closes or another item is picked from the list. Dropped as each /// in the list until the reader closes or another item is picked from the list. Dropped as each
/// was left, a swipe back had nothing to go back to: the item just read was already gone. /// was left, a swipe back had nothing to go back to: the item just read was already gone.
const turned=new Set<string>(); const turned=new Set<string>();
function dropTurned(keep){ function dropTurned(keep: string|null){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep); const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear(); turned.clear();
if(!gone.length) return; if(!gone.length) return;
@@ -201,7 +201,7 @@ function dropTurned(keep){
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove(); for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
} }
function selectEntry(e,turning=false){ function selectEntry(e: Entry,turning=false){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh // On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while. // next happens to come along, which left a few read ones in the list for a while.
if(S.filter==='unread' && S.sel && S.sel!==e.guid){ if(S.filter==='unread' && S.sel && S.sel!==e.guid){
@@ -217,10 +217,10 @@ function selectEntry(e,turning=false){
} }
/// Drag the divider between the item list and the item text. /// Drag the divider between the item list and the item text.
function dragSplit(pane){ function dragSplit(pane: HTMLElement){
const grab=$('#grab',pane); const grab=$('#grab',pane);
if(!grab) return; if(!grab) return;
const move=ev=>{ const move=(ev: MouseEvent)=>{
const box=pane.getBoundingClientRect(); const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100)); const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%'); pane.style.setProperty('--listh',pct.toFixed(1)+'%');
@@ -231,7 +231,7 @@ function dragSplit(pane){
document.body.style.userSelect=''; document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{} try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
}; };
grab.onmousedown=ev=>{ grab.onmousedown=(ev: MouseEvent)=>{
ev.preventDefault(); ev.preventDefault();
document.body.style.userSelect='none'; document.body.style.userSelect='none';
document.addEventListener('mousemove',move); document.addEventListener('mousemove',move);
@@ -243,24 +243,24 @@ function dragSplit(pane){
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick // ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens. // the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel); const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){ function syncTools(e: Entry|null){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e; $<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree. Each // The same icons as the item's own buttons beside its title, so the two never disagree. Each
// shows what is, as the pin always did: read showed what a click would do, so the two side by // shows what is, as the pin always did: read showed what a click would do, so the two side by
// side said opposite things (#74). The shape carries it, not the colour. // side said opposite things (#74). The shape carries it, not the colour.
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check; $<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read))); $<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged))); $<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin; $<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){ if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`; $<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted'; $<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
} }
} }
/// The item's text in the pane below the list, and its files in the pane beside it. /// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){ function showDetail(e: Entry|null){
const box=$('#detail'), files=$('#files'); if(!box) return; const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead. // A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length); if(files) files.hidden=!(e&&e.enclosures.length);
@@ -278,15 +278,15 @@ function showDetail(e){
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches; const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
box.innerHTML=detailHtml(e); box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`; if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$('#dback').onclick=()=>showDetail(null); $<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=> for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null)); b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
syncPlayButtons(); syncPlayButtons();
} }
/// What the reader shows for an item. Its own function because a phone also draws the next or /// What the reader shows for an item. Its own function because a phone also draws the next or
/// previous item beside the reader while it is swiped (gestures.ts). /// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){ function detailHtml(e: Entry){
const encs=e.enclosures.map(encBox).join(''); const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead. // A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone // Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
@@ -317,7 +317,7 @@ function detailHtml(e){
} }
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it. /// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){ function encBox(x: Enclosure){
const size=x.length?mb(x.length):''; const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act. // One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id); const f=S.feeds.find(y=>y.id===x.feed_id);
@@ -328,8 +328,9 @@ function encBox(x){
: 'Delete file'; : 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`; const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
// Played without its dead air, so saved without it too (#186). // Played without its dead air, so saved without it too (#186).
const saveLabel=x.deadair>0?'Save to this computer, without its dead air':'Save to this computer'; const dead=(x.deadair??0)>0;
const saveBtn=`<a class="btn ico" href="/media/${x.id}${x.deadair>0?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`; const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
// The publisher's address, not this server's copy, which only someone signed in here can open. // The publisher's address, not this server's copy, which only someone signed in here can open.
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`; const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
if(x.path && !isPlayable(x)){ if(x.path && !isPlayable(x)){
@@ -369,14 +370,14 @@ function encBox(x){
</div>`; </div>`;
} }
async function epAction(a: string, e, el, encId?: number){ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read. // Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); }; const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0]; const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{ try{
if(a==='play') play(e, encId!=null ? enc : undefined); if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el); if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); } if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){ if(a==='get'){
@@ -396,12 +397,12 @@ async function epAction(a: string, e, el, encId?: number){
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'}); await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){ }catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words. // 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err; if(!/one copy of this file/.test(why(err))) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return; if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'}); await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
} }
toast('Deleted'); loadEntries(); loadFeeds(true); toast('Deleted'); loadEntries(); loadFeeds(true);
} }
}catch(err){ toast(err.message,true); } }catch(err){ toast(why(err),true); }
} }

View File

@@ -1,6 +1,6 @@
document.getElementById('f').onsubmit = async e => { document.getElementById('f')!.onsubmit = async e => {
e.preventDefault(); e.preventDefault();
const msg = document.getElementById('msg'); const msg = document.getElementById('msg')!;
msg.textContent = ''; msg.textContent = '';
const r = await fetch('/api/login', { const r = await fetch('/api/login', {
method: 'POST', method: 'POST',

View File

@@ -1,11 +1,11 @@
/* ---------------- player ---------------- */ /* ---------------- player ---------------- */
const audio=$('#audio'); const audio=$<HTMLAudioElement>('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean, const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false}; saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's playlist when the server says this person plays it /// Where a file plays from: DeadAir's playlist when the server says this person plays it
/// shortened (#171, #181), whose times the entry is already given in, else the file itself. /// shortened (#171, #181), whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`; const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it /// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails /// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
@@ -31,7 +31,7 @@ function setSrc(url: string): Promise<void>|null{
return ready.then(()=>{ return ready.then(()=>{
// Its playlist and audio requests join the trace of the tap that played them (#192). // Its playlist and audio requests join the trace of the tap that played them (#192).
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())}); hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true); hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } }); report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
hls.loadSource(url); hls.attachMedia(audio); hls.loadSource(url); hls.attachMedia(audio);
}); });
@@ -49,8 +49,8 @@ function markPlayed(){
} }
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one. /// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e,enc=e.enclosures.find(isPlayable)){ function play(e: Entry,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){ if(!enc||!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true); toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return; return;
} }
@@ -76,17 +76,17 @@ function play(e,enc=e.enclosures.find(isPlayable)){
player.marked=false; player.marked=false;
paintDead(); paintDead();
} }
const go=()=>audio.play().catch(err=>toast('Playback failed: '+err.message,true)); const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true));
if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go(); if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go();
renderEntries(); renderEntries();
} }
function mediaSession(e,f){ function mediaSession(e: Entry,f: Feed|undefined){
if(!('mediaSession' in navigator)) return; if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({ navigator.mediaSession.metadata=new MediaMetadata({
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'', title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[], artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[],
}); });
const h={play:()=>audio.play(),pause:()=>audio.pause(), const h: Record<string,()=>void>={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30}; seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} } for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
} }
@@ -94,7 +94,7 @@ audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0; const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime); $('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d); $('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000)); if(d) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Only playing counts as moving: the seek to where you left off happens paused, and saving // Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old. // that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true; if(!audio.paused) player.moved=true;
@@ -117,29 +117,29 @@ function savePos(){
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null; const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.( navigator.sendBeacon?.(
// A beacon sends no headers, so the trace goes in the address (#192). // A beacon sends no headers, so the trace goes in the address (#192).
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`, `/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'})); new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
} }
audio.addEventListener('pause',savePos); audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play}); audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
// body.playing is what sets the EQ bars moving. // body.playing is what sets the EQ bars moving.
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); }); audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); }); audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons); for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files /// Every play button for what is playing shows pause, like the player bar's: a row's, the files
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played. /// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){ function syncPlayButtons(){
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b,now,idle)=>{ const paint=(b: HTMLElement,now: boolean,idle: string)=>{
const label=now?'Pause':idle; const label=now?'Pause':idle;
if(b.title===label) return; if(b.title===label) return;
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play; b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
}; };
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play'); for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest<HTMLElement>('.ep')!.dataset.guid!),'Play');
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]')) for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
paint(b,on(S.sel,Number(b.dataset.enc)),'Play'); paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item'); const e=cur(); paint($<HTMLButtonElement>('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
} }
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening); for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos); window.addEventListener('beforeunload',savePos);
@@ -157,27 +157,27 @@ audio.addEventListener('playing',()=>{
asked=0; waited=0; asked=0; waited=0;
}); });
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); }); audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15; $<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30; $<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)}; $<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)}; $<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the /// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour. /// icon's shape and aria-pressed, never a colour.
function paintDead(){ function paintDead(){
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed); const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
b.hidden=!f||document.body.classList.contains('has-video'); b.hidden=!f||document.body.classList.contains('has-video');
if(b.hidden) return; if(!f||b.hidden) return;
b.setAttribute('aria-pressed',String(!!f.deadair)); b.setAttribute('aria-pressed',String(!!f.deadair));
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair; b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show'; b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
} }
$('#pdead').onclick=async()=>{ $<HTMLButtonElement>('#pdead').onclick=async()=>{
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return; const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
const on=!f.deadair; const on=!f.deadair;
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); } try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
catch(err){ toast(err.message,true); return; } catch(err){ toast(why(err),true); return; }
f.deadair=on; paintDead(); f.deadair=on; paintDead();
const switched=await switchVersion(); const switched=await switchVersion();
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show' toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
@@ -188,14 +188,14 @@ $('#pdead').onclick=async()=>{
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time. /// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
async function switchVersion(){ async function switchVersion(){
if(!player.guid) return false; if(!player.guid) return false;
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent); const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s));
// Saved and waited for, not beaconed, so the entry asked for next has this place in it. // Saved and waited for, not beaconed, so the entry asked for next has this place in it.
if(audio.readyState){ if(audio.readyState){
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({ await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{}); secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false; player.moved=false;
} }
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null); const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc); const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false; if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused; const was=!audio.paused;
@@ -205,13 +205,13 @@ async function switchVersion(){
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{}); if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true; return true;
} }
$('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries(); $<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none. // Called here, not left to the pause event: closing a player already paused fires none.
syncListening()}; syncListening()};
(function restore(){ (function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol'); const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r} if(r){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=Number(v)/100} if(v){$<HTMLInputElement>('#vol').value=v;audio.volume=Number(v)/100}
})(); })();
document.addEventListener('keydown',ev=>{ document.addEventListener('keydown',ev=>{
@@ -223,34 +223,34 @@ document.addEventListener('keydown',ev=>{
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()} if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15} else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} else if(ev.key==='/'){ev.preventDefault();$<HTMLInputElement>('#epSearch')?.focus()}
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev); else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
}); });
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a // Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's // letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open. // own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',l:':listening'}; const GO: Record<string,string>={a:':all',d:':directory',l:':listening'};
let gAt=0; let gAt=0;
function stepEntry(by){ function stepEntry(by: number){
if(VIEWS[S.feed]?.url||!S.entries.length) return; if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel); const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e,true); selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
} }
function stepFeed(by){ function stepFeed(by: number){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id; const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return; if(!id) return;
selectFeed(id); selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'}); $(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
} }
const KEYS={ const KEYS: Record<string,()=>void>={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1), j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1), J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while // The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled. // they are disabled.
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(), o:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); }, v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(), A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
@@ -259,7 +259,7 @@ const KEYS={
'?':()=>keysModal(), '?':()=>keysModal(),
g:()=>{ gAt=Date.now(); }, g:()=>{ gAt=Date.now(); },
}; };
function typed(ev){ function typed(ev: KeyboardEvent){
// The second key of a g pair counts only if it follows within a second and a half. // The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0; const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key]; const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
@@ -268,7 +268,7 @@ function typed(ev){
} }
/// What ? shows: every key, grouped as Feedly's own list is. /// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){ function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c); const k=(s: string)=>`<kbd>${esc(s)}</kbd>`, g=(c: string)=>k('g')+' '+k(c);
const rows=[ const rows=[
['Go to'], ['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'], [g('a'),'All Subscriptions'],[g('d'),'Directory'],

View File

@@ -37,13 +37,13 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
root.dataset.mode = !both ? 'light' root.dataset.mode = !both ? 'light'
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode; : theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
// The tab's icon in the same variant as the logo on the page. // The tab's icon in the same variant as the logo on the page.
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode]; const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's // The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself. // toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
const tc = $('meta[name=theme-color]'), bar = $('#topbar'); const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; } if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
const sel = $('#stheme'); if(sel) sel.value = theme.name; const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode; const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both; const mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme(); if(save) saveTheme();
} }
@@ -55,13 +55,13 @@ function saveTheme(){
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); }); systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
(() => { (() => {
const root = document.documentElement; const root = document.documentElement;
// Without a cookie, the server sent the theme the account kept from before; this browser takes // The head script put this browser's theme from its cookie on the page. Without one, /api/me's
// it as its own, once, so nobody has to choose again. // answer brings the account's from before (dialogs.ts).
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie)); if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
// Nothing on the account either. A theme this browser kept in localStorage, from before that, // Nothing on the account either. A theme this browser kept in localStorage, from before that,
// becomes its cookie, once. // becomes its cookie, once.
let name: string | null = null, mode: string | null = null; let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{} try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name]; if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined, !!name); setTheme(name ?? undefined, mode ?? undefined, !!name);
})(); })();

153
web/src/types.ts Normal file
View File

@@ -0,0 +1,153 @@
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
// An Option is `T | null`, as serde writes None.
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
interface Feed {
id: string;
url: string;
title: string | null;
image: string | null;
folder: string | null;
category: string | null;
feed_category: string | null;
keywords: string[];
blocked: string[];
deadair: boolean;
deadair_pause: number;
allow_explicit: boolean;
auto_download: boolean;
max_new_per_check: number | null;
/// The OPML subscription it came from.
group: string | null;
orphaned: boolean;
managed: boolean;
schedule: string | null;
schedule_mins: number | null;
every_mins: number;
last_checked: number | null;
next_check: number | null;
last_error: string | null;
failing: {reason: string, new_url: string | null} | null;
entries: number;
downloaded: number;
unread: number;
subscribers: number;
pinned: boolean;
private: boolean;
private_by_address: boolean;
}
/// An item, in a page of `/api/entries` (db.rs EntryRow).
interface Entry {
guid: string;
feed_id: string;
title: string | null;
link: string | null;
published: number | null;
description: string | null;
read: boolean;
flagged: boolean;
image: string | null;
duration: number | null;
episode: number | null;
season: number | null;
position: number;
enclosures: Enclosure[];
}
/// An item's file (db.rs EncRow).
interface Enclosure {
id: number;
/// Seconds DeadAir's copy saves, when you play it from one.
deadair?: number | null;
deadair_pending?: boolean;
feed_id: string;
guid: string;
url: string;
mime: string | null;
length: number | null;
path: string | null;
state: string;
last_error: string | null;
}
/// `/api/me`.
interface Me {
name: string;
admin: boolean;
sign_out: string;
blocked: string[];
deadair_saved: number;
theme: {name: string, mode: string} | null;
version: string;
}
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
interface DirFeed {
id: string;
title: string | null;
image: string | null;
subscribers: number;
subscribed: boolean;
/// Its id in your list, when it is yours.
feed: string | null;
category: string | null;
subcategory: string | null;
podcast: boolean;
}
/// The page's own state (util.ts `S`).
interface State {
feeds: Feed[];
/// The feed or place open: a feed's id, or a view such as ':all'.
feed: string | null;
entries: Entry[];
total: number;
offset: number;
filter: string;
q: string;
/// The selected item's guid.
sel: string | null;
me: Me | null;
sort: {col: string, dir: 'asc' | 'desc'};
}
/// An account, `/api/users` (web.rs list_users).
interface Account {
id: number;
name: string;
admin: boolean;
/// Has a password; none signs in through the proxy.
password: boolean;
created: number | null;
last_login: number | null;
}
/// A line of the daemon's log, `/api/logs` (logbuf.rs LogLine).
interface LogLine {
seq: number;
ts: number;
level: string;
target: string;
msg: string;
}
/// `/api/directory-admin`: the servers sharing with the directory and the feeds it blocks
/// (directory lib.rs Instance, Blocked).
interface DirAdmin {
instances: {name: string, feeds: number, subscribers: number}[];
blocked: {id: string, url: string | null, title: string | null, at: number}[];
}
/// One of a listed feed's latest items, on its page in the Directory (web.rs ListedItem): never
/// its files.
interface ListedItem {
guid: string;
title: string | null;
link: string | null;
published: number | null;
duration: number | null;
description: string | null;
}

View File

@@ -1,15 +1,18 @@
'use strict'; 'use strict';
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the // `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
// smoke test, not the type checker, is what makes sure a selector exists. // smoke test, not the type checker, is what makes sure a selector exists.
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s); const $ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T => r.querySelector<T>(s)!;
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)]; const $$ = <T extends HTMLElement = HTMLElement>(s: string, r: ParentNode = document): T[] => [...r.querySelectorAll<T>(s)];
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); /// What went wrong, from whatever a catch caught: the page throws Errors, but a catch cannot know.
const why = (e: unknown): string => e instanceof Error ? e.message : String(e);
const ESCAPED: Record<string,string> = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[c]);
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com // Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the // License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes // ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag. // the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`; const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={ const ICON: Record<string,string>={
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
@@ -58,25 +61,32 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a // One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
// dialog's confirm button carries the icon of what it does. Words go in the tooltip. // dialog's confirm button carries the icon of what it does. Words go in the tooltip.
// The page's own buttons name their icon; this draws it in, ahead of any label they carry. // The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]); for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon!]);
/// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line /// An episode whose DeadAir copy is still being made: it plays as it is until then, and the line
/// becomes how much shorter it is once the copy is ready (the dead_air event). /// becomes how much shorter it is once the copy is ready (the dead_air event).
const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>'; const DEAD_PENDING='<span class="dapend" title="DeadAir is still working on this episode. Until it is done, it plays at its full length.">Cutting dead air…</span>';
/// What DeadAir took off an episode, as the list says it. /// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; } function shorter(secs: number){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s". /// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){ function spanText(secs: number){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60); const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`; return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
} }
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id, /// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a /// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
/// LAN address over plain HTTP does not have. /// LAN address over plain HTTP does not have. A trace ends after a minute with no request in it:
let traceId=''; /// the requests a live update or a woken laptop sets off hours later used to join the last tap's
/// trace, which then lasted nine hours (#219). A play's position saves, every ten seconds, keep it.
let traceId='', traceAt=0;
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join(''); const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true}); for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); traceAt=Date.now(); },{capture:true,passive:true});
function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; } function traceparent(){
const now=Date.now();
if(!traceId||now-traceAt>60_000) traceId=hex(16);
traceAt=now;
return `00-${traceId}-${hex(8)}-01`;
}
/// Something that went wrong, or how long something took, in the browser, for the server's log /// Something that went wrong, or how long something took, in the browser, for the server's log
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line /// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
@@ -95,13 +105,15 @@ window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e
async function api(url: string, opts?: RequestInit): Promise<any>{ async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts}); const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); } if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status)); // A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
return r.status===204?null:r.json().catch(()=>null); // whole 502 page (#219).
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
return r.status===204?null:r.json().catch((): null=>null);
} }
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN // navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
// address it is undefined, so fall back to the old selection-based copy. // address it is undefined, so fall back to the old selection-based copy.
async function copyText(text,btn){ async function copyText(text: string,btn?: HTMLElement){
const flash=ok=>{ const flash=(ok: boolean)=>{
if(!btn) return; if(!btn) return;
// The button is an icon, so it is the markup that has to come back, not just its text. // The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML; const was=btn.innerHTML;
@@ -135,14 +147,14 @@ async function copyText(text,btn){
/// ///
/// ponytail: a guess from the address. A private feed whose key has another name is shared /// ponytail: a guess from the address. A private feed whose key has another name is shared
/// without asking; add its pattern here when one turns up. /// without asking; add its pattern here when one turns up.
async function share(title: string, url: string, btn?){ async function share(title: string, url: string, btn?: HTMLElement){
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url) if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
&& !confirm('This address looks like it includes your own access to the feed, and anyone you ' && !confirm('This address looks like it includes your own access to the feed, and anyone you '
+'send it to could use it as you.\n\nShare it anyway?')) return; +'send it to could use it as you.\n\nShare it anyway?')) return;
if(navigator.share){ if(navigator.share){
// Cancelling the sheet rejects too, and is not a failure worth a word. // Cancelling the sheet rejects too, and is not a failure worth a word.
try{ await navigator.share({title, url}); } try{ await navigator.share({title, url}); }
catch(e){ if(e.name!=='AbortError') toast(e.message,true); } catch(e){ if((e as Error)?.name!=='AbortError') toast(why(e),true); }
return; return;
} }
await copyText(url,btn); await copyText(url,btn);
@@ -155,12 +167,12 @@ function toast(msg: string, bad?: boolean){
$('#toasts').appendChild(t); $('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200); setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
} }
const clock = s => { const clock = (s: number) => {
s=Math.max(0,Math.floor(s||0)); s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60; const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`; return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
}; };
const ago = t => { const ago = (t: number|null) => {
if(!t) return 'never'; if(!t) return 'never';
const d=(Date.now()/1000)-t; const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago'; if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
@@ -168,16 +180,16 @@ const ago = t => {
if(d<2592000) return Math.round(d/86400)+'d ago'; if(d<2592000) return Math.round(d/86400)+'d ago';
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
}; };
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):''; const dateOf = (t: number|null) => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB". // A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB' const mb = (n: number|null) => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB'; : n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?'; const initials = (s: string|null) => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`; const plural=(n: number,word: string)=>`${n} ${word}${n===1?'':'s'}`;
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match. // An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))']; const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; }; const tint=(name: string)=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){ function tileHTML(name: string,cls?: string){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`; return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
} }
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and /// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
@@ -186,25 +198,25 @@ function tileHTML(name,cls){
function artSrc(url: string){ function artSrc(url: string){
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url; return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
} }
function artHTML(url: string | null, name: string, cls?: string){ function artHTML(url: string | null | undefined, name: string, cls?: string){
return url return url
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">` ? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: tileHTML(name,cls); : tileHTML(name,cls);
} }
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own. /// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
function folderArt(f,kids){ function folderArt(f: Feed,kids: Feed[]){
const art=kids.filter(c=>c.image).slice(0,4); const art=kids.filter(c=>c.image).slice(0,4);
if(art.length<4) return artHTML(f.image,f.title||f.id); if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole. // Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=> return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`; `<img src="${esc(artSrc(c.image!))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
} }
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; } function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */ /* ---------------- state ---------------- */
const S = { const S: State = {
feeds:[], feeds:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where // Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically. // you were instead of jumping to the first feed alphabetically.
@@ -213,25 +225,25 @@ const S = {
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all', filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null, q:'', sel:null, me:null,
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time. // The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })() sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')??'null'); }catch{ return null; } })()
||{col:'published',dir:'desc'}, ||{col:'published',dir:'desc'},
}; };
const LIMIT = 50; const LIMIT = 50;
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']]; const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080}; const UNIT_MINS: Record<string,number> = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes". /// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){ function splitEvery(m: number){
if(!m) return {n:1, u:'h'}; if(!m) return {n:1, u:'h'};
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u}; for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
return {n:m, u:'m'}; return {n:m, u:'m'};
} }
function unitOptions(sel){ function unitOptions(sel: string){
return UNITS.map(([v,l]) => return UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join(''); `<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
} }
function everyText(m){ function everyText(m: number){
if(!m) return '\u2014'; if(!m) return '\u2014';
const {n,u} = splitEvery(m); const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u]; const name = {m:'min', h:'hour', d:'day', w:'week'}[u];