57 Commits
v0.6.0 ... main

Author SHA1 Message Date
2930be37ad Release 0.8.4
The Glass theme (#43), playback handed to a native shell (#45), and the
bottom bar kept clear of the home indicator (#46), which had no changelog
entry of its own. The unreleased compare link had been left at v0.6.1 since
0.7.0; it points at the new tag now.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:27:06 +00:00
680b5d4773 Release 0.8.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:34:13 +00:00
84e4b428e4 List the themes in alphabetical order
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:33:40 +00:00
768d02c840 Catppuccin, Gruvbox, Solarized and High contrast themes
Each in light and dark. The published palettes missed AA in 19 places,
mostly Solarized and Catppuccin Latte, so each failing colour is moved
the least distance, toward black or white, that clears every pair it is
drawn in. Solarized dark's base0 had to rise to base1 to read on base02,
so its dim sits between base2 and base1 to keep three steps of type.

tests/contrast.js checks every palette against the pairs the page draws,
and a border that matches the ground it sits on. Its first run caught
Classic's links at 3.7:1 on the source list and Modern's faint at 4.3:1
on inputs; both are tuned. A failed toast moves to --panel, since the
error colours are tuned for the page's grounds, not --raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:24:56 +00:00
b5ff57ac0f Release 0.8.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:13:06 +00:00
35b57fa997 Settings and the shortcuts list close from the corner
Both have nothing to confirm, so their only button was a lone X at the
foot of the card, below the fold of a long Settings card. A dialog with a
confirm keeps Cancel beside it at the bottom, where the pair belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:45:02 +00:00
43ffafe7ac A UI pass: contrast in every theme, and a sign-in page that fits
Measured every theme's palette against WCAG AA. The unread badge's count
on its --accent2 fill fell as low as 2.6:1 (Flat Remix light), and the
unread dot with it; each theme's --accent2 is taken down until white on
it clears 4.5. Tags were --warn or --bad on --raise, short of AA in most
light themes, so they are outlined on the row's own ground instead.

Nordic dark had --line equal to --panel2, so bordered buttons on a
panel2 ground drew no edge at all. Classic's selected row left the
row's icon buttons grey on the blue. A zero badge on a selected row was
--raise on --raise and vanished.

login.html never had a doctype or viewport meta, so it rendered in
quirks mode and at desktop width on a phone, and its light palette sat
under data-theme="light", which nothing ever set. It follows
prefers-color-scheme now, signed out having no account to ask.

Dropped the unused log and users icons. The Classic theme's label is
now just "Classic".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:37:21 +00:00
45cbd3a239 The scan spinner takes the unread count's place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:21:49 +00:00
905dfa0b02 A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and
"Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and
its folder's, carries a spinner between feed_start and its done, skip or error;
the list refreshes only for the reader's own feeds; the scan toasts are gone, and
"Downloaded" is said only for a file on screen.

"Check every feed" from the web UI sent a scan of every feed on the server.
Command::Fetch takes an optional `feeds` list -- those feeds and the feeds
inside any OPML among them -- and the web fills it with the asker's
subscriptions. The schedule and the CLI send none, meaning every feed.

Closes #37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:17:40 +00:00
5f6bdbfbcb Release 0.8.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:31:56 +00:00
8849ba6bef Merge config-db: the feed catalogue and server settings in the database (#18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:29:16 +00:00
1cafd8d6e3 Keep the feed catalogue and server settings in the database
Phase 3 of #18. Two tables: catalogue (each feed's config::Feed as JSON, so a
new feed setting needs no column) and settings (general: the five server
settings the admin page edits). config.toml keeps what is needed before the
database is reached, or decides who gets in: paths, [torrent], [web].

ipx still runs from one in-memory Config, assembled at start from both
(assemble_config). The eight places that saved config.toml and re-read it now
call Ctx::store_cfg, which writes the database and swaps the copy in memory; the
first-run web token, which is config.toml's, is written there.

The first start on a database with no catalogue imports config.toml's feeds and
settings in one transaction whose first insert is the settings row, so two ipx
starting at once cannot both import; it then trims config.toml, keeping the
original as config.toml.pre-database. After that, feeds written into the file are
ignored with a warning. copy-db skips it, and copies both tables.

Rehearsed on a clone of production's database with production's config: all 130
feeds imported, the file trimmed, and the feed list, settings and directory
identical to the live server's.

Postgres connections now ask for no notices. Every CREATE ... IF NOT EXISTS on an
existing table sends one, eleven per open; sqlx logs them, and
tracing-subscriber 0.3.23's per-layer filters then dropped the next line ipx
logged -- the import's own message went missing that way. Proved by toggling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:28:49 +00:00
f09bb4a11c Revert pinned items rising to the top of their list
Sorting by the pin column, or the Pinned tab, was enough. order_sql loses its
pinned_first option, pinning no longer reloads the list, and the tests and
changelog line for #35 go. The NULLS FIRST/LAST ordering from the Postgres work
stays.

Closes #36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:08:37 +00:00
973ebdd33a The database URL's env file lives beside the compose file
Arcane runs compose in its own container, where /mnt/fast/appdata does not
exist, so an absolute env_file path there failed its update with 'env file not
found'. The file is now ipodderx.env in the content project, referred to
relatively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:56:17 +00:00
bc7491a377 docker-compose.yml: the database URL, as production has it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:45:51 +00:00
c8df148546 Merge seaorm: the database through SeaORM, on SQLite or Postgres (#18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:42:27 +00:00
c1c06229c8 Docs: Postgres in production
Where the database now is and how to reach it, IPX_DATABASE_URL and
IPX_TEST_DATABASE_URL, copy-db, backups, and the title sort on Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:42:27 +00:00
bc53e0f730 Postgres: pick the database by URL, copy-db, tests on both
- IPX_DATABASE_URL (postgres://...) picks the database; unset, it is the SQLite
  file as before. Passwords are taken out of anything logged.
- `ipx copy-db <state.db>` copies every table into the empty database the URL
  names, in one transaction, and moves the id counters past the copied ids. A
  copy of production went across in 14s with every count and column
  fingerprint identical.
- With IPX_TEST_DATABASE_URL set, each test gets a Postgres schema of its own;
  all 79 pass on both databases. Fixtures write booleans as true/false.
- Sorts say where an item with no value goes (NULLS FIRST going up, LAST going
  down): SQLite counts NULL as smallest, Postgres as largest, so "largest first"
  on Postgres led with every item that has no file. Tested on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:28:50 +00:00
bd8f6ab855 Docs: the database through SeaORM
CLAUDE.md and the architecture notes described the SQL schema and migrate(),
both gone: the entities are the schema, create_missing makes what is missing,
and hand-written SQL has to run on SQLite and Postgres both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:11:24 +00:00
611716d8b7 SeaORM: feeds and scanning; rusqlite gone
The last nineteen functions move to SeaORM: recording feeds, items and
enclosures, managed OPML feeds, folding WordPress's repeated files, and handing a
Patreon creator's files to its shows. Two SQLite-only forms go: GLOB becomes a
LIKE with the underscore escaped (broader, harmlessly: the fold still keys on
`_=` and digits), and UPDATE OR IGNORE becomes an UPDATE ... WHERE NOT EXISTS.
The two transactions are SeaORM transactions.

With nothing left on it, rusqlite goes, with the SQL schema and migrate(). The
entities are the schema: create_missing makes whatever tables and indexes a
database lacks, from them, with CREATE ... IF NOT EXISTS. Production's schema
already has every column migrate() added and none it dropped.

Not SeaORM's schema sync, used until now: despite its docs it drops a unique
index the entities do not describe, so it dropped users_name_lower on every open.
Every `ipx` command then took a write lock, and against a daemon busy writing,
`ipx status` -- the healthcheck -- failed 7 times in 15 where the old code
failed none. Now 15 in 15, as before. On Postgres it would not have started.

WAL is set only when a file is not already in it: setting it takes a lock that
cannot wait out a busy daemon.

Checked on copies of production: a forced scan of all 162 feeds against the real
feeds with no database errors; the feed list, filters, sorts, search and the
reaper's candidates against the old code on the same data, earlier in the
branch. The column comments from the SQL schema move to the entities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:10:59 +00:00
a68bfb179b SeaORM: enclosures, downloads and the reaper
Twelve enclosure functions move to SeaORM: recording, the download queue,
marking done or failed, requeueing, and what the reaper may delete. INSERT OR
IGNORE becomes ON CONFLICT DO NOTHING; the reaper's read verdict is true or
false rather than 1 or 0, which Postgres would type as a 32-bit integer and
refuse to read as an i64; `read = 1` and `flagged = 1` test the booleans
themselves. retention::run and its callers (reap, rm, retire_group,
retire_stranded) become async.

The reaper deletes files, so it was checked on a copy of production against the
old SQL on the same file: all 2,195 candidates, identical and in the same order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:38:07 +00:00
6bf1ad6b31 SeaORM: items and read state
The item list, its counts, filters, sorts and search, positions, pins and
mark-all-read move to SeaORM, as SQL written for both databases:

- Parameters are gathered as the SQL is written (Args), so only what a
  statement uses is bound. rusqlite needed every one mentioned, hence the old
  `?1 IS NULL` and `?2 = ''`; Postgres refuses a parameter it cannot type.
- Yes/no columns are tested as booleans (NOT coalesce(s.read, false)) and
  written as true, not 1; SQLite reads true and false as 1 and 0.
- The last tiebreak of the sort is the guid, not SQLite's rowid, which Postgres
  lacks. Only items with the same date change places.
- set_position names entry_state.duration beside excluded.duration.
- The status callback on the control socket returns a future, as the counts
  are now a query.

Checked on a copy of production against the live server: 42 of 48 lists
identical; the other six differ only in how ties fall, or because the test
daemon cleared paths to files this machine does not have. Run on the same file,
every filter's count matches the old SQL exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:32:46 +00:00
d7f8f2df1d SeaORM: subscriptions and pins
Twelve subscription functions move to SeaORM. Lookups use the entity API; the
joins, counts and upserts are SQL written to run on both databases: $n
parameters, ON CONFLICT DO NOTHING in place of INSERT OR IGNORE, and
CASE WHEN on the yes/no column itself rather than comparing it to 1, which
Postgres would refuse for a boolean. INSERT ... SELECT ... ON CONFLICT gets a
WHERE true, which SQLite needs to tell the two apart.

Checked with a daemon on a copy of production: the feed list, read through the
new code, comes back with every feed and its settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:23:37 +00:00
4927677e66 SeaORM: accounts, sessions and themes
The fourteen user and session functions move from rusqlite to SeaORM and become
async; their callers await them (auth, admin_user, user_cmd, the account
handlers). Checked against a copy of production, where the yes/no columns are
still INTEGER: the admin flag reads back right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:17:52 +00:00
484aaa1849 SeaORM beside rusqlite: entities, schema sync, a second connection
The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes.

- src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema.
  Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN
  on Postgres and stays INTEGER in the existing SQLite file (sync notes the
  difference and leaves it alone).
- Db holds a SeaORM connection to the same SQLite file beside the rusqlite one;
  functions move to it one at a time, and rusqlite goes with the last of them.
- db::sync creates what a database is missing from the entities (SeaORM's
  schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes
  an entity cannot express. Checked against a copy of production: it added the
  lower(name) index and changed nothing else.
- Test databases are now built from the entities alone, in a temporary file
  (two connections to one ":memory:" are two databases), so every test also
  checks that the entities describe what the queries need. That caught the one
  difference: finding a user by name relied on COLLATE NOCASE, which Postgres
  lacks; it now compares lower() on both sides.
- rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts,
  and only one may link SQLite. It goes away at the end of this phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:11:28 +00:00
c99e17bd80 A pinned item goes to the top of its list
order_sql takes pinned_first, which puts coalesce(s.flagged, 0) DESC ahead of
the chosen sort, so pins lead every list in whatever order is asked for and on
every page of it. Not when sorting by the pin column itself, where the direction
is the point, and not for Currently Listening. Pinning now asks for the list again
so the row moves at once, instead of redrawing it where it stood.

Closes #35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:51:38 +00:00
e312f11bb1 Remove docs/history.md
It had grown past 1,700 lines, too large to be read or kept up. What it held --
what was wrong before a change and what it cost to find -- goes in commit
message bodies now, beside the change. CLAUDE.md says so; the README and the
changelog no longer point at it. It remains in git history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:47:21 +00:00
a465fa8471 Release 0.7.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:32:10 +00:00
aeb686163b A separate admin page: server settings, accounts and the log
/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.

Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.

The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:28:28 +00:00
2d158a4540 Pin a feed to the top of the feed list
subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).

Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.

Closes #33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:14:47 +00:00
fc425bffa6 Play/pause test: play without decoding the fixture
The fixture file does not reliably decode in the test browser; the load error
paused the player, which rightly turned the buttons back to play, and the test
failed in the full run. The test now fakes play and pause, events included, so it
checks what the buttons do and nothing else. The previous commit went up with
this test failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:09:26 +00:00
42a1136e2e Every play button for what is playing shows pause, and pauses it
Only the player bar's button changed; the files pane's, the row's and the
toolbar's kept showing play while it played. play() now pauses when asked to play
what is already playing, which makes each of them a toggle, and syncPlayButtons()
repaints them on play, pause and ended and whenever the list or reader is drawn.

Closes #34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:01:03 +00:00
53341264a7 On a phone, no "No files" box above an item that has none
The files sit over the text on a phone, so an item without any showed a box
saying so before its text. Nothing is shown now; the desktop files pane already
hid itself when empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:50:36 +00:00
9c16408d04 Keep the theme on the account, not in the browser
- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:33:59 +00:00
9b2537761f Ask for a post's images without a referrer
jeffgeerling.com answers 403 to an image request whose Referer is another
site, so his posts showed a broken image on iOS and the alt text on desktop.
The sanitiser now gives every <img> referrerpolicy="no-referrer".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:23:48 +00:00
3b00e2721a Touch gestures: pull to check for new items, swipe between items
- Pull the item list down from its top: checks the feed (or every feed, on All
  Subscriptions) for new items, which arrive as they do from the scan button.
  overscroll-behavior keeps the browser's own pull-to-reload out of it.
- Swipe the item you are reading left for the next, right for the one before,
  or back to the list from the first. A vertical move is a scroll; something
  that scrolls sideways, or takes typing, keeps its own swipe.

Touch events only, so a mouse never sets them off.

Closes #22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:13:28 +00:00
fc09e8a6b7 Favicon, level file icons, and a feed error mark in the triangle's column
- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
  /favicon.ico outside the auth layer, where a browser asking on its own got a
  401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
  file icon, lifting the icon above its row's; the bar now sits under it without
  taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
  triangle is, in the same column; a folder holding a failing feed has its
  triangle turn red.

Closes #31, #32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:09:38 +00:00
5483355021 Serve the script as /app.js, cached until a deploy changes it
The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.

Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:03:25 +00:00
e2969bcee8 Themes: Dracula, Material, Adwaita, Flat Remix, Paper, Nordic, each light, dark or Auto
The theme picker lists Modern (the old Dark and Light), Classic and six new
palettes, from Dracula's spec (with Alucard), Material 3's baseline scheme,
libadwaita's CSS variables, Flat Remix's _colors.scss, Paper and Nord. A second
setting picks Light, Dark or Auto where a theme has both; Classic and Paper do
not, so it is hidden for them.

The page gets data-mode, light or dark, and Auto is worked out in theme.ts from
the system, so each palette is written once instead of again under a media
query. Every new palette clears WCAG AA for text on its backgrounds. An old
ipx.theme of dark, light or auto carries over as Modern.

Closes #27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:53:25 +00:00
26802d2b23 The page's script is TypeScript in web/src, built and minified with swc
- web/src/*.ts: the script that was inline in index.html and login.html, split along its
  existing sections. Still one scope, concatenated in order, not modules.
- web/build.mjs strips the types, puts the script in the page and minifies it with swc;
  build.rs runs it into OUT_DIR and web.rs include_str!s the result. 137 KB -> 106 KB.
- npx tsc -p . type-checks web/src, loosely; the handful of annotations it needed
  change no behaviour.
- The Docker build installs node and swc (npm ci --omit=dev).
- Two list requests racing no longer let the older one win, and switching tabs clears
  the selection it closes, which made a browser test flaky.

Closes #23, #24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:41:34 +00:00
fbba447ca6 Add a feed without Popular; a phone shows an item's files above its notes
- The Add a feed dialog no longer lists Popular; the sidebar has it (#30).
- On a phone the files, with play and delete, come before the show notes. Below
  them, long notes buried the delete button and it looked missing on iOS (#21).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:21:44 +00:00
d7a4a0b663 Fix the open bugs: read state, Unread tab, feed errors, theme button, log button, relative images
- Opening an item stays read: a list refresh that crossed with the write no longer
  puts the unread dot back (#16).
- On the Unread tab the item you were reading goes when you move to the next (#17).
- Feed errors mark the feed with a red ! instead of a toast per failure (#20).
- The theme is chosen in Settings only (#15).
- The server leaves the Log button out of a non-admin's page, so it no longer flashes (#29).
- Relative images and links in a post resolve against the post's link (#28).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:10:05 +00:00
0443177471 Release 0.6.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:19:44 +00:00
0a83c716eb Time left and finished go by the length the player measured
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file,
which read 0:08 left with 2:33 to play. The player's length is kept in
entry_state beside the position, per listener, where no scan can put
the feed's figure back, and preferred to the feed's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:39:40 +00:00
b9e0d9f3cb Save a position only from a player that has played since its last save
A tab left paused further into an episode saved its older place as it
reloaded, over where the listener had got to since, and the episode
dropped out of Currently Listening. A jump back is now saved at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:29:10 +00:00
53 changed files with 9010 additions and 5736 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
/node_modules
/test-results
/playwright-report
/web/dist

View File

@@ -5,11 +5,162 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The long form, with what was wrong before and how it was found, is in
[docs/history.md](docs/history.md).
## [Unreleased]
## [0.8.4] - 2026-09-19
### Added
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
goes solid when the system asks for less transparency or more contrast.
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
plays through the host's own player instead of the page's, so it keeps going when the screen
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
work as they always did. Video still plays in the page. In a browser nothing changes.
### Fixed
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
home screen. In landscape the bars keep clear of the notch as well.
## [0.8.3] - 2026-09-19
### Security
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
says where the token is kept instead: `[web] token` in config.toml.
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
the email header. Without it, anything on the same Docker host as ipx could send the header. See
docs/sso.md.
### Fixed
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
now on have it.
## [0.8.2] - 2026-09-19
### Added
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
contrast, black and white with every colour at 7:1 or more.
### Changed
- The themes in Settings are listed in alphabetical order.
### Fixed
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
read comfortably. A failed-action message is easier to read in every theme.
## [0.8.1] - 2026-09-19
### Changed
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
"Downloaded" toast is only for a file on your screen.
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
- The Classic theme is listed in Settings as just "Classic".
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
### Fixed
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
drawing at desktop width.
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
- In Nordic's dark half, button and toolbar borders show.
- A zero unread count on the selected feed no longer disappears into the selection.
## [0.8.0] - 2026-09-18
### Added
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
across, everything in one go.
- The catalogue of feeds and the server settings the admin page edits are kept in the database
rather than config.toml, which keeps where things are, the torrent settings and who may sign
in. The first start takes them from config.toml and trims it, keeping the original as
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
### Changed
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
be opened by a 0.7 release first, which brings its tables up to date.
## [0.7.0] - 2026-09-18
### Added
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
A theme chosen before this carries over.
- Your theme is kept on your account rather than in the browser, so it follows you to another
browser or computer, and the page arrives in it with no flash of the default. The theme a
browser already had is saved to your account the first time you load the page.
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
- Touch gestures: pull the item list down from its top to check the feed for new items, and
swipe the item you are reading left for the next one and right for the one before, or back
to the list from the first.
### Changed
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
Settings is now yours alone: your theme and your subscriptions.
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
them, where long notes left them looking missing.
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
`web/src`, type-checked, and built with swc; building ipx needs node.
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
between visits and fetches it again only when an update changes it.
### Fixed
- Switching tabs straight after marking everything read no longer shows the previous tab's
items: of two lists asked for at once, only the later one is shown.
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
their own, and on white for an iPhone's home screen.
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
higher than a downloaded one's.
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
now show: ipx asks for them without saying it is the page showing them.
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
pause, as the player bar's does, and pause it when pressed.
- An item you open stays read. A list refresh that crossed with marking it read could put its
unread dot back until the next refresh.
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
one, rather than a few read items lingering until a refresh cleared them.
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
leaves it out of their page.
- An image or link in a post given relative to the post, such as The Observation Deck's, now
points at the post's site rather than at ipx, and shows.
## [0.6.1] - 2026-09-15
### Fixed
- Time left, and when an episode counts as finished, go by the length your player measured
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
- A player left open in another tab or on another device no longer saves its older place over
where you have got to since, which could drop an episode out of Currently Listening.
## [0.6.0] - 2026-09-15
### Added
@@ -421,7 +572,14 @@ The long form, with what was wrong before and how it was found, is in
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...main
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4

106
CLAUDE.md
View File

@@ -13,15 +13,36 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
| | Host | In the container |
|---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
| 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`) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
`main` and pushed, comment on the issue with what changed and the commit, then close it:
```sh
R="--login git.sdf1.net --repo rays/ipodderx-rs"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
t issues close $R 40
```
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
for later stays open, and that is how it gets picked up again.
Deploying a change is: build and push the image, then pull it and recreate the container.
```sh
@@ -56,23 +77,41 @@ container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
the shell running the command and kills the session (exit 144). This has happened more than once.
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
running the command and kills the session (exit 144), which has happened more than once.
## Before you touch the page
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before
it is visible. It is one file: markup, CSS and script.
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
make up each page's script. `build.rs` runs
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
deploy changes them and their names. So **every page change needs a
rebuild** before it is visible, and building needs node and `npm ci` run once.
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
file goes into that list. Top-level names are kept as they are, because markup calls some by
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
After any edit to it:
```sh
npx tsc -p .
node tests/page-smoke.js
```
That loads the script against a stub DOM and checks every selector it wires at load actually
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed.
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
builds the page as shipped and runs its script against a stub DOM, checking every selector it
wires at load actually exists. That check exists because a patch once anchored on a deleted
function, `String.replace` silently matched nothing, and the whole UI died with a
`ReferenceError` while every server-side test passed.
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.
@@ -80,9 +119,12 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
## Tests
```sh
cargo test # ~51 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
node tests/page-smoke.js
npx playwright test # 16 browser tests against a real daemon on fixture feeds
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 40 browser tests against a real daemon on fixture feeds
```
Things about the browser suite that have cost time:
@@ -108,10 +150,18 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
and `position` columns from before accounts; two bugs came from queries still reading them
(retention, and the entry pruner), and `migrate()` now drops them.
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
(retention, and the entry pruner), and they were dropped in 0.5.
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
and who gets in, the database for the rest (`assemble_config`); a change goes through
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
derived and never written to config.
derived and never in the catalogue.
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
Anyone wanting an item is enough to fetch it.
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
@@ -123,10 +173,16 @@ 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
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.
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
for `daemon started` in the log. Copy `state.db` aside before deploying one.
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
command, the healthcheck's `ipx status` included, so it must never write when nothing is
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
## House style
@@ -137,9 +193,9 @@ addressed to the person using it.
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
say, such as what was wrong before or what it cost to find out, write it up at the top of
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
than once.
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
body, where `git log` and `git blame` find it beside the change. (There was a long-form
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
@@ -150,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
## Known gaps
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
(documented in [docs/sso.md](docs/sso.md)).
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles.
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
issue left open.
<!-- rtk-instructions v2 -->
# Command output

915
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.6.0"
version = "0.8.4"
edition = "2024"
[dependencies]
@@ -12,13 +12,14 @@ axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6"
percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1"
rusqlite = { version = "0.40.2", features = ["bundled"] }
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }

View File

@@ -1,17 +1,23 @@
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
# build.rs builds the web pages from TypeScript with swc, which needs node.
FROM rust:1-slim-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
build-essential nodejs npm \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Dependencies first, so editing the source does not rebuild librqbit every time.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src
COPY build.rs ./
COPY src ./src
COPY web ./web
# cargo skips a rebuild if mtimes look untouched; make sure it does not.

View File

@@ -63,7 +63,6 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
| [docs/history.md](docs/history.md) | How it was built, with what was wrong and why |
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
## Tests
@@ -71,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
```sh
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js # the page script loads without throwing
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```

14
build.rs Normal file
View File

@@ -0,0 +1,14 @@
//! 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

@@ -8,6 +8,9 @@ services:
PGID: "100"
TZ: "America/Toronto"
IPX_LOG: "ipx=info"
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
env_file:
- ipodderx.env # relative: Arcane resolves it inside its own container
ports:
- "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers

View File

@@ -10,7 +10,8 @@ it to a running daemon.
|---|---|---|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
| `src/db.rs` | SQLite schema, migrations, every query | `.ipxd` plists, `history.dat`, `qmcache.dat` |
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
@@ -19,9 +20,15 @@ it to a running daemon.
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `web/index.html` | The whole front end, `include_str!`d into the binary | — |
| `web/index.html` | The app's markup | — |
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
| `web/app.css` | The stylesheet both pages share | — |
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
The page is compiled in, so **editing `web/index.html` needs a rebuild**.
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
build needs node and `npm ci` run once.
## A scan
@@ -59,14 +66,14 @@ entry_state user_id, feed_id, guid, read, flagged, position
```
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 `migrate()` drops them from an
older database.
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
path as `Db::open`, so a migration cannot pass the tests while missing in production.
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
never alters a table that exists, so a new column on one needs its own `ALTER` in
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
database the same way, so the tests run on the schema production gets. A database from before
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
## Control socket
@@ -134,6 +141,7 @@ before they reach the page.
```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```

View File

@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
pkill -x ipx
```
`pkill -f ipx` matches the shell running the command too, and kills your own session.
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
container's processes: stop the one you started by its PID instead (`kill <pid>`).
## Talking to it directly

View File

@@ -1,7 +1,19 @@
# Configuration
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
ignored, with a warning in the log saying so. The sections below describe them as they were
written in config.toml, which is still how a fresh install begins.
config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
| What | Where | Override |
@@ -11,8 +23,10 @@ effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
| Downloads | `[general] download_dir` | — |
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
while the daemon is stopped, or with `sqlite3 state.db .backup`.
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
## `[general]`
@@ -28,6 +42,9 @@ max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"]
```
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
@@ -66,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
token = "" # generated and saved on first run
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"]
access_team = "" # e.g. "<team>.cloudflareaccess.com"
access_aud = "" # the Access application's AUD tag
auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30
@@ -77,6 +96,9 @@ session_days = 30
disables that path. See [sso.md](sso.md).
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
for it. Name the proxy, never a subnet.
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
@@ -88,8 +110,9 @@ itself carry a credential. Put TLS in front of it if that matters.
## `[feeds.<id>]`
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
derives it from the feed title.
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
```toml
[feeds.atp]
@@ -107,8 +130,8 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md).
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
## Environment
@@ -116,6 +139,8 @@ they are re-derived on every scan. Editing one in the UI promotes it to a real c
|---|---|
| `IPX_CONFIG` | Config file path |
| `IPX_DATA_DIR` | Directory holding `state.db` |
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |

File diff suppressed because it is too large Load Diff

View File

@@ -39,11 +39,17 @@ enabled = true
bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
access_team = "rays-sdf1.cloudflareaccess.com"
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
auto_create_users = true
sign_out_url = "/cdn-cgi/access/logout"
session_days = 30
```
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`.
@@ -172,9 +178,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
sides. Never list a LAN address or range: anyone there could then send
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
boundary, and is the upgrade if that ever matters.
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
a container on Tower cannot make one.
### Verifying Cloudflare's token
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
request, and takes the name from its `email` claim.
```toml
[web]
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
```
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
most once a minute. It checks the signature (RS256 only), that the audience is this application's
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
the keys cannot be fetched; password and token sign-in still work then.
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
place.
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
in with them until they are given a password (`ipx user passwd <name>`).

887
package-lock.json generated
View File

@@ -1,12 +1,17 @@
{
"name": "ipx-ui-tests",
"name": "ipx-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipx-ui-tests",
"name": "ipx-web",
"dependencies": {
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
},
"devDependencies": {
"@playwright/test": "^1.56.0"
"@playwright/test": "^1.56.0",
"typescript": "^7.0.2"
}
},
"node_modules/@playwright/test": {
@@ -25,6 +30,847 @@
"node": ">=20"
}
},
"node_modules/@swc/core": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz",
"integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.28"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.16.2",
"@swc/core-darwin-x64": "1.16.2",
"@swc/core-linux-arm-gnueabihf": "1.16.2",
"@swc/core-linux-arm64-gnu": "1.16.2",
"@swc/core-linux-arm64-musl": "1.16.2",
"@swc/core-linux-ppc64-gnu": "1.16.2",
"@swc/core-linux-s390x-gnu": "1.16.2",
"@swc/core-linux-x64-gnu": "1.16.2",
"@swc/core-linux-x64-musl": "1.16.2",
"@swc/core-win32-arm64-msvc": "1.16.2",
"@swc/core-win32-ia32-msvc": "1.16.2",
"@swc/core-win32-x64-msvc": "1.16.2"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz",
"integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz",
"integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz",
"integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz",
"integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz",
"integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz",
"integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz",
"integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz",
"integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz",
"integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz",
"integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz",
"integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz",
"integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/html": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html/-/html-1.16.2.tgz",
"integrity": "sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
},
"engines": {
"node": ">=14"
},
"optionalDependencies": {
"@swc/html-darwin-arm64": "1.16.2",
"@swc/html-darwin-x64": "1.16.2",
"@swc/html-linux-arm-gnueabihf": "1.16.2",
"@swc/html-linux-arm64-gnu": "1.16.2",
"@swc/html-linux-arm64-musl": "1.16.2",
"@swc/html-linux-ppc64-gnu": "1.16.2",
"@swc/html-linux-s390x-gnu": "1.16.2",
"@swc/html-linux-x64-gnu": "1.16.2",
"@swc/html-linux-x64-musl": "1.16.2",
"@swc/html-win32-arm64-msvc": "1.16.2",
"@swc/html-win32-ia32-msvc": "1.16.2",
"@swc/html-win32-x64-msvc": "1.16.2"
}
},
"node_modules/@swc/html-darwin-arm64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz",
"integrity": "sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-darwin-x64": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz",
"integrity": "sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-arm-gnueabihf": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz",
"integrity": "sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-arm64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz",
"integrity": "sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-arm64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz",
"integrity": "sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-ppc64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz",
"integrity": "sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-s390x-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz",
"integrity": "sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-x64-gnu": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz",
"integrity": "sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-linux-x64-musl": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz",
"integrity": "sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-win32-arm64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz",
"integrity": "sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-win32-ia32-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz",
"integrity": "sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/html-win32-x64-msvc": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz",
"integrity": "sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/types": {
"version": "0.1.28",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
@@ -53,6 +899,41 @@
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
}
}
}

View File

@@ -1,13 +1,20 @@
{
"name": "ipx-ui-tests",
"name": "ipx-web",
"private": true,
"description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.",
"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": {
"build": "node web/build.mjs",
"typecheck": "tsc -p .",
"smoke": "node tests/page-smoke.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"smoke": "node tests/page-smoke.js"
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "^1.56.0"
"@playwright/test": "^1.56.0",
"typescript": "^7.0.2"
},
"dependencies": {
"@swc/core": "^1.16.2",
"@swc/html": "^1.16.2"
}
}

215
src/access.rs Normal file
View File

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

View File

@@ -78,6 +78,14 @@ pub struct Web {
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>,
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
/// host any container can send one from the gateway's address.
pub access_team: String,
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
/// leaves the signature unchecked.
pub access_aud: String,
/// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool,
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
@@ -96,6 +104,8 @@ impl Default for Web {
token: String::new(),
trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
access_team: String::new(),
access_aud: String::new(),
auto_create_users: true,
sign_out_url: String::new(),
session_days: 30,
@@ -107,6 +117,12 @@ impl Web {
pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
}
/// Both halves of the Access check, or None while either is unset.
pub fn access(&self) -> Option<(&str, &str)> {
(!self.access_team.is_empty() && !self.access_aud.is_empty())
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -261,14 +277,80 @@ impl Config {
Ok(cfg)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating {}", dir.display()))?;
}
let text = toml::to_string_pretty(self)?;
/// What the database keeps of the configuration (issue #18): the server settings the admin page
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
/// or decides who gets in, and stays in config.toml.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Stored {
pub schedule: String,
pub max_total_gb: f64,
pub max_age_days: u64,
pub max_new_per_check: usize,
pub media_types: Vec<String>,
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
impl Stored {
pub fn of(cfg: &Config) -> Self {
let g = &cfg.general;
Self {
schedule: g.schedule.clone(),
max_total_gb: g.max_total_gb,
max_age_days: g.max_age_days,
max_new_per_check: g.max_new_per_check,
media_types: g.media_types.clone(),
}
}
pub fn apply(self, cfg: &mut Config) {
let g = &mut cfg.general;
g.schedule = self.schedule;
g.max_total_gb = self.max_total_gb;
g.max_age_days = self.max_age_days;
g.max_new_per_check = self.max_new_per_check;
g.media_types = self.media_types;
}
}
impl Config {
/// config.toml as it is kept once the database holds the feeds and server settings: the same
/// file without `[feeds]` or the `[general]` keys in `Stored`.
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
let mut v = toml::Value::try_from(self)?;
if let Some(t) = v.as_table_mut() {
t.remove("feeds");
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
for k in STORED_KEYS {
g.remove(k);
}
}
}
write_private(path, &toml::to_string_pretty(&v)?)
}
/// Whether config.toml still lists feeds or server settings, which the database now holds:
/// an edit there would otherwise go unnoticed.
pub fn file_holds_stored(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
let Ok(v) = text.parse::<toml::Table>() else { return false };
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|| v.get("general")
.and_then(|g| g.as_table())
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
}
}
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
fn write_private(path: &Path, text: &str) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
// Passwords may live in here.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -276,7 +358,6 @@ impl Config {
}
Ok(())
}
}
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
pub fn config_path() -> PathBuf {
@@ -370,6 +451,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests {
use super::*;
#[test]
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
let cfg: Config = toml::from_str(
r#"
[general]
download_dir = "/downloads"
schedule = "every 2h"
max_new_per_check = 7
media_types = ["audio"]
[web]
bind = "0.0.0.0:8099"
token = "t"
[feeds.show]
url = "http://x/show.xml"
"#,
)
.unwrap();
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
cfg.save_bootstrap(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!Config::file_holds_stored(&path), "{text}");
let back: Config = toml::from_str(&text).unwrap();
assert!(back.feeds.is_empty());
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
std::fs::remove_file(&path).unwrap();
}
#[test]
fn parses_a_config_and_applies_defaults() {
let cfg: Config = toml::from_str(

2509
src/db.rs

File diff suppressed because it is too large Load Diff

285
src/entity.rs Normal file
View File

@@ -0,0 +1,285 @@
//! The database's tables as SeaORM entities: the one description of the schema, from which
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
//! `db::create_missing`). Times are Unix seconds.
pub mod feeds {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "feeds")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub id: String,
#[sea_orm(column_type = "Text")]
pub url: String,
#[sea_orm(column_type = "Text", nullable)]
pub title: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub image: Option<String>,
/// The channel's first <itunes:category>, for the Directory.
#[sea_orm(column_type = "Text", nullable)]
pub category: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub etag: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub last_modified: Option<String>,
pub last_checked: Option<i64>,
pub ttl_mins: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub last_error: Option<String>,
/// When the current run of failures began; NULL while the feed is healthy. Kept through
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
/// later) from a feed that has been down for a day.
pub error_since: Option<i64>,
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
#[sea_orm(default_value = false)]
pub orphaned: bool,
/// The OPML subscription this feed came from.
#[sea_orm(column_type = "Text", nullable)]
pub group_id: Option<String>,
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
/// are re-derived instead. Customising one promotes it to config.
#[sea_orm(default_value = false)]
pub managed: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod entries {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "entries")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
#[sea_orm(column_type = "Text", nullable)]
pub title: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub link: Option<String>,
pub published: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub first_seen: i64,
#[sea_orm(column_type = "Text", nullable)]
pub image: Option<String>,
pub duration: Option<i64>,
pub episode: Option<i64>,
pub season: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod enclosures {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "enclosures")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(column_type = "Text")]
pub feed_id: String,
#[sea_orm(column_type = "Text")]
pub guid: String,
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
#[sea_orm(unique, column_type = "Text")]
pub url: String,
#[sea_orm(column_type = "Text", nullable)]
pub mime: Option<String>,
pub length: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>,
#[sea_orm(column_type = "Text")]
pub state: String,
#[sea_orm(default_value = 0)]
pub bytes_done: i64,
pub downloaded_at: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub last_error: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod users {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
#[sea_orm(column_type = "Text")]
pub name: String,
/// NULL for someone who only ever arrives through the proxy: there is no password to
/// check, and leaving it empty is not the same as leaving it unset.
#[sea_orm(column_type = "Text", nullable)]
pub pass_hash: Option<String>,
#[sea_orm(default_value = false)]
pub is_admin: bool,
/// For whoever maintains the server. NULL where it is not known.
pub created: Option<i64>,
pub last_login: Option<i64>,
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
#[sea_orm(column_type = "Text", nullable)]
pub theme: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub theme_mode: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// A table of one person's rows, gone when they are.
macro_rules! owned_by_user {
() => {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_delete = "Cascade"
)]
User,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
};
}
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
/// part that is not. NULL in a column means: follow the feed's own setting.
pub mod subscriptions {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "subscriptions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings; NULL follows the feed.
#[sea_orm(column_type = "Text", nullable)]
pub keywords: Option<String>,
pub auto_download: Option<bool>,
pub allow_explicit: Option<bool>,
pub max_new_per_check: Option<i64>,
/// Pinned to the top of this person's feed list, a feed inside a folder included.
#[sea_orm(default_value = false)]
pub pinned: bool,
}
owned_by_user!();
}
/// Read, kept and how far in. One row per person per item, created on first touch; an item
/// nobody has touched has no row at all, which is what unread means.
pub mod entry_state {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "entry_state")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
#[sea_orm(default_value = false)]
pub read: bool,
#[sea_orm(default_value = false)]
pub flagged: bool,
#[sea_orm(default_value = 0)]
pub position: i64,
/// The length this person's player measured, beside the position it is measured against.
pub duration: Option<i64>,
}
owned_by_user!();
}
pub mod sessions {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "sessions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub token: String,
pub user_id: i64,
pub seen: i64,
}
owned_by_user!();
}
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
pub mod catalogue {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "catalogue")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub id: String,
#[sea_orm(column_type = "Text")]
pub spec: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
/// configuration has moved in (issue #18).
pub mod settings {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "settings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub name: String,
#[sea_orm(column_type = "Text")]
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}

View File

@@ -698,10 +698,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
/// `description` was whole. With no description to fall back on, a damaged body beats none.
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
non_empty(content)
.filter(|c| !starts_mid_tag(c))
.or_else(|| non_empty(description))
.or_else(|| non_empty(content))
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
Some(c) => Some(match subtitle(&c, description) {
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
None => c,
}),
None => non_empty(description).or_else(|| non_empty(content)),
}
}
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
/// description found in the body is not shown twice.
///
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
/// `entries` column.
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
let d = title_text(description)?;
if d.contains('<') || d.chars().count() > 300 {
return None;
}
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
// description ends in "…" or "[...]".
let words = |s: &str| {
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
};
let want = words(&d);
let text = title_text(Some(&text_of(body))).unwrap_or_default();
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
}
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' if in_tag => { in_tag = false; out.push(' '); }
_ if !in_tag => out.push(c),
_ => {}
}
}
out
}
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
@@ -841,12 +882,36 @@ mod tests {
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
}
#[test]
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
// Substack: the subtitle is the description, and content:encoded does not repeat it.
assert_eq!(
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
Some("<p>The post.</p>"),
"a description with markup in it is notes, not a subtitle",
);
assert_eq!(
body(Some("<p>The post.</p>"), Some("Why Q&amp;A threads go wrong")).as_deref(),
Some("<p><em>Why Q&amp;A threads go wrong</em></p><p>The post.</p>"),
);
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
for d in ["This week we talk about tape, drums and a very long list.",
"This week we talk about tape, drums…",
"This week we talk about\ntape [...]"] {
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
}
let long = "word ".repeat(80);
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
"a long description is notes, not a subtitle");
}
#[test]
fn feed_level_explicit_overrides_entries() {
let xml = br#"<?xml version="1.0"?>

View File

@@ -88,6 +88,11 @@ pub enum Command {
feed: Option<String>,
#[serde(default)]
force: bool,
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
/// Empty is every feed, as the schedule and the CLI mean it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
feeds: Vec<String>,
},
Reap {
#[serde(default)]
@@ -176,7 +181,9 @@ pub async fn daemon_is_live(path: &Path) -> bool {
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
/// A future, since reading the counts is a database query.
pub type StatusFn =
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve(
@@ -249,7 +256,7 @@ async fn handle(
// Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(Command::Status) => {
tracing::info!(target: "ipx::io", "-> {line}");
let ev = status();
let ev = status().await;
if let Ok(json) = serde_json::to_string(&ev) {
tracing::info!(target: "ipx::io", "<- {json}");
}
@@ -299,10 +306,10 @@ mod tests {
#[test]
fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true }));
@@ -366,7 +373,8 @@ mod tests {
// Another client, watching a scan: it must not be handed someone else's answer, which
// would end its session.
let mut watcher = events.subscribe();
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
let status: StatusFn =
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status));

File diff suppressed because it is too large Load Diff

View File

@@ -45,28 +45,28 @@ pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
.collect()
}
pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
let mut report = Report::default();
// Someone may have deleted a file by hand; the row must stop claiming it exists.
for (id, path) in db.missing_files()? {
for (id, path) in db.missing_files().await? {
if !dry_run {
db.mark_reaped(id)?;
db.mark_reaped(id).await?;
}
tracing::debug!(path, "file gone, row reaped");
report.reconciled += 1;
}
let candidates = db.reap_candidates()?;
let candidates = db.reap_candidates().await?;
if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
report.aged_out = aged(&candidates, cutoff);
for c in &report.aged_out {
report.bytes_freed += remove(db, c, dry_run)?;
report.bytes_freed += remove(db, c, dry_run).await?;
}
if !dry_run {
report.entries_pruned = db.prune_entries(cutoff)?;
report.entries_pruned = db.prune_entries(cutoff).await?;
}
}
@@ -81,14 +81,14 @@ pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
report.over_quota = pick(&remaining, total, limit);
for c in &report.over_quota {
report.bytes_freed += remove(db, c, dry_run)?;
report.bytes_freed += remove(db, c, dry_run).await?;
}
}
Ok(report)
}
fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
if dry_run {
return Ok(c.bytes.max(0) as u64);
}
@@ -100,7 +100,7 @@ fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
tracing::warn!(path = c.path, error = %e, "could not delete");
return Ok(0);
}
db.mark_reaped(c.id)?;
db.mark_reaped(c.id).await?;
Ok(size)
}
@@ -149,12 +149,12 @@ mod tests {
// age_key 0 means "never recorded" -- not the same as "infinitely old".
}
#[test]
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
#[tokio::test]
async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
// One file serves both subscribers, so it takes both of them to release it.
let db = Db::memory().unwrap();
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'keep', 0),
@@ -163,20 +163,20 @@ mod tests {
('f', 'read', 0);
-- Starred by one of the two, so it stays whatever the other thinks.
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(1, 'f', 'keep', 1, 1),
(2, 'f', 'keep', 1, 0),
(1, 'f', 'half', 1, 0),
(1, 'f', 'read', 1, 0),
(2, 'f', 'read', 1, 0);
(1, 'f', 'keep', true, true),
(2, 'f', 'keep', true, false),
(1, 'f', 'half', true, false),
(1, 'f', 'read', true, false),
(2, 'f', 'read', true, false);
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
)
).await
.unwrap();
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
assert_eq!(
got,
vec![4, 2, 3],
@@ -185,12 +185,12 @@ mod tests {
);
}
#[test]
fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().unwrap();
#[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100),
('f', 'no-file', 100),
@@ -198,9 +198,9 @@ mod tests {
('f', 'recent', 900);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
)
).await
.unwrap();
assert_eq!(db.prune_entries(500).unwrap(), 1, "only the old, fileless, unflagged one");
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
}
}

View File

@@ -16,7 +16,6 @@ use serde::Deserialize;
use tower::ServiceExt;
use tower_http::services::ServeFile;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
@@ -28,15 +27,16 @@ const COOKIE: &str = "ipx_token";
#[derive(Clone)]
pub struct WebState {
pub ctx: Arc<Ctx>,
pub config_path: PathBuf,
pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
}
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/me", get(me))
.route("/api/me", get(me).patch(patch_me))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
@@ -59,12 +59,20 @@ pub fn router(state: WebState) -> Router {
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
.route("/api/logs", get(logs))
.route("/api/events", get(events))
.route("/admin", get(admin_page))
.route("/admin.js", get(admin_js))
.route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth))
// 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("/icon.png", get(icon))
.route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon))
.route("/app.js", get(app_js))
.route("/app.css", get(app_css))
.route("/login.js", get(login_js))
.route("/inter.woff2", get(inter))
.layer(middleware::from_fn(access_log))
.with_state(state)
@@ -93,22 +101,22 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let token = cfg.web.token.clone();
// 1. A header, but only from a hop we were told to believe.
let vouched = vouched_name(&cfg, &req);
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
let mut set_cookie: Option<String> = None;
let mut user = None;
if let Some(name) = vouched {
user = match state.ctx.db.user_by_name(&name) {
user = match state.ctx.db.user_by_name(&name).await {
Ok(Some(u)) => Some(u),
Ok(None) if cfg.web.auto_create_users => {
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
state
.ctx
.db
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
.ok()
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
// The first account made is the admin.
let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
match state.ctx.db.create_user(&name, None, first).await {
Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
Err(_) => None,
}
}
Ok(None) => {
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
@@ -122,7 +130,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
// must not turn anyone away, so its error goes unanswered.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id);
let _ = state.ctx.db.signed_in(u.id).await;
}
}
let by_proxy = user.is_some();
@@ -133,7 +141,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
user = state
.ctx
.db
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
.session_user(&sid, cfg.web.session_days.max(1) * 86_400).await
.unwrap_or(None);
}
}
@@ -147,11 +155,11 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
if user.is_none() && !token.is_empty() {
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
user = admin_user(&state);
user = admin_user(&state).await;
if from_query.is_some() {
// The token link is a sign-in; the cookie it leaves behind is not one each time.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id);
let _ = state.ctx.db.signed_in(u.id).await;
}
set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
@@ -195,20 +203,31 @@ struct Proxied(bool);
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
/// be whoever they liked.
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
let peer = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default();
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
/// token Cloudflare Access signed, and the name is the one in the token.
///
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
/// the future unsendable, as a body is not `Sync`.
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return None;
}
req.headers()
.get(&cfg.web.trusted_header)
.and_then(|v| v.to_str().ok())
.and_then(crate::auth::name_from_header)
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
let Some((team, aud)) = cfg.web.access() else {
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
};
// The plain header still has to be there, as it is what switches this path on for a
// request; who it names is the token's to say.
header(&cfg.web.trusted_header)?;
let token = header("Cf-Access-Jwt-Assertion")?;
state.access.verify(&state.ctx.client, team, aud, token).await
}
fn peer(req: &Request) -> String {
req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default()
}
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
@@ -239,8 +258,8 @@ fn cookie(req: &Request, name: &str) -> Option<String> {
}
/// The account the shared token stands for: the first admin, or the first user at all.
fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().ok()?;
async fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().await.ok()?;
users
.iter()
.find(|u| u.is_admin)
@@ -259,7 +278,7 @@ async fn login(
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase();
let user = state.ctx.db.user_by_name(&name)?;
let user = state.ctx.db.user_by_name(&name).await?;
// The same answer either way: whether a name exists is not something to leak.
let ok = user
.as_ref()
@@ -272,8 +291,8 @@ async fn login(
let user = user.expect("verified above");
let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token)?;
state.ctx.db.signed_in(user.id)?;
state.ctx.db.create_session(user.id, &token).await?;
state.ctx.db.signed_in(user.id).await?;
tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1);
@@ -290,7 +309,7 @@ async fn login(
async fn logout(State(state): State<WebState>, req: Request) -> Response {
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
let _ = state.ctx.db.delete_session(&sid);
let _ = state.ctx.db.delete_session(&sid).await;
}
let mut resp = StatusCode::NO_CONTENT.into_response();
for c in [
@@ -312,7 +331,37 @@ async fn me(
) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
}))
}
#[derive(Deserialize)]
struct MePatch {
theme: String,
mode: String,
}
/// Saves the theme to the account, so it follows the person rather than the browser.
async fn patch_me(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> {
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
Ok(StatusCode::NO_CONTENT)
}
fn theme_ok(theme: &str, mode: &str) -> bool {
(1..=20).contains(&theme.len())
&& theme.bytes().all(|b| b.is_ascii_lowercase())
&& ["light", "dark", "auto"].contains(&mode)
}
// ---- accounts: admin only ----
@@ -340,7 +389,7 @@ async fn list_users(
let users: Vec<_> = state
.ctx
.db
.users()?
.users().await?
.iter()
.map(|u| {
serde_json::json!({
@@ -371,7 +420,7 @@ async fn add_user(
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
})?;
if state.ctx.db.user_by_name(&name)?.is_some() {
if state.ctx.db.user_by_name(&name).await?.is_some() {
return Err(ApiError::bad_request(format!("{name} already exists")));
}
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
@@ -380,7 +429,7 @@ async fn add_user(
} else {
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
};
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
state.ctx.db.create_user(&name, hash.as_deref(), body.admin).await?;
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
Ok(StatusCode::CREATED)
}
@@ -397,7 +446,7 @@ async fn patch_user(
Json(body): Json<UserPatch>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
let users = state.ctx.db.users()?;
let users = state.ctx.db.users().await?;
let target = users
.iter()
.find(|u| u.id == id)
@@ -408,7 +457,7 @@ async fn patch_user(
target.name
)));
}
state.ctx.db.set_admin(id, body.admin)?;
state.ctx.db.set_admin(id, body.admin).await?;
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
Ok(StatusCode::NO_CONTENT)
}
@@ -419,7 +468,7 @@ async fn remove_user(
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
require_admin(&user)?;
let users = state.ctx.db.users()?;
let users = state.ctx.db.users().await?;
let target = users
.iter()
.find(|u| u.id == id)
@@ -430,7 +479,7 @@ async fn remove_user(
target.name
)));
}
state.ctx.db.delete_user(id)?;
state.ctx.db.delete_user(id).await?;
tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT)
}
@@ -438,10 +487,58 @@ async fn remove_user(
/// The password form, except for someone the proxy vouches for: they are signed in already, and
/// the form only made it look as if they were not.
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state.ctx.cfg(), &req).is_some() {
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
return Redirect::to("/").into_response();
}
Html(include_str!("../web/login.html")).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 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) -> Response {
if !user.is_admin {
return Redirect::to("/").into_response();
}
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
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")))
}
fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
}
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
@@ -453,6 +550,25 @@ async fn icon() -> impl IntoResponse {
)
}
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
/// square can be passed over. /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(),
)
}
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
/// one is on white.
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.
@@ -471,8 +587,45 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
async fn index() -> Html<&'static str> {
Html(include_str!("../web/index.html"))
// 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.
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
const ADMIN_LINK: &str = "<a id=admin ";
/// 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) -> impl IntoResponse {
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
([(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);
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)]
@@ -510,6 +663,8 @@ struct FeedRow {
unread: i64,
/// Including you. More than one means every file here is shared.
subscribers: i64,
/// Pinned to the top of your list.
pinned: bool,
}
#[derive(Serialize)]
@@ -528,15 +683,16 @@ async fn feeds(
let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to.
let subs = crate::subscriptions(&state.ctx)?;
let subs = crate::subscriptions(&state.ctx).await?;
let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx
.db
.subscriptions_for(user.id)?
.subscriptions_for(user.id).await?
.into_iter()
.map(|s| (s.feed_id.clone(), s))
.collect();
let counts = state.ctx.db.subscriber_counts()?;
let counts = state.ctx.db.subscriber_counts().await?;
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
let mut out = Vec::with_capacity(mine.len());
for sub in &subs {
let (id, feed) = (&sub.id, &sub.cfg);
@@ -544,8 +700,8 @@ async fn feeds(
// the same fallback the scanner uses (`Db::subscribers`).
let up = feed.group.as_deref().and_then(|g| mine.get(g));
let Some(mine) = mine.get(id) else { continue };
let s = state.ctx.db.feed_summary(id)?;
let st = state.ctx.db.http_state(id)?;
let s = state.ctx.db.feed_summary(id).await?;
let st = state.ctx.db.http_state(id).await?;
out.push(FeedRow {
id: id.clone(),
url: feed.url.clone(),
@@ -595,8 +751,9 @@ async fn feeds(
last_error: s.last_error,
entries: s.entries,
downloaded: s.downloaded,
unread: state.ctx.db.unread_count(user.id, id)?,
unread: state.ctx.db.unread_count(user.id, id).await?,
subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id),
});
}
Ok(Json(out))
@@ -654,13 +811,13 @@ struct PopularRow {
/// first. Popular is the top of it, the directory is all of it, and it is all that
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
/// feeds inside it and never itself: both lists are for finding a show.
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db;
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?;
let media = db.media_feeds()?;
let catalogue = crate::subscriptions(&state.ctx)?;
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 by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_folder: std::collections::HashSet<&str> =
@@ -677,7 +834,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
{
continue;
}
let sum = db.feed_summary(&s.id)?;
let sum = db.feed_summary(&s.id).await?;
let subscribed = mine.contains(&s.id);
out.push(PopularRow {
id: s.id.clone(),
@@ -698,7 +855,7 @@ async fn get_popular(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?;
let mut rows = popular(&state, user.id).await?;
rows.truncate(10);
Ok(Json(rows))
}
@@ -708,7 +865,7 @@ async fn get_directory(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?;
let mut rows = popular(&state, user.id).await?;
rows.sort_by_key(sort_name);
Ok(Json(rows))
}
@@ -724,10 +881,10 @@ async fn subscribe_popular(
user: crate::db::User,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
}
state.ctx.db.subscribe(user.id, &id)?;
state.ctx.db.subscribe(user.id, &id).await?;
Ok(Json(serde_json::json!({ "id": id })))
}
@@ -780,6 +937,13 @@ impl ApiError {
status: StatusCode::FORBIDDEN,
}
}
fn not_found(msg: impl Into<String>) -> Self {
Self {
error: anyhow::Error::msg(msg.into()),
status: StatusCode::NOT_FOUND,
}
}
}
impl IntoResponse for ApiError {
@@ -795,6 +959,38 @@ impl IntoResponse for ApiError {
mod tests {
use super::*;
#[test]
fn a_relative_image_resolves_against_the_post() {
let mut b = feed_sanitizer();
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/"));
assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
assert!(!out.contains("bad()"), "{out}");
assert!(out.contains(r#"referrerpolicy="no-referrer""#), "{out}");
assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
}
#[test]
fn only_an_admin_is_sent_the_admin_link() {
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
let page = page_for(false, (None, None));
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");
}
#[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 {
@@ -939,7 +1135,7 @@ async fn entries(
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
entry_page(&state, user.id, Some(&id), &page)
entry_page(&state, user.id, Some(&id), &page).await
}
/// Every subscribed feed's items together, newest first: All Subscriptions.
@@ -948,11 +1144,11 @@ async fn all_entries(
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
entry_page(&state, user.id, None, &page)
entry_page(&state, user.id, None, &page).await
}
/// One feed's page of items, or every subscribed feed's when `feed` is None.
fn entry_page(
async fn entry_page(
state: &WebState,
user_id: i64,
feed: Option<&str>,
@@ -961,26 +1157,43 @@ fn entry_page(
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let db = &state.ctx.db;
let order = crate::db::order_sql(
page.sort.as_deref().unwrap_or("published"),
page.dir.as_deref().unwrap_or("desc"),
);
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
// keeps that safe -- so following one in show notes never navigates away from ipx.
let mut sanitizer = ammonia::Builder::new();
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
let mut sanitizer = feed_sanitizer();
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(sanitizer.clean(d).to_string());
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
}
}
let total = db.count_in(user_id, feed, filter, search)?;
let total = db.count_in(user_id, feed, filter, search).await?;
Ok(Json(EntryPage { total, entries: rows }))
}
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
fn feed_sanitizer() -> ammonia::Builder<'static> {
let mut b = ammonia::Builder::new();
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
// keeps that safe -- so following one in show notes never navigates away from ipx.
b.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
// An image is asked for without saying it is shown on ipx. A site that refuses images to
// other sites' pages goes by that: jeffgeerling.com answers 403, and his posts showed a
// broken image on iOS and only the alt text on desktop.
b.add_tag_attributes("img", &["referrerpolicy"]).set_tag_attribute_value("img", "referrerpolicy", "no-referrer");
b
}
/// A relative `src` or `href` in a post means relative to the post, not to ipx: The
/// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image.
fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {
let base = link.and_then(|l| url::Url::parse(l).ok());
sanitizer.url_relative(match base {
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
None => ammonia::UrlRelative::PassThrough,
});
sanitizer.clean(html).to_string()
}
#[derive(Deserialize)]
struct NewFeed {
url: String,
@@ -995,10 +1208,10 @@ struct NewFeed {
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
/// goes on your subscription, and before the first scan, which would otherwise skip every
/// explicit item.
fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
async fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
if allow {
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
state.ctx.db.set_subscription(user_id, &sub)?;
state.ctx.db.set_subscription(user_id, &sub).await?;
}
Ok(())
}
@@ -1012,14 +1225,14 @@ async fn add_feed(
let url = crate::feed::expand_input(&body.url);
// Someone else may already have it. Then adding costs nothing: no second fetch, no
// second copy on disk, just another name against the same feed.
if let Some(existing) = crate::subscriptions(&state.ctx)?
if let Some(existing) = crate::subscriptions(&state.ctx).await?
.into_iter()
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
{
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
state.ctx.db.subscribe(user.id, &existing.id)?;
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
state.ctx.db.subscribe(user.id, &existing.id).await?;
if !already {
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit)?;
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
}
scan_soon(&state, Some(existing.id.clone())).await;
return Ok(Json(
@@ -1027,10 +1240,9 @@ async fn add_feed(
));
}
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
state.ctx.db.subscribe(user.id, &id)?;
explicit_on_add(&state, user.id, &id, body.allow_explicit)?;
state.ctx.store_cfg(cfg).await?;
state.ctx.db.subscribe(user.id, &id).await?;
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
}
@@ -1040,7 +1252,7 @@ async fn add_feed(
/// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
}
}
@@ -1064,6 +1276,7 @@ struct FeedPatch {
auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
max_new_per_check: Option<Option<usize>>,
pinned: Option<bool>,
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
@@ -1080,13 +1293,19 @@ async fn patch_feed(
user: crate::db::User,
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
if let Some(on) = body.pinned
&& !state.ctx.db.set_pinned(user.id, &id, on).await?
{
return Err(ApiError::not_found("you do not subscribe to that feed"));
}
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.
if state.ctx.db.subscription(user.id, &id)?.is_some() {
if state.ctx.db.subscription(user.id, &id).await?.is_some() {
let mut mine = state
.ctx
.db
.subscription(user.id, &id)?
.subscription(user.id, &id).await?
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
let mut touched = false;
if let Some(v) = body.keywords.clone() {
@@ -1106,7 +1325,7 @@ async fn patch_feed(
touched = true;
}
if touched {
state.ctx.db.set_subscription(user.id, &mine)?;
state.ctx.db.set_subscription(user.id, &mine).await?;
}
}
@@ -1127,13 +1346,13 @@ async fn patch_feed(
// Derived feeds have no config entry. Editing one is the moment it earns a real
// entry: promote it, so the config holds your decisions and nothing else.
if !cfg.feeds.contains_key(&id) {
let subs = crate::subscriptions(&state.ctx)?;
let subs = crate::subscriptions(&state.ctx).await?;
let found = subs
.iter()
.find(|s| s.id == id)
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
cfg.feeds.insert(id.clone(), found.cfg.clone());
state.ctx.db.unmanage(&id)?;
state.ctx.db.unmanage(&id).await?;
}
let checked = match &body.url {
@@ -1169,12 +1388,11 @@ async fn patch_feed(
if let Some(v) = body.category {
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
state.ctx.store_cfg(cfg).await?;
if url_changed {
// Refreshing a rotated auth token is the common case; entries and download history
// are keyed by feed id, so they survive the change.
state.ctx.db.clear_validators(&id)?;
state.ctx.db.clear_validators(&id).await?;
}
Ok(StatusCode::NO_CONTENT)
}
@@ -1186,14 +1404,14 @@ async fn remove_feed(
) -> Result<StatusCode, ApiError> {
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
// else's alone.
state.ctx.db.unsubscribe(user.id, &id)?;
for child in crate::subscriptions(&state.ctx)?
state.ctx.db.unsubscribe(user.id, &id).await?;
for child in crate::subscriptions(&state.ctx).await?
.iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
{
state.ctx.db.unsubscribe(user.id, &child.id)?;
state.ctx.db.unsubscribe(user.id, &child.id).await?;
}
if state.ctx.db.subscriber_counts()?.contains_key(&id) {
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
return Ok(StatusCode::NO_CONTENT);
}
@@ -1203,12 +1421,11 @@ async fn remove_feed(
if cfg.feeds.remove(&id).is_none() {
// A derived feed: forget it here, though the OPML will list it again on the next
// read unless you unsubscribe from the OPML itself.
state.ctx.db.drop_managed(&id)?;
state.ctx.db.drop_managed(&id).await?;
return Ok(StatusCode::NO_CONTENT);
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
crate::retire_group(&state.ctx, &id)?;
state.ctx.store_cfg(cfg).await?;
crate::retire_group(&state.ctx, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -1226,10 +1443,10 @@ async fn set_flags(
) -> Result<StatusCode, ApiError> {
use crate::db::EntryFlag;
if let Some(v) = body.read {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v)?;
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
}
if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v)?;
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
}
Ok(StatusCode::NO_CONTENT)
}
@@ -1244,12 +1461,12 @@ async fn download_now(
let enc = state
.ctx
.db
.enclosure(id)?
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here.
}
state.ctx.db.requeue(id)?;
state.ctx.db.requeue(id).await?;
state
.cmds
.send(Command::Download { enclosure: id })
@@ -1273,13 +1490,13 @@ async fn delete_file(
let enc = state
.ctx
.db
.enclosure(id)?
.enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
// it, once, and let them decide.
if !q.force {
let (starred, unread) = state.ctx.db.others_wanting(id, user.id)?;
let (starred, unread) = state.ctx.db.others_wanting(id, user.id).await?;
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
let complaint = match (starred, unread) {
(0, 0) => None,
@@ -1305,7 +1522,7 @@ async fn delete_file(
return Err(e.into());
}
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
state.ctx.db.mark_reaped(id)?;
state.ctx.db.mark_reaped(id).await?;
state.events.send(Event::Reaped {
path: enc.path.unwrap_or_default(),
bytes: enc.length.unwrap_or(0).max(0) as u64,
@@ -1323,11 +1540,21 @@ struct FetchBody {
async fn fetch_now(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> {
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
let feeds = if body.feed.is_some() {
vec![]
} else {
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
};
if body.feed.is_none() && feeds.is_empty() {
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
}
state
.cmds
.send(Command::Fetch { feed: body.feed, force: body.force })
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED)
@@ -1359,7 +1586,7 @@ async fn media(
Path(id): Path<i64>,
req: Request,
) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
let Some(path) = enc.path else {
@@ -1384,7 +1611,7 @@ async fn set_position(
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration)?;
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -1396,12 +1623,12 @@ async fn read_all(
// A subscription's own row has no entries, so marking it read means everything under it.
let mut ids = vec![id.clone()];
ids.extend(
crate::subscriptions(&state.ctx)?
crate::subscriptions(&state.ctx).await?
.into_iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
.map(|s| s.id),
);
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
Ok(Json(serde_json::json!({ "marked": n })))
}
@@ -1412,8 +1639,8 @@ async fn read_all_mine(
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
let ids: Vec<String> =
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
Ok(Json(serde_json::json!({ "marked": n })))
}
@@ -1434,9 +1661,9 @@ async fn download_latest(
Path(id): Path<String>,
Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> {
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
for enc in &ids {
state.ctx.db.requeue(*enc)?;
state.ctx.db.requeue(*enc).await?;
state
.cmds
.send(Command::Download { enclosure: *enc })
@@ -1454,7 +1681,7 @@ async fn export_opml(
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
// not yours to download. This used to export config.toml to whoever asked.
let mine: std::collections::HashSet<String> =
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
let mut doc = opml::OPML {
head: Some(opml::Head {
title: Some("ipx subscriptions".into()),
@@ -1462,7 +1689,7 @@ async fn export_opml(
}),
..Default::default()
};
for s in crate::subscriptions(&state.ctx)? {
for s in crate::subscriptions(&state.ctx).await? {
// A feed from an OPML subscription comes back with the OPML itself.
if s.managed || !mine.contains(&s.id) {
continue;
@@ -1470,7 +1697,7 @@ async fn export_opml(
let title = state
.ctx
.db
.feed_summary(&s.id)
.feed_summary(&s.id).await
.ok()
.and_then(|sum| sum.title)
.unwrap_or_else(|| s.id.clone());
@@ -1504,7 +1731,7 @@ async fn import_opml(
// file arrives as text, is read here, and is gone when the request ends.
let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id)?;
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
if added > 0 {
scan_soon(&state, None).await;
}
@@ -1578,8 +1805,7 @@ async fn patch_settings(
if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v;
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
state.ctx.store_cfg(cfg).await?;
Ok(StatusCode::NO_CONTENT)
}

96
tests/contrast.js Normal file
View File

@@ -0,0 +1,96 @@
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
//
// node tests/contrast.js
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
const blocks = {};
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
const vars = {};
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
if (Object.keys(vars).length) blocks[m[1]] = vars;
}
const lum = h => {
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
};
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
const RULES = [
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
['ink', ['accent'], 4.5], // a primary button's label
['ink', ['accent2'], 4.5], // the unread count on its badge
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
];
const base = blocks[':root'];
const themes = {};
for (const sel of Object.keys(blocks)) {
const t = sel.match(/data-theme="(\w+)"/);
if (!t) continue;
const light = /data-mode="light"/.test(sel);
// A theme's dark half is :root under its own block; its light half adds the light block.
const name = `${t[1]}${light ? ' light' : ''}`;
themes[name] = light
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
: { ...base, ...blocks[sel] };
}
themes['modern'] = base;
let bad = 0;
for (const [name, p] of Object.entries(themes)) {
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
const r = ratio(p[fg], p[g]);
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
}
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
}
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
const rgba = (sel, n) => {
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
return [[+m[1], +m[2], +m[3]], +m[4]];
};
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
const p = themes[name];
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
const worst = {};
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
let g = rgb(p.bg);
for (const [w, [c, a]] of tint) {
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
g = over(c, a * Math.max(0, 1 - d), g);
}
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it.
for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]])
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
if (!(k in worst) || r < worst[k]) worst[k] = r;
}
}
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
for (const [k, r] of Object.entries(worst))
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
}
if (bad) process.exit(1);
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);

Binary file not shown.

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

Binary file not shown.

View File

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

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

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

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

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

View File

@@ -1,4 +1,5 @@
// Executes web/index.html's script against a stub DOM and fails on anything thrown.
// Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
//
// This exists because a ReferenceError at load once blanked the whole UI: a patch
// anchored on a function that no longer existed, so `prefsModal` was referenced but
@@ -6,65 +7,23 @@
// and every server-side test passed too, because the server was fine.
//
// node tests/page-smoke.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { makeContext, vm } = require('./dom-stub.js');
const PAGE = process.argv[2] || 'index.html';
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8');
const script = html.split('<script>')[1].split('</script>')[0];
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
const document = {
querySelector(sel) {
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body: el('body'),
documentElement: { dataset: {} },
};
const ctx = {
document, console,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
ctx.globalThis = ctx;
ctx.window.location = { href: '' };
const { buildPage } = require('../web/build.mjs');
// What ships: minified, so an id may have lost its quotes.
const { html, js: script, script: file } = buildPage(PAGE);
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
}
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
const { ctx, missing } = makeContext(html, script);
try {
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
} catch (e) {
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
process.exit(1);
@@ -78,19 +37,21 @@ const feed = {
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
};
const drive = [
const drive = PAGE === 'admin.html' ? [
['drawServer', () => ctx.drawServer()],
['drawAccounts', () => ctx.drawAccounts()],
['drawLogView', () => ctx.drawLogView()],
] : [
['settingsModal', () => ctx.settingsModal(feed)],
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
['removeFeed', () => ctx.removeFeed(feed)],
['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()],
['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.
@@ -108,10 +69,18 @@ for (const [name, fn] of drive) {
}
}
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
if (missing.length) {
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
process.exit(1);
}
console.log('OK: page script loads clean, every selector it wires at load exists');
// logsModal arms a poll timer; without this the pending interval keeps node alive.
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
if (PAGE === 'index.html') {
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
process.exit(r.status);
}
process.exit(0);

View File

@@ -20,62 +20,130 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
expect(errors, 'the page script must not throw at load').toEqual([]);
});
test('the theme toggle actually changes the theme', async ({ page }) => {
// Regression: this button was wired after a line that threw, so it did nothing.
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
await page.locator('#theme').click();
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
.not.toBe(before);
test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
const doc = await page.reload();
const r = await js;
// The page is checked every visit, so it always names the current script...
expect(doc.headers()['cache-control']).toBe('no-cache');
// ...by a hash of its contents, which is why the script itself can be kept for a year.
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
expect(r.headers()['cache-control']).toContain('immutable');
expect(r.headers()['content-type']).toContain('javascript');
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
// The sign-in page's script loads before signing in.
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
expect(login.status()).toBe(200);
});
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
expect(await theme()).toBe('classic');
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
await page.locator('#prefs').click();
await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
await page.locator('#stheme').selectOption('modern');
await page.locator('#smode').selectOption('auto');
// Auto follows the system, live, with no reload.
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(root).toEqual(['modern', 'light']);
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(root).toEqual(['modern', 'dark']);
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
// Dracula, then its light half, Alucard.
await page.locator('#stheme').selectOption('dracula');
await page.locator('#smode').selectOption('dark');
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
await page.locator('#smode').selectOption('light');
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
// Classic and Paper come one way only, so there is nothing to choose.
await page.locator('#stheme').selectOption('paper');
await expect(page.locator('#smode')).toBeHidden();
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
// The save that says Classic: the ones before it may still be answering.
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
&& r.request().postDataJSON().theme === 'classic');
await page.locator('#stheme').selectOption('classic');
await expect(page.locator('#smode')).toBeHidden();
await saved; // kept on the account, not the browser
await page.reload();
await expect.poll(theme).toBe('classic');
await expect.poll(root).toEqual(['classic', 'light']);
// The 2004 Mac app set its type in Lucida Grande.
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
await page.locator('#prefs').click();
await page.locator('#stheme').selectOption('nordic');
await expect(page.locator('#smode')).toHaveValue('light');
await page.locator('#smode').selectOption('dark');
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
});
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
await page.locator('#prefs').click();
await expect(page.locator('#stheme')).toHaveValue(await theme());
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#stheme').selectOption('flatremix');
expect((await saved).status()).toBe(204);
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#smode').selectOption('light');
await saved2;
await page.locator('#stheme').selectOption('auto');
await expect.poll(theme).toBe('auto');
// Auto follows the system; emulating a light system must show the light palette live,
// no reload needed, since it is a media query rather than something JS picks per click.
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
// The header button and the dropdown are the same one setting, not two.
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
await page.locator('#theme').click();
expect(await theme()).toBe('dark');
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
// written onto <html> by the server rather than set once the script has run.
const other = await browser.newContext();
const p2 = await other.newPage();
const res = await p2.goto(`/?token=${TOKEN}`);
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['flatremix', 'light']);
await other.close();
});
test('settings opens and saves the global schedule', async ({ page }) => {
await page.locator('#prefs').click();
await expect(page.locator('#modal.on')).toBeVisible();
await expect(page.locator('#gnum')).toBeVisible();
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
const who = `theme-${Date.now()}@example.com`;
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
const page = await ctx.newPage();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.goto('/');
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['modern', 'light']);
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
await ctx.close();
// Anywhere else now, it comes from the account.
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
const p2 = await fresh.newPage();
const res = await p2.goto('/');
expect(await res.text()).toContain('data-theme=modern data-choice=light');
await fresh.close();
});
test('the admin page saves the global schedule', async ({ page }) => {
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
await page.locator('#admin').click();
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator('#atabs a.on')).toHaveText('Server');
await page.locator('#gnum').fill('4');
await page.locator('#gunit').selectOption('h');
await page.locator('#gsave').click();
await expect(page.locator('#modal.on')).toBeHidden();
await expect(page.locator('#toasts')).toContainText('Settings saved');
// It must survive a reload, i.e. actually reach the config.
await page.locator('#prefs').click();
await page.reload();
await expect(page.locator('#gnum')).toHaveValue('4');
await expect(page.locator('#gunit')).toHaveValue('h');
// And Settings in the app no longer has it.
await page.goto('/');
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Theme');
await expect(page.locator('#gnum')).toHaveCount(0);
});
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
@@ -120,6 +188,35 @@ test('the three panes are there and the item text lands in the bottom one', asyn
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
});
test('while an episode plays, its play buttons all say pause, and pause it', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
await expect(downloaded).toBeVisible({ timeout: 20_000 });
await downloaded.click();
// What the buttons do, not whether this browser decodes the fixture: it does not, reliably,
// and a load error pauses the player, rightly turning every button back to play. So the
// player plays and pauses here as a real one does, events and all, with no file behind it.
await page.evaluate(() => {
let paused = true;
Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true });
audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); };
audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); };
});
const pane = page.locator('#files [data-a="play"]');
await pane.click();
await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true);
// The files pane, the row and the toolbar all follow the player bar, not only the bar.
await expect(pane).toHaveAttribute('title', 'Pause');
await expect(downloaded.locator('[data-a="play"]')).toHaveAttribute('title', 'Pause');
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Pause');
await pane.click(); // and pressing it pauses
await expect.poll(() => page.evaluate(() => audio.paused)).toBe(true);
await expect(pane).toHaveAttribute('title', 'Play');
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Play the selected item');
await page.locator('#pclose').click();
});
test('a downloaded file that is not audio gets no player', async ({ page }) => {
// Regression: anything with a file got an <audio> element and a play button, so a blog's
// header image rendered as a broken player.
@@ -200,6 +297,26 @@ test('Currently Listening, its own place below Popular, resumes an episode you s
await expect(row.locator('.eq')).toBeHidden();
});
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
const sent = await page.evaluate(() => {
Object.defineProperty(audio, 'readyState', { get: () => 4 });
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
let n = 0;
navigator.sendBeacon = () => (n++, true);
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
savePos(); // what a reload, a pause or the close button calls
const idle = n;
player.moved = true; // what playing sets
savePos();
savePos(); // and once saved, it is idle again
return [idle, n];
});
expect(sent).toEqual([0, 1]);
});
test('the filter tabs change what is listed', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
@@ -213,6 +330,32 @@ test('the filter tabs change what is listed', async ({ page }) => {
await expect(page.locator('#count')).toContainText('0 items');
});
test('on the Unread tab an item stays while you read it and goes when you move on', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
// Earlier tests read things; make the first two unread with their own dots.
for (const i of [0, 1]) {
const row = page.locator('.ep').nth(i);
if (await row.evaluate(r => r.classList.contains('read'))) {
await row.locator('[data-a="read"]').click();
await expect(page.locator('.ep').nth(i)).not.toHaveClass(/\bread\b/);
}
}
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
const first = page.locator('.ep').first();
const guid = await first.getAttribute('data-guid');
await first.click();
const it = page.locator(`.ep[data-guid="${guid}"]`);
await expect(it).toHaveClass(/\bread\b/);
await page.waitForTimeout(1500); // past the SSE refresh debounce and loadFeeds
await expect(it).toBeVisible();
await page.locator('.ep').nth(1).click();
await expect(it).toHaveCount(0);
await page.locator('.tabs button', { hasText: 'All' }).first().click();
});
test('a feed URL is editable and has a copy button', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('#content .acts [data-a="settings"]').click();
@@ -228,7 +371,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
});
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
await page.locator('#logs').click();
await page.goto('/admin#log');
await expect(page.locator('#logbox')).toBeVisible();
await expect(page.locator('#logtabs button')).toHaveCount(4);
@@ -426,18 +569,22 @@ test('a second person has their own feeds and their own read state', async ({ br
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
await expect(page.locator('#feedlist')).toContainText('No feeds.');
// Settings stays: Sam has their own subscriptions to export and import, and the
// schedule and quota are worth seeing even without a say in them. Only the log and the
// users screen -- and the server -- are an admin's alone.
// Settings stays: Sam has their own theme and subscriptions. The admin page -- the server's
// settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
// link to it, never mind the page.
await expect(page.locator('#prefs')).toBeVisible();
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#gsave')).toBeHidden();
await expect(page.locator('#gusers')).toBeHidden();
await page.locator('#modalCard .cardacts .btn').first().click();
// Hiding the button is not the guard; the server is.
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardx').click();
await expect(page.locator('#admin')).toHaveCount(0);
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
// Asking for it anyway goes back to the app, and its script is refused.
await page.goto('/admin');
await expect(page).toHaveURL(/:8791\/$/);
expect((await page.request.get('/admin.js')).status()).toBe(403);
// Hiding the way in is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403);
await expect(page.locator('#logs')).toBeHidden();
expect((await page.request.get('/api/logs')).status()).toBe(403);
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
@@ -493,11 +640,12 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
// Every row says "Admin" on its checkbox, so match the name exactly.
const userRow = (page, name) =>
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
async function openUsers(page) {
await page.locator('#prefs').click();
await page.locator('#gusers').click();
await page.goto('/admin');
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
await expect(page).toHaveURL(/\/admin#accounts$/);
await expect(userRow(page, 'admin')).toBeVisible();
}
@@ -743,9 +891,10 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await pick('tabs', 'All').click();
await expect(tiles).toHaveCount(dir.length);
// Add a feed opened over Directory fills its own list, not the pane behind it.
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
await piper.locator('#addFeed').click();
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
await expect(piper.locator('#nurl')).toBeVisible();
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length);
await piper.locator('#modalCard button[title="Cancel"]').click();
@@ -822,9 +971,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
const dialogs = [
() => page.locator('#addFeed').click(),
() => page.locator('#prefs').click(),
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
() => page.locator('#logs').click(),
() => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(),
@@ -849,6 +996,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
// its own button makes it unread again.
await page.locator('.ep').first().click();
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
// begin with, and a mark-unread still in flight could land after the read-all below.
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
await expect(all.locator('.badge')).not.toHaveText('0');
page.once('dialog', d => d.accept());
@@ -995,3 +1145,199 @@ test('the pinned heading sits over its pins, and the page is set in Inter', asyn
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test('a downloaded file can be deleted, from above the show notes', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
await page.locator('.ep').first().click();
const del = page.locator('#detail [data-a="del"]');
await expect(del).toBeInViewport();
// Below a long set of notes it was screens down and looked missing (issue #21).
expect(await page.locator('#detail').evaluate(d =>
!!(d.querySelector('.encbox').compareDocumentPosition(d.querySelector('.dbody')) & Node.DOCUMENT_POSITION_FOLLOWING)))
.toBe(true);
});
});
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
// The icon's middle against the date's, downloaded or not. The download bar used to take a
// line of its own and lift the icon of every pending file (issue #31).
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
if (!k || !d) return null;
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
}).filter(x => x !== null));
expect(offsets.length).toBeGreaterThan(1);
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
});
test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
// A browser asks for /favicon.ico on its own, signed in or not.
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
const r = await page.request.get(path, { headers: { cookie: '' } });
expect(r.status(), path).toBe(200);
expect(r.headers()['content-type'], path).toBe('image/png');
}
});
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
await page.locator('.feed.group .chev').first().click();
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
await page.evaluate(() => {
S.feeds.find(f => f.group).last_error = 'HTTP 404';
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
renderFeeds();
});
await expect(page.locator('.ferr')).toHaveCount(2);
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
const xs = await page.$$eval('.chev, .ferr', els =>
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
await page.reload(); // put the real list back
});
test.describe('touch gestures on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
async function drag(page, from, to) {
const cdp = await page.context().newCDPSession(page);
const steps = 8;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
for (let i = 1; i <= steps; i++)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
}
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
const titles = await page.locator('.ep .t').allTextContents();
await page.locator('.ep').first().click();
const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
await expect(page.locator('body')).not.toHaveClass(/reading/);
});
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
const box = await page.locator('#list').boundingBox();
const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST');
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
});
});
test.describe('an item with no files, on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test('shows no files box at all', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
// An item with no enclosure, found from the list the page itself has.
const guid = await page.evaluate(() => S.entries.find(e => !e.enclosures.length)?.guid);
expect(guid, 'the fixtures have an item with no files').toBeTruthy();
await page.locator(`.ep[data-guid="${guid}"]`).click();
await expect(page.locator('#detail .dt')).toBeVisible();
await expect(page.locator('#detail .encbox')).toHaveCount(0);
await expect(page.locator('#detail')).not.toContainText('No files');
});
});
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
const rows = page.locator('#feedlist .feed');
const group = page.locator('.feed.group').first();
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
const child = page.locator('.feed.child').first();
const name = (await child.locator('.txt b').textContent()).trim();
await child.click();
await page.locator('#content .acts [data-a="pin"]').click();
// First in the list, out of its folder, marked, and with the rule under it.
await expect(rows.first().locator('.txt b')).toHaveText(name);
await expect(rows.first()).toHaveClass(/\bpinned\b/);
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
await expect(rows.first()).toHaveClass(/\blastpin\b/);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
// It is on the account: a reload keeps it.
await page.reload();
await expect(rows.first().locator('.txt b')).toHaveText(name);
// Unpinned, it goes back into its folder.
await rows.first().click();
await page.locator('#content .acts [data-a="pin"]').click();
await expect(page.locator('.feed.pinned')).toHaveCount(0);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
});
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' });
await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true));
await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible();
// Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click();
await page.waitForTimeout(1500);
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
});
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
// piper, made in an earlier test, subscribes to Test Show alone.
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 .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
// Listen as the page does, then ask.
await piper.evaluate(() => {
window.started = []; window.done = false;
const es = new EventSource('/api/events');
es.onmessage = m => { const ev = JSON.parse(m.data);
if (ev.ev === 'feed_start') window.started.push(ev.feed);
if (ev.ev === 'scan_done') window.done = true; };
});
await piper.waitForTimeout(500);
await piper.locator('#scanAll').click();
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
const started = await piper.evaluate(() => window.started);
expect(started.length, 'something was checked').toBeGreaterThan(0);
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
});

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mjs does that with swc.
// The files are one script in one scope, not modules, which is why there are no imports.
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true,
"strict": false,
"skipLibCheck": true
},
"include": ["web/src/*.ts"]
}

34
web/admin.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>iPodderX admin</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" data-src="app.css">
</head>
<body class="adminpage">
<!-- The server sends this page, and its script, to admins only. -->
<header id="topbar">
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
<img class="logo" src="/icon.png" alt="" width="26">
<h1>Admin</h1>
<span class="grow"></span>
<nav class="tabs" id="atabs">
<a href="#server" data-t="server">Server</a>
<a href="#accounts" data-t="accounts">Accounts</a>
<a href="#log" data-t="log">Log</a>
</nav>
</header>
<main class="wrap plain" id="admin">
<section id="server" hidden></section>
<section id="accounts" hidden></section>
<section id="log" hidden></section>
</main>
<div id="toasts"></div>
<script data-src="web/src"></script>
</body>
</html>

1151
web/app.css Normal file

File diff suppressed because it is too large Load Diff

BIN
web/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

91
web/build.mjs Normal file
View File

@@ -0,0 +1,91 @@
// Builds the pages ipx serves: 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
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
// include_str!s the results, so the binary still carries everything and nothing is served
// from disk.
//
// 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
// URL, and the page, which the browser checks on every visit, is what carries it.
//
// node web/build.mjs [out-dir] default out-dir: web/dist
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import swc from '@swc/core';
import html from '@swc/html';
const here = path.dirname(fileURLToPath(import.meta.url));
// 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.
const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
if (!minify) return css;
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
// The minifier drops the space between a calc() and the value after it in a shorthand --
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
// declaration away, so the element silently loses its padding. It reports no error and the
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
return out;
}
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
const js = swc.transformSync(src, {
filename: name + '.ts',
jsc: {
parser: { syntax: 'typescript' },
target: 'es2022',
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
// and the browser tests reach others (player, savePos) through page.evaluate.
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
},
isModule: false,
minify,
}).code;
const page = fs.readFileSync(path.join(here, name), 'utf8');
const marker = /<script data-src="[^"]*"><\/script>/;
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
// after the markup it wires up, before anything else.
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error');
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
return { html: r.code, js, script };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);
fs.writeFileSync(path.join(out, script), js);
}
}

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Sign in — iPodderX</title>
<link rel="icon" href="/icon.png">
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
/* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
@@ -11,7 +17,7 @@
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
@@ -21,7 +27,8 @@
--shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px;
}
:root[data-theme="light"] {
/* Signed out, there is no account to take a theme from, so this follows the system. */
@media (prefers-color-scheme:light){:root {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
@@ -29,15 +36,15 @@
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--faint:#6b6b6b;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10;
--accent2:#985e0a;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--warn:#985e0a;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
}}
*{box-sizing:border-box}
html,body{height:100%}
body{
@@ -76,20 +83,4 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
<p class="msg" id="msg"></p>
</form>
<script>
document.getElementById('f').onsubmit = async e => {
e.preventDefault();
const msg = document.getElementById('msg');
msg.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('name').value,
password: document.getElementById('pw').value,
}),
});
if (r.ok) location.href = '/';
else msg.textContent = await r.text() || 'Sign in failed';
};
</script>
<script data-src="web/src"></script>

204
web/src/admin.ts Normal file
View File

@@ -0,0 +1,204 @@
/* ---------------- admin page ---------------- */
// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's
// hash so a link can go straight to one. The server sends this page and this script to admins
// only, and refuses every call below to anyone else; they were parts of Settings and the header,
// shown or hidden by the main page's script (issue #19).
let me: {name: string} | null = null;
api('/api/me').then(u => { me = u; }).catch(() => {});
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
function showSection(){
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
for(const s of $$('#admin > section')) s.hidden = s.id !== t;
for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t);
// The log polls every two seconds while it is showing, and not otherwise.
if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; }
SECTIONS[t]();
}
window.addEventListener('hashchange', showSection);
/* ---------------- server ---------------- */
async function drawServer(){
const box = $('#server');
const g = await api('/api/settings');
const gs = splitEvery(g.every_mins);
box.innerHTML = `<h2>Server</h2>
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
many items a feed downloads for them, are in that feed's settings.</p>
<div class="field"><label>Check feeds every</label>
<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
catalogue the first time a feed is scanned.</span></div>
<div class="field"><label>Download these media types automatically</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Pinned
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Download folder</label>
<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>`;
$('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0})});
toast('Settings saved');
}catch(e){ toast(e.message, true); }
};
}
/* ---------------- accounts ---------------- */
async function drawAccounts(){
const box = $('#accounts');
const users = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2>
${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>
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
<div class="field" style="margin-top:20px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<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>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
const change = async (u, opts) => {
try{
await api(`/api/users/${u.id}`, opts);
// 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.
if(u.name === me?.name){ location.href = '/'; return; }
}catch(e){ toast(e.message, true); }
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
};
for(const row of $$('#accounts [data-id]')){
const u = users.find(x => String(x.id) === row.dataset.id);
$('[data-a="admin"]', row).onchange = e =>
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
$('[data-a="rm"]', row).onclick = () => {
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u, {method: 'DELETE'});
};
}
$('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
toast('Added'); drawAccounts();
}catch(e){ toast(e.message, true); } // keep what was typed
};
}
/* ---------------- log ---------------- */
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it.
const LOG_TABS = {
all: null,
daemon: t => t === 'ipx::io',
scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http',
};
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){
logSeq = 0; logLines = [];
$('#log').innerHTML = `<h2>Log</h2>
<div class="logbar">
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t =>
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
</div>
<select id="loglevel" style="width:auto">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
</div>
<div id="logbox"><p class="empty">Loading…</p></div>
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`;
for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
drawLog();
};
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog();
if(!logTimer) logTimer = setInterval(pollLog, 2000);
}
async function pollLog(){
try{
const r = await api(`/api/logs?after=${logSeq}&limit=500`);
if(r.lines.length){
logLines = logLines.concat(r.lines).slice(-2000);
logSeq = r.latest;
drawLog();
}else if(!logLines.length){ drawLog(); }
}catch(e){
const box = $('#logbox');
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
}
}
function visibleLog(){
const min = logLevel ? LEVELS[logLevel] : -1;
const tab = LOG_TABS[logTab];
return logLines.filter(l =>
(!tab || tab(l.target)) &&
(LEVELS[l.level] ?? 1) >= min &&
(!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter)));
}
function drawLog(){
const box = $('#logbox'); if(!box) return;
const follow = $('#logfollow')?.checked;
const rows = visibleLog();
box.innerHTML = rows.length ? rows.map(l => {
const t = new Date(l.ts * 1000).toLocaleTimeString();
if(logTab === 'daemon'){
const out = l.msg.startsWith('<-');
return `<div class="l"><time>${t}</time>` +
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
if(follow) box.scrollTop = box.scrollHeight;
}
showSection();

398
web/src/dialogs.ts Normal file
View File

@@ -0,0 +1,398 @@
/* ---------------- modals ---------------- */
function openModal(html: string, wide?: boolean){
$('#modalCard').innerHTML=html;
$('#modalCard').classList.toggle('wide',!!wide);
$('#modal').classList.add('on');
}
function closeModal(){
$('#modal').classList.remove('on');
}
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3>
<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>
<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>
<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>
<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>`);
$('#nurl').focus();
$('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').disabled=true; $('#nsave').title='Adding…';
try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
};
};
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
// URL, so a key in someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
async function listFeeds(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':NONE_LISTED;
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
return rows.length;
}
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
/// parts are the same either way; the class lays them out.
function listedFeed(p,cls){
const el=document.createElement('div');
el.className=cls;
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// 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>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
return el;
}
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
// otherwise clear them.
let dirKind='All', dirCat=null;
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
/// category, as chips). Both filter in place, without asking the server again.
async function renderDirectory(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
const bar=$('#dirbar');
// Only a filter when the server has both kinds.
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
const draw=()=>{
if(!both) dirKind='All';
const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: only the categories among the feeds the kind lets through.
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
if(!cats.includes(dirCat)) dirCat=null;
bar.innerHTML=
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed.
for(const b of $$('button',bar)) b.onclick=()=>{
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
};
box.innerHTML='';
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
};
draw();
return rows.length;
}
/// Directory and Popular open in the main pane, as the original's Directory did.
async function renderListed(v){
const box=$('#content');
box.classList.add('plain');
$('#tbRemove').disabled=true;
syncTools(null);
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
</div>
${grid?'<div class="dirbar" id="dirbar"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
}
/// 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
/// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){
let rows=[];
try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
// Carries its episode, for paintListenRow to repaint as the player moves.
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
el.className='childrow';
el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
`<button class="iconbtn" data-a="play"></button>`+
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
`<div class="rail"><i></i></div>`;
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
paintListenRow(el);
box.appendChild(el);
}
return rows.length;
}
/// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){
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.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
// The player's own length first: a feed's can be minutes out.
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
el.classList.toggle('now',now);
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
// With no length there is nothing to show, and an empty rail reads as a heavy border.
const rail=$('.rail',el); rail.hidden=!d;
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
}
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
function syncListening(){
for(const el of $$('#listening .childrow'))
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
/// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){
// 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(); }
try{
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
{method:'POST',body:JSON.stringify({secs:0})});
}catch(err){ toast(err.message,true); }
if(S.feed===':listening') renderListed(VIEWS[':listening']);
}
// 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); };
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
let searchT;
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
// Crossing the phone breakpoint moves the files between their pane and the text.
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
function toggleGroup(id){
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
renderFeeds();
}
let globalMax = 3;
function due(ts){
const d = ts - Date.now()/1000;
if(d <= 0) return 'due now';
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
return 'in '+Math.round(d/86400)+'d';
}
/// 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
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
async function prefsModal(){
const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin);
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
<select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting.</span></div>
<div class="field"><label>Subscriptions</label>
<div class="inline">
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
</div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div>
<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.
${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>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
}
function settingsModal(f, newUrl?: string){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
settings apply to it and are inherited by every feed inside it.</p>`:''}
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
config.toml, and it stops following the subscription's settings.</p>`:''}
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
Everyone else keeps their own.</p>
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
</div>
<span class="hint">${S.me&&S.me.admin
? `Shared with everyone reading this feed. Editing it keeps every item and download —
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
on the next scan.`
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
<span class="hint">Where the files land. There is one copy however many people
subscribe, so this is the same for everyone.</span></div>`:''}
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
<datalist id="scats"></datalist>
<span class="hint">${f.feed_category
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
<div class="cardacts"><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>`);
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
// Offer the categories the Directory already shows, so a blog about games joins Games rather
// than starting a second chip beside it.
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
const patch: Record<string, unknown>={
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
// The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim();
patch.folder=$('#sfolder').value.trim()||null;
patch.category=$('#scat').value.trim()||null;
}
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved — applies on the next scan');
await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
};
}
function downloadLatestModal(f){
openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5">
<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>
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
$('#dgo').onclick=async()=>{
const n=Number($('#dcount').value)||5;
try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); }
};
}
function removeFeed(f){
openModal(`<h3>Unsubscribe?</h3>
<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.
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>
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
$('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed();
};
}
function opmlModal(){
openModal(`<h3>OPML</h3>
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
<div class="field"><label>Export: save your subscriptions as OPML</label>
<div class="inline">
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
</div></div>
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<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>
<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()=>{
// 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.
const pick=$('#opmlFile'), file=pick.files[0];
const xml=file ? await file.text() : $('#opmlText').value;
const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
}
try{
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);
}catch(e){ letGo(); toast(e.message,true); }
};
}
// 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})}); }
$('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal;
// 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.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
}).catch(()=>{});
$('#feedFilter').oninput=renderFeeds;
// 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.
$('#feedlist').onkeydown=ev=>{
const row=ev.target.closest('[data-id]'); if(!row) return;
const id=row.dataset.id;
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
else if(row.classList.contains('group')&&
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
else return;
ev.preventDefault(); ev.stopPropagation();
};
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);

48
web/src/events.ts Normal file
View File

@@ -0,0 +1,48 @@
/* ---------------- live events ---------------- */
let sse;
function connect(){
sse=new EventSource('/api/events');
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
// 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);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
const pct=ev.total?ev.done/ev.total*100:0;
// 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.
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file}${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
// everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds();
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries();
}
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
else if(ev.ev==='feed_done'){
setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
}
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
}
connect();
loadFeeds();

185
web/src/feedpage.ts Normal file
View File

@@ -0,0 +1,185 @@
/* ---------------- feed page ---------------- */
function renderFeed(){
const box=$('#content');
box.classList.remove('plain');
const v=VIEWS[S.feed];
if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed);
$('#tbRemove').disabled=!f;
syncTools(null);
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;
$('#epSearch').placeholder=`Search ${name}`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
box.innerHTML = (f ? `
<div class="fhead slim">
${artHTML(f.image,name)}
<div class="meta">
<h2>${esc(name)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${failBannerHTML(f)}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>` : `
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta">
<h2>${v.title}</h2>
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
subscribe to, newest first · ${unreadAll} unread</div>
</div>
<div class="acts">
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
</div>
</div>`) + `
<div class="toolbar">
<div class="tabs">
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
</div>
</div>`;
const pane=document.createElement('div');
pane.id='split';
if(f) pane.className='one';
pane.innerHTML='<div id="list">'+sortHead()+
'<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));
box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
dragSplit(pane);
// Nothing is open any more, so nothing is kept on the Unread tab for being open.
S.sel=null;
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries();
});
if(f) wireFailBanner(box,f);
}
/// The item table's headings, each a button that sorts by its column. The first click goes the
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
function sortHead(){
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
const on=S.sort.col===k;
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}${icon?' h-ic':''}" data-sort="${k}"`+
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>';
}
function sortBy(col){
const first=['published','size','kept'].includes(col)?'desc':'asc';
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{}
S.offset=0; renderFeed(); loadEntries();
}
/// 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.
// 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||'');
function renderGroup(f,kids){
const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length;
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
// The same header as a feed's, buttons in the same places: it is a feed underneath.
$('#content').innerHTML = `
<div class="fhead slim">
${folderArt(f,kids)}
<div class="meta">
<h2>${esc(f.title||f.id)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
${failBannerHTML(f)}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>
<div class="toolbar">
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
</div>
<div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
wireFailBanner($('#content'),f);
const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML='';
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; }
for(const c of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">Gone</span>':'')+
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id);
box.appendChild(el);
}
};
$('#kidSearch').oninput=draw;
draw();
}
async function feedAction(a,f){
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
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==='pin'){
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
await loadFeeds(true); renderFeed();
}catch(e){ toast(e.message,true); }
}
if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f);
}
/// All Subscriptions' own buttons: a feed's, across every feed you read.
async function allAction(a){
if(a==='scanall') return scanAll();
if(a==='readall'){
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
if(!n){ toast('Nothing unread'); return; }
// One click across every feed is a lot to take back, so this one asks first.
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
try{
const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
}
}

146
web/src/feeds.ts Normal file
View File

@@ -0,0 +1,146 @@
/* ---------------- feeds ---------------- */
async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds();
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all');
}
}
// 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.
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
// 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.
const VIEWS={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
':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.'},
':all':{title:'All Subscriptions',icon:ICON.all},
};
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
/// holding one, carry a spinner.
const scanning=new Set<string>();
function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id);
paintScanning();
}
function paintScanning(){
for(const row of $$('#feedlist .feed')){
const id=row.dataset.id;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
}
}
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
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.
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 done=()=>{
list.scrollTop=top;
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
if(row) (back[1]&&$('.chev',row)||row).focus();
};
list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div');
places.className='places';
for(const [id,v] of Object.entries(VIEWS)){
const el=document.createElement('div');
el.className='place'+(S.feed===id?' sel':'');
el.tabIndex=0; el.dataset.id=id;
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
el.onclick=()=>{ selectFeed(id); nav(false); };
places.appendChild(el);
}
list.appendChild(places);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
// 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.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
const order=[];
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
// opens them all, or matches inside a closed folder would be invisible.
if(expanded.has(f.id) || q)
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
}
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
// it is open.
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
for(const [f,depth] of order){
const kids=inside(f).length;
// 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.
const mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded];
// 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
// went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML =
// The error mark hangs in the margin where a folder's triangle does. A folder already has
// its triangle there, so that turns red instead, and the feed inside shows the mark.
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
`</small></div>`+
(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>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el);
}
paintScanning();
done();
}
function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
}
/// 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
/// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){
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>${
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>`;
return '';
}
function wireFailBanner(box,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);
}

82
web/src/gestures.ts Normal file
View File

@@ -0,0 +1,82 @@
/* ---------------- touch gestures ---------------- */
// On a touch screen: pull the item list down from its top to check the feed for new items, and
// swipe the item you are reading left for the next one, right for the one before, or back to
// the list from the first. Touch events only, so a mouse never sets these off. Listened for on
// the document because the panes are rebuilt every time a feed renders.
const PULL = 70; // px down, from the list's top, that counts as a pull
const SWIPE = 60; // px across that counts as a swipe
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
/// Something that scrolls sideways, or takes typing, keeps its own gestures: a wide code block
/// or table in a post, the player's seek bar, a text box.
function ownsSwipe(el: Element | null){
for(let n = el; n && n.id !== 'detail'; n = n.parentElement){
if(/^(INPUT|TEXTAREA|SELECT|AUDIO|VIDEO)$/.test(n.tagName)) return true;
if(n.scrollWidth > n.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true;
}
return false;
}
/// How far the list has been pulled: a note at its top, growing with the pull and pushing the
/// items down, that says what letting go will do.
function pullShow(dy: number){
const list = $('#list'); if(!list) return;
const d = Math.round(Math.min(dy, PULL * 1.6) / 2);
let tip = $('#pulltip');
if(!d){ tip?.remove(); return; }
if(!tip){ tip = document.createElement('div'); tip.id = 'pulltip'; list.prepend(tip); }
tip.style.height = d + 'px';
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
}
function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return;
// New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true));
loadEntries();
}
document.addEventListener('touchstart', ev => {
touch = null;
if(ev.touches.length !== 1 || $('#modal')?.classList.contains('on')) return;
const t = ev.touches[0], target = ev.target as Element;
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', 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: true});
document.addEventListener('touchmove', ev => {
if(!touch) return;
const t = ev.touches[0];
touch.dx = t.clientX - touch.x; touch.dy = t.clientY - touch.y;
if(touch.pane === 'list'){
// Only a pull that starts at the top and goes down; anything else is an ordinary scroll.
if(touch.dy < 0 || $('#list').scrollTop > 0){ pullShow(0); touch = null; return; }
pullShow(touch.dy);
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
touch = null; // scrolling the text, not swiping; the first few px
// decide nothing, being mostly jitter
}
}, {passive: false});
document.addEventListener('touchend', () => {
const g = touch; touch = null;
if(!g) return;
if(g.pane === 'list'){
pullShow(0);
if(g.dy >= PULL) refreshFeed();
return;
}
// Across, mostly sideways, and not a slow drag while selecting text.
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
const i = S.entries.findIndex(x => x.guid === S.sel);
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
if(i > 0) stepEntry(-1); else showDetail(null);
});
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });

344
web/src/items.ts Normal file
View File

@@ -0,0 +1,344 @@
/* ---------------- items ---------------- */
let loadSeq=0;
async function loadEntries(append?: boolean){
// Directory and Popular list feeds, not items.
if(!S.feed || VIEWS[S.feed]?.url) return;
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);
const asked=performance.now(), seq=++loadSeq;
const r=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
// 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.
if(seq!==loadSeq) return;
S.total=r.total;
for(const e of r.entries){
const w=readWrites.get(readKey(e));
if(w && !(w.done<asked)) e.read=w.read;
}
let entries = append ? S.entries.concat(r.entries) : r.entries;
// 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.
// Keep it until you pick a different one; the next refresh after that no longer protects it.
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
const open=S.entries.find(e=>e.guid===S.sel);
if(open) entries=[open,...entries];
}
S.entries = entries;
renderEntries();
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
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);
$('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
syncPlayButtons();
if(pane) pane.scrollTop=top;
if(S.entries.length < S.total){
const b=document.createElement('button');
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
box.appendChild(b);
}
}
function epEl(e){
// 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
// many others there are; the pane below lists them all.
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
const has=!!(enc&&enc.path);
const playable=isPlayable(enc);
const others=e.enclosures.length-1;
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</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>
</div>
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
</div>
<span class="size">${enc?mb(enc.length):''}</span>
<span class="date">${dateOf(e.published)}</span>
<div class="rowacts">
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`;
el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el;
}
/// 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
/// is just a broken player.
function isPlayable(x){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
if(m) return false;
// No declared type: fall back to the file's extension.
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
.test((x.path||x.url||'').split('?')[0]);
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){
const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video';
if(m.startsWith('audio/')) return 'audio';
if(m.includes('pdf')) return 'pdf';
if(m.includes('torrent')) return 'torrent';
const ext=(enc.url||'').split('?')[0].split('.').pop();
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
}
/// 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
/// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(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 [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k];
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
}
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; };
/// 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.
function swapRow(e){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
}
/// 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
/// the next refresh took it off again (loadEntries).
const readWrites=new Map();
const readKey=e=>e.feed_id+'\n'+e.guid;
function setRead(e,read){
e.read=read;
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})})
.then(()=>{ w.done=performance.now(); loadFeeds(true); });
}
/// 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.
function markRead(e){
if(e.read) return;
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
}
function selectEntry(e){
// 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.
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
if(prev&&prev.read){
S.entries=S.entries.filter(x=>x!==prev); S.total--;
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
}
S.sel=e.guid;
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
swapRow(e);
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane){
const grab=$('#grab',pane);
if(!grab) return;
const move=ev=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
};
const stop=()=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',stop);
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',stop);
};
}
/// Which item the toolbar's play, read and keep buttons act on.
// 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.
const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree.
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#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.
function showDetail(e){
const box=$('#detail'), files=$('#files'); if(!box) return;
// A pane that can only say "No files" gives its width to the list instead.
if(files) files.hidden=!(e&&e.enclosures.length);
document.body.classList.toggle('reading',!!e);
syncTools(e);
if(!e){
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
// where the files sit over the text, a box saying "No files" only pushed the text down.
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.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
// it looked as if a downloaded file could not be deleted at all (issue #21).
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${/* Joined, so a missing date or number leaves no stray dot behind. */
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
</div>
${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
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));
syncPlayButtons();
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){
const size=x.length?mb(x.length):'';
// 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 shared=f&&f.subscribers>1;
// Icons, with the words in the tooltip and for screen readers.
const delLabel=shared
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: '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 saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
if(x.path && !isPlayable(x)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn}
${delBtn}
</div>`;
}
if(x.path){
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
// started the bar as well, so the same file played twice at once.
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn}
${delBtn}
</div>`;
}
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`;
}
async function epAction(a: string, e, el, encId?: number){
// 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 enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e, encId!=null ? enc : undefined);
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==='get'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'item'));
}
if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id);
const shared=f&&f.subscribers>1;
if(!confirm(shared
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
+`and will not be downloaded again automatically.`
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
try{
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
}
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(err.message,true); }
}

15
web/src/login.ts Normal file
View File

@@ -0,0 +1,15 @@
document.getElementById('f').onsubmit = async e => {
e.preventDefault();
const msg = document.getElementById('msg');
msg.textContent = '';
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: (document.getElementById('name') as HTMLInputElement).value,
password: (document.getElementById('pw') as HTMLInputElement).value,
}),
});
if (r.ok) location.href = '/';
else msg.textContent = await r.text() || 'Sign in failed';
};

177
web/src/native.ts Normal file
View File

@@ -0,0 +1,177 @@
/* ---------------- native shell bridge ---------------- */
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
// that cannot render a WebView at all, and the only audio they will control is the host's.
//
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
// exactly as they do in a browser, with nothing in player.ts changed.
//
// In a browser none of this installs and the page is untouched.
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
/// which is what switches the whole file off.
const ipxHost: ((m: any) => void) | null = (() => {
const w = window as any;
const ios = w.webkit?.messageHandlers?.ipx;
if (ios) return (m: any) => ios.postMessage(m);
const android = w.ipxAndroid;
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
return null;
})();
if (ipxHost) installNativePlayback();
function installNativePlayback(){
const post = ipxHost!;
const M = HTMLMediaElement.prototype;
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
// Bound before anything is redefined, because the src setter below has to drop the element's
// file without that counting as closing the player: removeAttribute is overridden further down
// to mean exactly that, and going through it there switched the shim straight back off.
const realRemoveAttribute = audio.removeAttribute.bind(audio);
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
const paused = own('paused'), readyState = own('readyState');
const volume = own('volume'), playbackRate = own('playbackRate');
// What the host last told us. `on` is the whole switch: false means this element is playing for
// itself, which is still the case for video -- the host plays audio, and a native video layer
// under a WebView buys nothing when CarPlay is audio-only either way.
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
const fire = (name: string) => audio.dispatchEvent(new Event(name));
const define = (k: string, d: PropertyDescriptor) =>
Object.defineProperty(audio, k, {configurable:true, ...d});
define('play', {value(){
if(!N.on) return realPlay.call(audio);
post({t:'play'});
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
// message from the host instead, so there is nothing to reject.
return Promise.resolve();
}});
define('pause', {value(){
if(!N.on) return realPause.call(audio);
post({t:'pause'});
}});
define('src', {
get(){ return N.on ? '' : src.get!.call(audio); },
set(v){
// has-video is set immediately before the src in play(), so it is already right here.
if(document.body.classList.contains('has-video')){
stop();
src.set!.call(audio, v);
return;
}
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
realPause.call(audio);
realRemoveAttribute('src');
realLoad.call(audio);
const e = player.entry, f = player.feed;
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
title:(e && e.title) || '', feedTitle:feedName(f),
artwork:(e && e.image) || feedArt(f) || null,
// Where the host starts is not this: the seek to where you left off is player.ts's, on
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
// display before the file has loaded.
position:(e && e.position) || 0, duration:(e && e.duration) || null,
rate:audio.playbackRate, volume:audio.volume});
},
});
define('currentTime', {
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
set(v){
if(!N.on){ currentTime.set!.call(audio, v); return; }
N.cur = v;
post({t:'seek', to:v});
// The clock and the scrubber move now rather than at the host's next tick, which is what
// makes the 15 and 30 second keys feel like they did.
fire('timeupdate');
},
});
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
define('volume', {
get(){ return volume.get!.call(audio); },
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
});
define('playbackRate', {
get(){ return playbackRate.get!.call(audio); },
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
});
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
// playing on with nothing on screen to stop it.
define('removeAttribute', {value(name: string){
if(name === 'src') stop();
return realRemoveAttribute(name);
}});
function stop(){
if(!N.on) return;
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
post({t:'stop'});
}
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
// left paused in another tab once saved its older place over where you had got to -- and a
// backgrounded WebView is exactly that tab: frozen, holding a time from minutes ago, while the
// host plays on. So the beacon becomes a request for the host to save its own time, and the host
// is also the one saving while nothing here is running at all.
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(url: string, data?: any){
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
return beacon ? beacon(url, data) : false;
} as any;
// What the host calls back into. On `window` deliberately: the host reaches it by name through
// evaluateJavaScript, and a top-level const would work but not obviously.
(window as any).ipxNative = {
version: 1,
on(m: any){
if(!N.on) return;
switch(m.t){
case 'time':
N.cur = m.cur;
if(m.dur != null) N.dur = m.dur;
fire('timeupdate');
break;
case 'meta':
N.dur = m.dur; N.ready = 1;
fire('loadedmetadata');
break;
case 'state':
if(m.playing === !N.paused) return;
N.paused = !m.playing;
fire(m.playing ? 'play' : 'pause');
break;
case 'ended':
N.paused = true;
fire('pause');
fire('ended');
break;
case 'error':
N.paused = true;
fire('pause');
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
break;
}
},
};
// The host waits for this to know the bridge is in and which build it got: an app newer than the
// deployed page would otherwise sit there sending messages nothing answers.
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
}

197
web/src/player.ts Normal file
View File

@@ -0,0 +1,197 @@
/* ---------------- player ---------------- */
const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
// Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the item vanish from the Unread list the instant
// you pressed play, which looks exactly like it went missing.
function markPlayed(){
if(!player.guid||player.marked) return;
player.marked=true;
const e=player.entry;
if(!e||e.read) return;
setRead(e,true).catch(()=>{});
}
/// 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)){
if(!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return;
}
// The same file carries on where it was; another of the item's files starts from its top.
const resuming = player.guid===e.guid && player.enc===enc.id;
// Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; }
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
$('#ptitle').textContent=e.title||'(untitled)';
const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on');
mediaSession(e,f);
player.marked=false;
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
renderEntries();
}
function mediaSession(e,f){
if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
});
const h={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
}
audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#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
// that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true;
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
// to wait for the next pause to be saved.
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
if(d && audio.currentTime/d >= 0.9) markPlayed();
});
function savePos(){
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
// a failed load, or a pause before the seek to where you left off -- wiped the position.
// Nor from a player nobody has played since it last saved: one left paused in another tab
// saved its older place as that tab reloaded, over where you had got to since.
if(!player.guid||!audio.readyState||!player.moved) return;
player.moved=false;
player.saveAt=audio.currentTime;
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
// The measured length stands in for one the feed left out: without it Currently Listening
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
}
audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
// body.playing is what sets the EQ bars moving.
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
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
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
function syncPlayButtons(){
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
const paint=(b,now,idle)=>{
const label=now?'Pause':idle;
if(b.title===label) return;
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 $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
}
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();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.
syncListening()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
})();
document.addEventListener('keydown',ev=>{
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
if(ev.key==='Escape'){closeModal();nav(false);return}
// The rest are single keys that would otherwise eat what you type.
if(/^(INPUT|TEXTAREA|SELECT)$/.test((ev.target as Element).tagName)) return;
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==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
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
// 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.
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
let gAt=0;
function stepEntry(by){
if(VIEWS[S.feed]?.url||!S.entries.length) return;
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))];
selectEntry(e);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
}
function stepFeed(by){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return;
selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
}
const KEYS={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled.
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
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(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
'[':()=>matchMedia('(max-width:820px)').matches
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
'?':()=>keysModal(),
g:()=>{ gAt=Date.now(); },
};
function typed(ev){
// 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 fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
if(!fn) return;
ev.preventDefault(); fn();
}
/// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
[g('l'),'Currently Listening'],[g('s'),'Settings'],
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
['Items'],
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
[k('Shift')+' '+k('A'),'Mark all read'],
['The selected item'],
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
[k('v'),'Open the original in a new tab'],
['The player'],
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
];
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
}

63
web/src/theme.ts Normal file
View File

@@ -0,0 +1,63 @@
/* ---------------- theme ---------------- */
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
// the account, so it follows you to another browser or computer. The server writes it onto the
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = {
adwaita: {name: 'Adwaita', modes: true},
catppuccin: {name: 'Catppuccin', modes: true},
classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true},
flatremix: {name: 'Flat Remix', modes: true},
glass: {name: 'Glass', modes: true},
gruvbox: {name: 'Gruvbox', modes: true},
contrast: {name: 'High contrast', modes: true},
material: {name: 'Material', modes: true},
modern: {name: 'Modern', modes: true},
nordic: {name: 'Nordic', modes: true},
paper: {name: 'Paper', modes: false},
solarized: {name: 'Solarized', modes: true},
};
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
const theme = {name: 'modern', mode: 'dark'};
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
function setTheme(name = theme.name, mode = theme.mode, save = false){
theme.name = THEMES[name] ? name : 'modern';
theme.mode = MODES[mode] ? mode : 'dark';
const both = THEMES[theme.name].modes;
const root = document.documentElement;
root.dataset.theme = theme.name;
// A theme with one palette has it whatever the mode; both of those are light.
root.dataset.mode = !both ? 'light'
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
const sel = $('#stheme'); if(sel) sel.value = theme.name;
const ms = $('#smode'); if(ms) ms.value = theme.mode;
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
if(save) saveTheme();
}
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
/// several at once, a quick run through the list could reach the server out of order and
/// leave the account on a theme passed on the way.
let themeSaving = Promise.resolve();
function saveTheme(){
themeSaving = themeSaving
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
}
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
(() => {
const root = document.documentElement;
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
// account, goes up to it once, so nobody has to choose again.
let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
setTheme(name ?? undefined, mode ?? undefined, !!name);
})();

176
web/src/util.ts Normal file
View File

@@ -0,0 +1,176 @@
'use strict';
// `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.
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// 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
// 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.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={
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
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
// for the outline's stroke, so the two draw the same size.
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
video:fa('0 0 576 512','<path fill="currentColor" d="M96 64c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L96 64zM464 336l73.5 58.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2L464 176 464 336z"/>'), // solid/video
image:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm64 80a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM272 224c8.4 0 16.1 4.4 20.5 11.5l88 144c4.5 7.4 4.7 16.7 .5 24.3S368.7 416 360 416L88 416c-8.9 0-17.2-5-21.3-12.9s-3.5-17.5 1.6-24.8l56-80c4.5-6.4 11.8-10.2 19.7-10.2s15.2 3.8 19.7 10.2l26.4 37.8 61.4-100.5c4.4-7.1 12.1-11.5 20.5-11.5z"/>'), // solid/image
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
};
// What is playing, as the icon's EQ bars; the stylesheet moves them.
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
// 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.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
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));
return r.status===204?null:r.json().catch(()=>null);
}
// 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.
async function copyText(text,btn){
const flash=ok=>{
if(!btn) return;
// The button is an icon, so it is the markup that has to come back, not just its text.
const was=btn.innerHTML;
btn.textContent=ok?'Copied':'Failed';
setTimeout(()=>btn.innerHTML=was,1300);
};
try{
if(navigator.clipboard&&window.isSecureContext){
await navigator.clipboard.writeText(text);
}else{
const ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','');
ta.style.cssText='position:fixed;top:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select(); ta.setSelectionRange(0,ta.value.length);
const ok=document.execCommand('copy');
ta.remove();
if(!ok) throw new Error('copy rejected');
}
flash(true);
}catch(e){
flash(false);
toast('Could not copy automatically — select the URL and copy it manually',true);
}
}
function toast(msg: string, bad?: boolean){
const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
}
const clock = s => {
s=Math.max(0,Math.floor(s||0));
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')}`;
};
const ago = t => {
if(!t) return 'never';
const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
if(d<86400) return Math.round(d/3600)+'h ago';
if(d<2592000) return Math.round(d/86400)+'d ago';
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'}):'';
// 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'
: 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 plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
// 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 tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
function tileHTML(name,cls){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
}
function artHTML(url: string | null, name: string, cls?: string){
return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(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.
function folderArt(f,kids){
const art=kids.filter(c=>c.image).slice(0,4);
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.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(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.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */
const S = {
feeds:[],
// 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.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
entries:[], total:0, offset:0,
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null,
// 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; } })()
||{col:'published',dir:'desc'},
};
const LIMIT = 50;
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){
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};
return {n:m, u:'m'};
}
function unitOptions(sel){
return UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
}
function everyText(m){
if(!m) return '\u2014';
const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
}