39 Commits

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
41 changed files with 4582 additions and 3506 deletions

View File

@@ -5,11 +5,97 @@ 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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
The long form, with what was wrong before and how it was found, is in
[docs/history.md](docs/history.md).
## [Unreleased] ## [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 ## [0.7.0] - 2026-09-18
### Added ### Added
@@ -486,7 +572,12 @@ 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. - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...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.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.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.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0

View File

@@ -13,15 +13,36 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
| | Host | In the container | | | Host | In the container |
|---|---|---| |---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | | | Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` | | 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 | `/mnt/user/ipodderx/state.db` | `/data/state.db` | | 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` | | 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` | | 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 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`. `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. Deploying a change is: build and push the image, then pull it and recreate the container.
```sh ```sh
@@ -56,8 +77,10 @@ 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 Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run `/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
the shell running the command and kills the session (exit 144). This has happened more than once. `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 ## Before you touch the page
@@ -99,6 +122,8 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src npx tsc -p . # type-checks web/src
node tests/page-smoke.js node tests/page-smoke.js
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 40 browser tests against a real daemon on fixture feeds npx playwright test # 40 browser tests against a real daemon on fixture feeds
``` ```
@@ -125,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. 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` * **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 and `position` columns from before accounts; two bugs came from queries still reading them
(retention, and the entry pruner), and `migrate()` now drops them. (retention, and the entry pruner), and they were dropped in 0.5.
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once; * **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 `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`). * **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
Anyone wanting an item is enough to fetch it. 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 * **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
@@ -140,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 * `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker different task. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log. getting through its jobs, watch for `scan complete` in the log.
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's * **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on `Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait command, the healthcheck's `ipx status` included, so it must never write when nothing is
for `daemon started` in the log. Copy `state.db` aside before deploying one. 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 ## House style
@@ -154,9 +193,9 @@ addressed to the person using it.
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated, [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to Removed, Fixed or Security. 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 say, such as what was wrong before or what it cost to find out, it goes in the commit message's
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more body, where `git log` and `git blame` find it beside the change. (There was a long-form
than once. `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 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 `[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
@@ -167,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
## Known gaps ## Known gaps
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies` * They are the open issues in Gitea, not a list here: a limitation known and left in place is an
(documented in [docs/sso.md](docs/sso.md)). issue left open.
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles.
<!-- rtk-instructions v2 --> <!-- rtk-instructions v2 -->
# Command output # Command output

915
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.7.0" version = "0.8.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -12,13 +12,14 @@ axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] } 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"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] } quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1" 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 = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] } tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }

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/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API | | [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release | | [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 | | [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
## Tests ## Tests
@@ -71,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
```sh ```sh
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state 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/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 npx playwright test # a real browser against a real daemon on fixture feeds
``` ```

View File

@@ -8,6 +8,9 @@ services:
PGID: "100" PGID: "100"
TZ: "America/Toronto" TZ: "America/Toronto"
IPX_LOG: "ipx=info" 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: ports:
- "8099:8099" # web UI - "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers - "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/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/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/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` | | `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 | | `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
@@ -65,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 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 before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
older database.
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before never alters a table that exists, so a new column on one needs its own `ALTER` in
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same `create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
path as `Db::open`, so a migration cannot pass the tests while missing in production. 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 ## Control socket
@@ -140,6 +141,7 @@ before they reach the page.
```sh ```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation 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/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 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 -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 ## Talking to it directly

View File

@@ -1,7 +1,19 @@
# Configuration # Configuration
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml` 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`. (`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
| What | Where | Override | | 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` | | Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
| Downloads | `[general] download_dir` | — | | Downloads | `[general] download_dir` | — |
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db` `~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
while the daemon is stopped, or with `sqlite3 state.db .backup`. 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]` ## `[general]`
@@ -28,6 +42,9 @@ max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"] 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 * **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI. be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`. * **`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 token = "" # generated and saved on first run
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email" trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"] 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 auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout" sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30 session_days = 30
@@ -77,6 +96,9 @@ session_days = 30
disables that path. See [sso.md](sso.md). disables that path. See [sso.md](sso.md).
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary * **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
for it. Name the proxy, never a subnet. 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. * **`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, * **`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 `/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>]` ## `[feeds.<id>]`
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add` Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
derives it from the feed title. 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 ```toml
[feeds.atp] [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 config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md). and are the same for everyone. See [users.md](users.md).
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry. and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
## Environment ## 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_CONFIG` | Config file path |
| `IPX_DATA_DIR` | Directory holding `state.db` | | `IPX_DATA_DIR` | Directory holding `state.db` |
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) | | `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view | | `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches | | `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" bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email" trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"] trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
access_team = "rays-sdf1.cloudflareaccess.com"
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
auto_create_users = true auto_create_users = true
sign_out_url = "/cdn-cgi/access/logout" sign_out_url = "/cdn-cgi/access/logout"
session_days = 30 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 ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`. 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 sides. Never list a LAN address or range: anyone there could then send
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you. `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 **Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
boundary, and is the upgrade if that ever matters. 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 **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
in with them until they are given a password (`ipx user passwd <name>`). in with them until they are given a password (`ipx user passwd <name>`).

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 /// 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. /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>, 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. /// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool, pub auto_create_users: bool,
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick /// 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(), token: String::new(),
trusted_header: String::new(), trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
access_team: String::new(),
access_aud: String::new(),
auto_create_users: true, auto_create_users: true,
sign_out_url: String::new(), sign_out_url: String::new(),
session_days: 30, session_days: 30,
@@ -107,6 +117,12 @@ impl Web {
pub fn binds_publicly(&self) -> bool { pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost") !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)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -261,21 +277,86 @@ impl Config {
Ok(cfg) Ok(cfg)
} }
pub fn save(&self, path: &Path) -> Result<()> { }
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir) /// What the database keeps of the configuration (issue #18): the server settings the admin page
.with_context(|| format!("creating {}", dir.display()))?; /// 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()))?;
} }
let text = toml::to_string_pretty(self)?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
// Passwords may live in here.
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
} }
Ok(()) Ok(())
}
} }
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`. /// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
@@ -370,6 +451,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests { mod tests {
use super::*; 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] #[test]
fn parses_a_config_and_applies_defaults() { fn parses_a_config_and_applies_defaults() {
let cfg: Config = toml::from_str( let cfg: Config = toml::from_str(

2524
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 /// 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. /// `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> { fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
non_empty(content) match non_empty(content).filter(|c| !starts_mid_tag(c)) {
.filter(|c| !starts_mid_tag(c)) Some(c) => Some(match subtitle(&c, description) {
.or_else(|| non_empty(description)) Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
.or_else(|| non_empty(content)) 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 /// 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 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>"#; 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(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("<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("Summary")).as_deref(), Some("Plain notes, no tags.")); 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(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary")); 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] #[test]
fn feed_level_explicit_overrides_entries() { fn feed_level_explicit_overrides_entries() {
let xml = br#"<?xml version="1.0"?> let xml = br#"<?xml version="1.0"?>

View File

@@ -88,6 +88,11 @@ pub enum Command {
feed: Option<String>, feed: Option<String>,
#[serde(default)] #[serde(default)]
force: bool, 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 { Reap {
#[serde(default)] #[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 /// 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 /// 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. /// `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. /// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve( pub async fn serve(
@@ -249,7 +256,7 @@ async fn handle(
// Answered here, not queued behind whatever the worker is on: see StatusFn. // Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(Command::Status) => { Ok(Command::Status) => {
tracing::info!(target: "ipx::io", "-> {line}"); tracing::info!(target: "ipx::io", "-> {line}");
let ev = status(); let ev = status().await;
if let Ok(json) = serde_json::to_string(&ev) { if let Ok(json) = serde_json::to_string(&ev) {
tracing::info!(target: "ipx::io", "<- {json}"); tracing::info!(target: "ipx::io", "<- {json}");
} }
@@ -299,10 +306,10 @@ mod tests {
#[test] #[test]
fn commands_parse_from_the_wire_form() { fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap(); 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(); 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(); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true })); 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 // Another client, watching a scan: it must not be handed someone else's answer, which
// would end its session. // would end its session.
let mut watcher = events.subscribe(); 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(); let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status)); 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() .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(); let mut report = Report::default();
// Someone may have deleted a file by hand; the row must stop claiming it exists. // 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 { if !dry_run {
db.mark_reaped(id)?; db.mark_reaped(id).await?;
} }
tracing::debug!(path, "file gone, row reaped"); tracing::debug!(path, "file gone, row reaped");
report.reconciled += 1; report.reconciled += 1;
} }
let candidates = db.reap_candidates()?; let candidates = db.reap_candidates().await?;
if cfg.general.max_age_days > 0 { if cfg.general.max_age_days > 0 {
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64; let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
report.aged_out = aged(&candidates, cutoff); report.aged_out = aged(&candidates, cutoff);
for c in &report.aged_out { 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 { 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(); let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
report.over_quota = pick(&remaining, total, limit); report.over_quota = pick(&remaining, total, limit);
for c in &report.over_quota { 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) 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 { if dry_run {
return Ok(c.bytes.max(0) as u64); 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"); tracing::warn!(path = c.path, error = %e, "could not delete");
return Ok(0); return Ok(0);
} }
db.mark_reaped(c.id)?; db.mark_reaped(c.id).await?;
Ok(size) Ok(size)
} }
@@ -149,12 +149,12 @@ mod tests {
// age_key 0 means "never recorded" -- not the same as "infinitely old". // age_key 0 means "never recorded" -- not the same as "infinitely old".
} }
#[test] #[tokio::test]
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() { 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. // 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( 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 subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'keep', 0), ('f', 'keep', 0),
@@ -163,20 +163,20 @@ mod tests {
('f', 'read', 0); ('f', 'read', 0);
-- Starred by one of the two, so it stays whatever the other thinks. -- 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 INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(1, 'f', 'keep', 1, 1), (1, 'f', 'keep', true, true),
(2, 'f', 'keep', 1, 0), (2, 'f', 'keep', true, false),
(1, 'f', 'half', 1, 0), (1, 'f', 'half', true, false),
(1, 'f', 'read', 1, 0), (1, 'f', 'read', true, false),
(2, 'f', 'read', 1, 0); (2, 'f', 'read', true, false);
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10), (1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20), (2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30), (3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);", (4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
) ).await
.unwrap(); .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!( assert_eq!(
got, got,
vec![4, 2, 3], vec![4, 2, 3],
@@ -185,12 +185,12 @@ mod tests {
); );
} }
#[test] #[tokio::test]
fn prune_keeps_entries_that_still_have_a_file() { async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',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',1); INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100), ('f', 'has-file', 100),
('f', 'no-file', 100), ('f', 'no-file', 100),
@@ -198,9 +198,9 @@ mod tests {
('f', 'recent', 900); ('f', 'recent', 900);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');", (1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
) ).await
.unwrap(); .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::ServiceExt;
use tower_http::services::ServeFile; use tower_http::services::ServeFile;
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
@@ -28,9 +27,10 @@ const COOKIE: &str = "ipx_token";
#[derive(Clone)] #[derive(Clone)]
pub struct WebState { pub struct WebState {
pub ctx: Arc<Ctx>, pub ctx: Arc<Ctx>,
pub config_path: PathBuf,
pub cmds: mpsc::Sender<Command>, pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>, 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 { pub fn router(state: WebState) -> Router {
@@ -101,22 +101,22 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let token = cfg.web.token.clone(); let token = cfg.web.token.clone();
// 1. A header, but only from a hop we were told to believe. // 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 set_cookie: Option<String> = None;
let mut user = None; let mut user = None;
if let Some(name) = vouched { 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(Some(u)) => Some(u),
Ok(None) if cfg.web.auto_create_users => { Ok(None) if cfg.web.auto_create_users => {
tracing::info!(user = %name, "creating an account for a name the proxy vouched for"); tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
state // The first account made is the admin.
.ctx let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
.db match state.ctx.db.create_user(&name, None, first).await {
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false)) Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
.ok() Err(_) => None,
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten()) }
} }
Ok(None) => { Ok(None) => {
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off"); tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
@@ -130,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 // 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. // must not turn anyone away, so its error goes unanswered.
if let Some(u) = &user { 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(); let by_proxy = user.is_some();
@@ -141,7 +141,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
user = state user = state
.ctx .ctx
.db .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); .unwrap_or(None);
} }
} }
@@ -155,11 +155,11 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
if user.is_none() && !token.is_empty() { if user.is_none() && !token.is_empty() {
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE)); let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) { 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() { if from_query.is_some() {
// The token link is a sign-in; the cookie it leaves behind is not one each time. // The token link is a sign-in; the cookie it leaves behind is not one each time.
if let Some(u) = &user { 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!( set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000" "{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
@@ -203,20 +203,31 @@ struct Proxied(bool);
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and /// 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 /// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
/// be whoever they liked. /// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> { /// token Cloudflare Access signed, and the name is the one in the token.
let peer = req ///
.extensions() /// Takes the request's parts rather than the request: a `&Request` held across the await makes
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() /// the future unsendable, as a body is not `Sync`.
.map(|c| c.0.ip().to_string()) async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
.unwrap_or_default();
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) { if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return None; return None;
} }
req.headers() let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
.get(&cfg.web.trusted_header) let Some((team, aud)) = cfg.web.access() else {
.and_then(|v| v.to_str().ok()) return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_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 /// Handlers take `User` to say they need one; the auth layer put it there, and nothing
@@ -247,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. /// 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> { async fn admin_user(state: &WebState) -> Option<crate::db::User> {
let users = state.ctx.db.users().ok()?; let users = state.ctx.db.users().await.ok()?;
users users
.iter() .iter()
.find(|u| u.is_admin) .find(|u| u.is_admin)
@@ -267,7 +278,7 @@ async fn login(
Json(body): Json<Credentials>, Json(body): Json<Credentials>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let name = body.name.trim().to_ascii_lowercase(); 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. // The same answer either way: whether a name exists is not something to leak.
let ok = user let ok = user
.as_ref() .as_ref()
@@ -280,8 +291,8 @@ async fn login(
let user = user.expect("verified above"); let user = user.expect("verified above");
let token = crate::auth::new_session_token(); let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token)?; state.ctx.db.create_session(user.id, &token).await?;
state.ctx.db.signed_in(user.id)?; state.ctx.db.signed_in(user.id).await?;
tracing::info!(user = %user.name, "signed in"); tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1); let days = state.ctx.cfg().web.session_days.max(1);
@@ -298,7 +309,7 @@ async fn login(
async fn logout(State(state): State<WebState>, req: Request) -> Response { async fn logout(State(state): State<WebState>, req: Request) -> Response {
if let Some(sid) = cookie(&req, SESSION_COOKIE) { 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(); let mut resp = StatusCode::NO_CONTENT.into_response();
for c in [ for c in [
@@ -320,7 +331,7 @@ async fn me(
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone(); let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url); let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default(); let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
Json(serde_json::json!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode, "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
})) }))
@@ -343,7 +354,7 @@ async fn patch_me(
if !theme_ok(&body.theme, &body.mode) { if !theme_ok(&body.theme, &body.mode) {
return Err(ApiError::bad_request("not a theme")); return Err(ApiError::bad_request("not a theme"));
} }
state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?; state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -378,7 +389,7 @@ async fn list_users(
let users: Vec<_> = state let users: Vec<_> = state
.ctx .ctx
.db .db
.users()? .users().await?
.iter() .iter()
.map(|u| { .map(|u| {
serde_json::json!({ serde_json::json!({
@@ -409,7 +420,7 @@ async fn add_user(
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| { 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") 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"))); return Err(ApiError::bad_request(format!("{name} already exists")));
} }
// No password is someone the proxy signs in, as with `ipx user add --no-password`. // No password is someone the proxy signs in, as with `ipx user add --no-password`.
@@ -418,7 +429,7 @@ async fn add_user(
} else { } else {
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?) 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"); tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@@ -435,7 +446,7 @@ async fn patch_user(
Json(body): Json<UserPatch>, Json(body): Json<UserPatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
require_admin(&user)?; require_admin(&user)?;
let users = state.ctx.db.users()?; let users = state.ctx.db.users().await?;
let target = users let target = users
.iter() .iter()
.find(|u| u.id == id) .find(|u| u.id == id)
@@ -446,7 +457,7 @@ async fn patch_user(
target.name 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"); tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -457,7 +468,7 @@ async fn remove_user(
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
require_admin(&user)?; require_admin(&user)?;
let users = state.ctx.db.users()?; let users = state.ctx.db.users().await?;
let target = users let target = users
.iter() .iter()
.find(|u| u.id == id) .find(|u| u.id == id)
@@ -468,7 +479,7 @@ async fn remove_user(
target.name 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"); tracing::info!(by = %user.name, user = %target.name, "account removed");
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -476,7 +487,7 @@ async fn remove_user(
/// The password form, except for someone the proxy vouches for: they are signed in already, and /// 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. /// the form only made it look as if they were not.
async fn login_page(State(state): State<WebState>, req: Request) -> Response { 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(); return Redirect::to("/").into_response();
} }
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html")))) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
@@ -510,7 +521,7 @@ async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Res
if !user.is_admin { if !user.is_admin {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
let theme = state.ctx.db.theme(user.id).unwrap_or_default(); 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); let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response() ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
} }
@@ -584,7 +595,7 @@ 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 /// 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. /// 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 { async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
let theme = state.ctx.db.theme(user.id).unwrap_or_default(); let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme))) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
} }
@@ -672,16 +683,16 @@ async fn feeds(
let cfg = state.ctx.cfg(); let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue. // Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to. // 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 let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx .ctx
.db .db
.subscriptions_for(user.id)? .subscriptions_for(user.id).await?
.into_iter() .into_iter()
.map(|s| (s.feed_id.clone(), s)) .map(|s| (s.feed_id.clone(), s))
.collect(); .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)?; let pinned = state.ctx.db.pinned_feeds(user.id).await?;
let mut out = Vec::with_capacity(mine.len()); let mut out = Vec::with_capacity(mine.len());
for sub in &subs { for sub in &subs {
let (id, feed) = (&sub.id, &sub.cfg); let (id, feed) = (&sub.id, &sub.cfg);
@@ -689,8 +700,8 @@ async fn feeds(
// the same fallback the scanner uses (`Db::subscribers`). // the same fallback the scanner uses (`Db::subscribers`).
let up = feed.group.as_deref().and_then(|g| mine.get(g)); let up = feed.group.as_deref().and_then(|g| mine.get(g));
let Some(mine) = mine.get(id) else { continue }; let Some(mine) = mine.get(id) else { continue };
let s = state.ctx.db.feed_summary(id)?; let s = state.ctx.db.feed_summary(id).await?;
let st = state.ctx.db.http_state(id)?; let st = state.ctx.db.http_state(id).await?;
out.push(FeedRow { out.push(FeedRow {
id: id.clone(), id: id.clone(),
url: feed.url.clone(), url: feed.url.clone(),
@@ -740,7 +751,7 @@ async fn feeds(
last_error: s.last_error, last_error: s.last_error,
entries: s.entries, entries: s.entries,
downloaded: s.downloaded, 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), subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id), pinned: pinned.contains(id),
}); });
@@ -800,13 +811,13 @@ struct PopularRow {
/// first. Popular is the top of it, the directory is all of it, and it is all that /// 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 /// `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. /// 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 db = &state.ctx.db;
let mine: std::collections::HashSet<String> = let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect(); db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?; let counts = db.subscriber_counts().await?;
let media = db.media_feeds()?; let media = db.media_feeds().await?;
let catalogue = crate::subscriptions(&state.ctx)?; let catalogue = crate::subscriptions(&state.ctx).await?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> = let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect(); catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_folder: std::collections::HashSet<&str> = let is_folder: std::collections::HashSet<&str> =
@@ -823,7 +834,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
{ {
continue; continue;
} }
let sum = db.feed_summary(&s.id)?; let sum = db.feed_summary(&s.id).await?;
let subscribed = mine.contains(&s.id); let subscribed = mine.contains(&s.id);
out.push(PopularRow { out.push(PopularRow {
id: s.id.clone(), id: s.id.clone(),
@@ -844,7 +855,7 @@ async fn get_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?; let mut rows = popular(&state, user.id).await?;
rows.truncate(10); rows.truncate(10);
Ok(Json(rows)) Ok(Json(rows))
} }
@@ -854,7 +865,7 @@ async fn get_directory(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?; let mut rows = popular(&state, user.id).await?;
rows.sort_by_key(sort_name); rows.sort_by_key(sort_name);
Ok(Json(rows)) Ok(Json(rows))
} }
@@ -870,10 +881,10 @@ async fn subscribe_popular(
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
if !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"))); 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 }))) Ok(Json(serde_json::json!({ "id": id })))
} }
@@ -1124,7 +1135,7 @@ async fn entries(
user: crate::db::User, user: crate::db::User,
Query(page): Query<Page>, Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> { ) -> 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. /// Every subscribed feed's items together, newest first: All Subscriptions.
@@ -1133,11 +1144,11 @@ async fn all_entries(
user: crate::db::User, user: crate::db::User,
Query(page): Query<Page>, Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> { ) -> 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. /// One feed's page of items, or every subscribed feed's when `feed` is None.
fn entry_page( async fn entry_page(
state: &WebState, state: &WebState,
user_id: i64, user_id: i64,
feed: Option<&str>, feed: Option<&str>,
@@ -1146,19 +1157,16 @@ fn entry_page(
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all")); 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 search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let db = &state.ctx.db; let db = &state.ctx.db;
let order = crate::db::order_sql( let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
page.sort.as_deref().unwrap_or("published"),
page.dir.as_deref().unwrap_or("desc"),
);
let mut rows = let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?; db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
let mut sanitizer = feed_sanitizer(); let mut sanitizer = feed_sanitizer();
for row in &mut rows { for row in &mut rows {
if let Some(d) = &row.description { if let Some(d) = &row.description {
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref())); 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 })) Ok(Json(EntryPage { total, entries: rows }))
} }
@@ -1200,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 /// 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 /// goes on your subscription, and before the first scan, which would otherwise skip every
/// explicit item. /// 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 { if allow {
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() }; 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(()) Ok(())
} }
@@ -1217,14 +1225,14 @@ async fn add_feed(
let url = crate::feed::expand_input(&body.url); let url = crate::feed::expand_input(&body.url);
// Someone else may already have it. Then adding costs nothing: no second fetch, no // 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. // 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() .into_iter()
.find(|s| crate::feed::same_feed(&s.cfg.url, &url)) .find(|s| crate::feed::same_feed(&s.cfg.url, &url))
{ {
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some(); let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
state.ctx.db.subscribe(user.id, &existing.id)?; state.ctx.db.subscribe(user.id, &existing.id).await?;
if !already { 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; scan_soon(&state, Some(existing.id.clone())).await;
return Ok(Json( return Ok(Json(
@@ -1232,10 +1240,9 @@ async fn add_feed(
)); ));
} }
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?; let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?; state.ctx.db.subscribe(user.id, &id).await?;
state.ctx.db.subscribe(user.id, &id)?; explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
explicit_on_add(&state, user.id, &id, body.allow_explicit)?;
scan_soon(&state, Some(id.clone())).await; scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id, "existing": false }))) Ok(Json(serde_json::json!({ "id": id, "existing": false })))
} }
@@ -1245,7 +1252,7 @@ async fn add_feed(
/// succeeded either way, so a daemon not taking commands is only logged. /// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) { async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some(); 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"); tracing::warn!("could not queue a scan: the daemon is not accepting commands");
} }
} }
@@ -1288,17 +1295,17 @@ async fn patch_feed(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to. // Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
if let Some(on) = body.pinned if let Some(on) = body.pinned
&& !state.ctx.db.set_pinned(user.id, &id, on)? && !state.ctx.db.set_pinned(user.id, &id, on).await?
{ {
return Err(ApiError::not_found("you do not subscribe to that feed")); 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 -- // 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. // 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 let mut mine = state
.ctx .ctx
.db .db
.subscription(user.id, &id)? .subscription(user.id, &id).await?
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() }); .unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
let mut touched = false; let mut touched = false;
if let Some(v) = body.keywords.clone() { if let Some(v) = body.keywords.clone() {
@@ -1318,7 +1325,7 @@ async fn patch_feed(
touched = true; touched = true;
} }
if touched { if touched {
state.ctx.db.set_subscription(user.id, &mine)?; state.ctx.db.set_subscription(user.id, &mine).await?;
} }
} }
@@ -1339,13 +1346,13 @@ async fn patch_feed(
// Derived feeds have no config entry. Editing one is the moment it earns a real // 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. // entry: promote it, so the config holds your decisions and nothing else.
if !cfg.feeds.contains_key(&id) { if !cfg.feeds.contains_key(&id) {
let subs = crate::subscriptions(&state.ctx)?; let subs = crate::subscriptions(&state.ctx).await?;
let found = subs let found = subs
.iter() .iter()
.find(|s| s.id == id) .find(|s| s.id == id)
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?; .ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
cfg.feeds.insert(id.clone(), found.cfg.clone()); cfg.feeds.insert(id.clone(), found.cfg.clone());
state.ctx.db.unmanage(&id)?; state.ctx.db.unmanage(&id).await?;
} }
let checked = match &body.url { let checked = match &body.url {
@@ -1381,12 +1388,11 @@ async fn patch_feed(
if let Some(v) = body.category { if let Some(v) = body.category {
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
} }
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?;
if url_changed { if url_changed {
// Refreshing a rotated auth token is the common case; entries and download history // Refreshing a rotated auth token is the common case; entries and download history
// are keyed by feed id, so they survive the change. // 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) Ok(StatusCode::NO_CONTENT)
} }
@@ -1398,14 +1404,14 @@ async fn remove_feed(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// Unsubscribing is personal: it takes the feed off your list and leaves everyone // Unsubscribing is personal: it takes the feed off your list and leaves everyone
// else's alone. // else's alone.
state.ctx.db.unsubscribe(user.id, &id)?; state.ctx.db.unsubscribe(user.id, &id).await?;
for child in crate::subscriptions(&state.ctx)? for child in crate::subscriptions(&state.ctx).await?
.iter() .iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str())) .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); return Ok(StatusCode::NO_CONTENT);
} }
@@ -1415,12 +1421,11 @@ async fn remove_feed(
if cfg.feeds.remove(&id).is_none() { if cfg.feeds.remove(&id).is_none() {
// A derived feed: forget it here, though the OPML will list it again on the next // A derived feed: forget it here, though the OPML will list it again on the next
// read unless you unsubscribe from the OPML itself. // 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); return Ok(StatusCode::NO_CONTENT);
} }
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?; crate::retire_group(&state.ctx, &id).await?;
crate::retire_group(&state.ctx, &id)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -1438,10 +1443,10 @@ async fn set_flags(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
use crate::db::EntryFlag; use crate::db::EntryFlag;
if let Some(v) = body.read { 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 { 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) Ok(StatusCode::NO_CONTENT)
} }
@@ -1456,12 +1461,12 @@ async fn download_now(
let enc = state let enc = state
.ctx .ctx
.db .db
.enclosure(id)? .enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?; .ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if enc.path.is_some() { if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here. return Ok(StatusCode::NO_CONTENT); // Already here.
} }
state.ctx.db.requeue(id)?; state.ctx.db.requeue(id).await?;
state state
.cmds .cmds
.send(Command::Download { enclosure: id }) .send(Command::Download { enclosure: id })
@@ -1485,13 +1490,13 @@ async fn delete_file(
let enc = state let enc = state
.ctx .ctx
.db .db
.enclosure(id)? .enclosure(id).await?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?; .ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
// There is one copy of the file: deleting it deletes everyone's. Say so before doing // There is one copy of the file: deleting it deletes everyone's. Say so before doing
// it, once, and let them decide. // it, once, and let them decide.
if !q.force { 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 people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
let complaint = match (starred, unread) { let complaint = match (starred, unread) {
(0, 0) => None, (0, 0) => None,
@@ -1517,7 +1522,7 @@ async fn delete_file(
return Err(e.into()); return Err(e.into());
} }
// The row survives as 'reaped', which is what stops the next scan re-downloading it. // 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 { state.events.send(Event::Reaped {
path: enc.path.unwrap_or_default(), path: enc.path.unwrap_or_default(),
bytes: enc.length.unwrap_or(0).max(0) as u64, bytes: enc.length.unwrap_or(0).max(0) as u64,
@@ -1535,11 +1540,21 @@ struct FetchBody {
async fn fetch_now( async fn fetch_now(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<FetchBody>, Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> { ) -> 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 state
.cmds .cmds
.send(Command::Fetch { feed: body.feed, force: body.force }) .send(Command::Fetch { feed: body.feed, force: body.force, feeds })
.await .await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?; .map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
@@ -1571,7 +1586,7 @@ async fn media(
Path(id): Path<i64>, Path(id): Path<i64>,
req: Request, req: Request,
) -> Response { ) -> 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(); return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
}; };
let Some(path) = enc.path else { let Some(path) = enc.path else {
@@ -1596,7 +1611,7 @@ async fn set_position(
user: crate::db::User, user: crate::db::User,
Json(body): Json<Position>, Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> { ) -> 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) Ok(StatusCode::NO_CONTENT)
} }
@@ -1608,12 +1623,12 @@ async fn read_all(
// A subscription's own row has no entries, so marking it read means everything under it. // A subscription's own row has no entries, so marking it read means everything under it.
let mut ids = vec![id.clone()]; let mut ids = vec![id.clone()];
ids.extend( ids.extend(
crate::subscriptions(&state.ctx)? crate::subscriptions(&state.ctx).await?
.into_iter() .into_iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str())) .filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
.map(|s| s.id), .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 }))) Ok(Json(serde_json::json!({ "marked": n })))
} }
@@ -1624,8 +1639,8 @@ async fn read_all_mine(
user: crate::db::User, user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let ids: Vec<String> = let ids: Vec<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 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 }))) Ok(Json(serde_json::json!({ "marked": n })))
} }
@@ -1646,9 +1661,9 @@ async fn download_latest(
Path(id): Path<String>, Path(id): Path<String>,
Json(body): Json<HowMany>, Json(body): Json<HowMany>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> 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 { for enc in &ids {
state.ctx.db.requeue(*enc)?; state.ctx.db.requeue(*enc).await?;
state state
.cmds .cmds
.send(Command::Download { enclosure: *enc }) .send(Command::Download { enclosure: *enc })
@@ -1666,7 +1681,7 @@ async fn export_opml(
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are // 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. // not yours to download. This used to export config.toml to whoever asked.
let mine: std::collections::HashSet<String> = 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 { let mut doc = opml::OPML {
head: Some(opml::Head { head: Some(opml::Head {
title: Some("ipx subscriptions".into()), title: Some("ipx subscriptions".into()),
@@ -1674,7 +1689,7 @@ async fn export_opml(
}), }),
..Default::default() ..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. // A feed from an OPML subscription comes back with the OPML itself.
if s.managed || !mine.contains(&s.id) { if s.managed || !mine.contains(&s.id) {
continue; continue;
@@ -1682,7 +1697,7 @@ async fn export_opml(
let title = state let title = state
.ctx .ctx
.db .db
.feed_summary(&s.id) .feed_summary(&s.id).await
.ok() .ok()
.and_then(|sum| sum.title) .and_then(|sum| sum.title)
.unwrap_or_else(|| s.id.clone()); .unwrap_or_else(|| s.id.clone());
@@ -1716,7 +1731,7 @@ async fn import_opml(
// file arrives as text, is read here, and is gone when the request ends. // file arrives as text, is read here, and is gone when the request ends.
let doc = opml::OPML::from_str(&body.xml) let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?; .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 { if added > 0 {
scan_soon(&state, None).await; scan_soon(&state, None).await;
} }
@@ -1790,8 +1805,7 @@ async fn patch_settings(
if let Some(v) = body.max_age_days { if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v; cfg.general.max_age_days = v;
} }
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(StatusCode::NO_CONTENT) 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

@@ -7,7 +7,7 @@
// and every server-side test passed too, because the server was fine. // and every server-side test passed too, because the server was fine.
// //
// node tests/page-smoke.js // node tests/page-smoke.js
const vm = require('vm'); const { makeContext, vm } = require('./dom-stub.js');
const PAGE = process.argv[2] || 'index.html'; const PAGE = process.argv[2] || 'index.html';
const { buildPage } = require('../web/build.mjs'); const { buildPage } = require('../web/build.mjs');
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) { 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); console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
} }
// Ids in the page, and in the markup the script builds for its dialogs. const { ctx, missing } = makeContext(html, script);
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
const document = {
querySelector(sel) {
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
return el(sel);
},
querySelectorAll: () => [],
createElement: () => el('created'),
addEventListener(){}, body: el('body'),
documentElement: { dataset: {} },
};
const ctx = {
document, console,
window: { isSecureContext: false, addEventListener(){} },
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
fetch: (url) => Promise.resolve({
ok: true, status: 200, text: () => Promise.resolve(''),
json: () => Promise.resolve(
String(url).includes('/api/settings')
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
: String(url).includes('/api/users')
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
Blob: function () {},
setTimeout, clearTimeout, setInterval, clearInterval,
confirm: () => false, prompt: () => null, alert(){},
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
ctx.globalThis = ctx;
ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
try { try {
vm.createContext(ctx); vm.createContext(ctx);

View File

@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
await page.locator('#prefs').click(); await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions'); await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this'); await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardacts .btn').first().click(); await page.locator('#modalCard .cardx').click();
await expect(page.locator('#admin')).toHaveCount(0); await expect(page.locator('#admin')).toHaveCount(0);
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin'); 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. // Asking for it anyway goes back to the app, and its script is refused.
@@ -1299,3 +1299,45 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
await expect(page.locator('.feed.pinned')).toHaveCount(0); await expect(page.locator('.feed.pinned')).toHaveCount(0);
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1); 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();
});

View File

@@ -12,7 +12,7 @@
--line:#2c3849; --line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --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 */ --accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */ --ink:#0e131b; /* text on an accent fill */
@@ -32,12 +32,12 @@
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */ --dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */ --faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */ --accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#985e0a; /* the same amber; the lighter one failed AA as text */
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }
@@ -124,7 +124,7 @@
--dim:#c4c4c8; --dim:#c4c4c8;
--faint:#a0a0a7; --faint:#a0a0a7;
--accent:#81d0ff; --accent:#81d0ff;
--accent2:#3584e4; --accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
--ink:#1d1d20; --ink:#1d1d20;
--good:#78e9ab; --good:#78e9ab;
--warn:#ffc252; --warn:#ffc252;
@@ -141,7 +141,7 @@
--dim:#57575d; --dim:#57575d;
--faint:#66666c; --faint:#66666c;
--accent:#0461be; --accent:#0461be;
--accent2:#3584e4; --accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#00753a; --good:#00753a;
--warn:#905400; --warn:#905400;
@@ -177,7 +177,7 @@
--dim:#5c616c; /* fg */ --dim:#5c616c; /* fg */
--faint:#686d78; --faint:#686d78;
--accent:#0060f0; /* link */ --accent:#0060f0; /* link */
--accent2:#fd7d00; --accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#23794f; --good:#23794f;
--warn:#a85400; --warn:#a85400;
@@ -196,7 +196,7 @@
--dim:#3d3a33; --dim:#3d3a33;
--faint:#5a564c; --faint:#5a564c;
--accent:#1a5fae; --accent:#1a5fae;
--accent2:#b58900; --accent2:#957100; /* taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#216609; --good:#216609;
--warn:#795a00; --warn:#795a00;
@@ -210,7 +210,7 @@
--panel:#3b4252; /* nord1 */ --panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */ --panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */ --raise:#4c566a; /* nord3 */
--line:#434c5e; --line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
--fg:#eceff4; /* nord6 */ --fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */ --dim:#d8dee9; /* nord4 */
--faint:#b4bccb; --faint:#b4bccb;
@@ -232,13 +232,158 @@
--dim:#3b4252; /* nord1 */ --dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */ --faint:#4c566a; /* nord3 */
--accent:#3a5e8a; --accent:#3a5e8a;
--accent2:#b0674e; --accent2:#ab644c;
--ink:#ffffff; --ink:#ffffff;
--good:#446632; --good:#446632;
--warn:#7e590e; --warn:#7e590e;
--bad:#9c3f49; --bad:#9c3f49;
--shadow:0 8px 28px rgba(46,52,64,.15); --shadow:0 8px 28px rgba(46,52,64,.15);
} }
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
and peach for the rest. */
:root[data-theme="catppuccin"] {
--bg:#1e1e2e; /* base */
--panel:#181825; /* mantle */
--panel2:#313244; /* surface0 */
--raise:#45475a; /* surface1 */
--line:#45475a;
--fg:#cdd6f4; /* text */
--dim:#bac2de; /* subtext1 */
--faint:#a6adc8; /* subtext0 */
--accent:#cba6f7; /* mauve */
--accent2:#fab387; /* peach */
--ink:#11111b; /* crust */
--good:#a6e3a1;
--warn:#f9e2af;
--bad:#f38ba8;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="catppuccin"][data-mode="light"] {
--bg:#eff1f5; /* base */
--panel:#e6e9ef; /* mantle */
--panel2:#dce0e8; /* crust */
--raise:#ccd0da; /* surface0 */
--line:#bcc0cc; /* surface1 */
--fg:#4c4f69; /* text */
--dim:#5c5f77; /* subtext1 */
--faint:#5f6276; /* subtext0 */
--accent:#8638ec; /* mauve */
--accent2:#c74e09; /* peach */
--ink:#ffffff;
--good:#3a9127;
--warn:#925d13;
--bad:#d00f39;
--shadow:0 8px 28px rgba(76,79,105,.15);
}
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
half as gruvbox itself does. Blue for the accent, orange for what is new. */
:root[data-theme="gruvbox"] {
--bg:#282828; /* bg0 */
--panel:#1d2021; /* bg0_h */
--panel2:#32302f; /* bg0_s */
--raise:#3c3836; /* bg1 */
--line:#504945; /* bg2 */
--fg:#ebdbb2; /* fg */
--dim:#d5c4a1; /* fg2 */
--faint:#a89984; /* fg4 */
--accent:#83a598; /* blue */
--accent2:#fe8019; /* orange */
--ink:#1d2021;
--good:#b8bb26;
--warn:#fabd2f;
--bad:#fb533f;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="gruvbox"][data-mode="light"] {
--bg:#fbf1c7; /* bg0 */
--panel:#f2e5bc; /* bg0_s */
--panel2:#ebdbb2; /* bg1 */
--raise:#d5c4a1; /* bg2 */
--line:#d5c4a1;
--fg:#3c3836; /* fg */
--dim:#504945; /* fg2 */
--faint:#665c54; /* fg3 */
--accent:#076678; /* faded blue */
--accent2:#af3a03; /* faded orange */
--ink:#fbf1c7;
--good:#79740e;
--warn:#8d5c10;
--bad:#9d0006;
--shadow:0 8px 28px rgba(60,56,54,.15);
}
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
:root[data-theme="solarized"] {
--bg:#002b36; /* base03 */
--panel:#073642; /* base02 */
--panel2:#0b3f4c;
--raise:#124a58;
--line:#1d5563;
--fg:#eee8d5; /* base2 */
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
--accent:#4b9fda; /* blue */
--accent2:#b58900; /* yellow */
--ink:#002b36;
--good:#859900;
--warn:#bb9315;
--bad:#e87674;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="solarized"][data-mode="light"] {
--bg:#fdf6e3; /* base3 */
--panel:#eee8d5; /* base2 */
--panel2:#e6dfca;
--raise:#ddd5bd;
--line:#d3cab0;
--fg:#073642; /* base02 */
--dim:#52666d; /* base01 */
--faint:#54666d; /* base00 */
--accent:#1e6da5; /* blue */
--accent2:#cb4b16; /* orange */
--ink:#ffffff;
--good:#768700;
--warn:#846400;
--bad:#c52d2a;
--shadow:0 8px 28px rgba(0,43,54,.14);
}
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
that show. For reading, not for looks. */
:root[data-theme="contrast"] {
--bg:#000000;
--panel:#000000;
--panel2:#121212;
--raise:#333333;
--line:#9a9a9a;
--fg:#ffffff;
--dim:#ffffff;
--faint:#d6d6d6;
--accent:#8cc8ff;
--accent2:#ffd400;
--ink:#000000;
--good:#7ee787;
--warn:#ffd400;
--bad:#ff9a8f;
--shadow:0 0 0 1px #9a9a9a;
}
:root[data-theme="contrast"][data-mode="light"] {
--bg:#ffffff;
--panel:#ffffff;
--panel2:#f0f0f0;
--raise:#d6d6d6;
--line:#555555;
--fg:#000000;
--dim:#000000;
--faint:#303030;
--accent:#0033a0;
--accent2:#7a3d00;
--ink:#ffffff;
--good:#0a5c1c;
--warn:#6b4500;
--bad:#a30000;
--shadow:0 0 0 1px #555555;
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */ /* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] { :root[data-theme="classic"] {
color-scheme:light; color-scheme:light;
@@ -250,14 +395,61 @@
--fg:#000000; --fg:#000000;
--dim:#444444; --dim:#444444;
--faint:#666666; --faint:#666666;
--accent:#3875d7; /* Aqua selection blue */ --accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
--accent2:#2a5db0; --accent2:#2a5db0;
--ink:#ffffff; --ink:#ffffff;
--good:#237a23; --good:#237a23;
--warn:#a15f00; --warn:#9b5b00;
--bad:#c42b1c; --bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28); --shadow:0 4px 16px rgba(0,0,0,.28);
} }
/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured
wash show through. The hex values are what a panel reads as once blended, so the contrast check
still means something; the translucency and the wash are in the chrome at the end of the sheet. */
:root[data-theme="glass"] {
--bg:#0b0d14;
--panel:#161a24;
--panel2:#1f2430;
--raise:#2a3040;
--line:#343b4c;
--fg:#f5f5f7; /* Apple's label */
--dim:#aeaeb2; /* systemGray2 */
--faint:#98989f; /* systemGray, a shade up to clear AA over the wash */
--accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */
--accent2:#ff9f0a; /* systemOrange */
--ink:#000000;
--good:#30d158;
--warn:#ff9f0a;
--bad:#ff6961;
--shadow:0 10px 36px rgba(0,0,0,.45);
--glass-edge:rgba(255,255,255,.10);
--glass-hi:rgba(255,255,255,.14);
--wash1:rgba(64,120,255,.30);
--wash2:rgba(175,82,222,.24);
--wash3:rgba(48,176,199,.20);
}
:root[data-theme="glass"][data-mode="light"] {
--bg:#eef1f7;
--panel:#ffffff;
--panel2:#f2f4f8;
--raise:#e4e8f0;
--line:#d1d5de;
--fg:#1d1d1f;
--dim:#515154;
--faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */
--accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */
--accent2:#b25000; /* systemOrange taken down until white on it clears AA */
--ink:#ffffff;
--good:#248a3d;
--warn:#824200;
--bad:#b8000f;
--shadow:0 10px 36px rgba(30,50,90,.16);
--glass-edge:rgba(255,255,255,.65);
--glass-hi:rgba(255,255,255,.9);
--wash1:rgba(64,120,255,.22);
--wash2:rgba(175,82,222,.16);
--wash3:rgba(48,176,199,.18);
}
*{box-sizing:border-box} *{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */ /* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important} [hidden]{display:none!important}
@@ -308,8 +500,22 @@ a{color:var(--accent)}
} }
.iconbtn:hover{background:var(--raise);color:var(--fg)} .iconbtn:hover{background:var(--raise);color:var(--fg)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */ /* One toolbar across the window, as the original had: grouped buttons, search on the right. */
/* The display's own intrusions -- the home indicator along the bottom, the notch at one side in
landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
padding. A browser with its own chrome reports nought and nothing moves. */
:root{
--safe-b:env(safe-area-inset-bottom,0px);
--safe-l:env(safe-area-inset-left,0px);
--safe-r:env(safe-area-inset-right,0px);
}
#topbar{ #topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden; display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
the whole declaration away, leaving the bar with no padding at all. */
padding-top:7px;padding-bottom:7px;
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
background:var(--panel);border-bottom:1px solid var(--line); background:var(--panel);border-bottom:1px solid var(--line);
} }
#topbar .grow{flex:1} #topbar .grow{flex:1}
@@ -368,6 +574,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
} }
.chev:hover{color:var(--fg)} .chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)} .chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
.feed.scanning .badge{display:none}
.feed.scanning::after{
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
}
@keyframes ipxspin{to{transform:rotate(360deg)}}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle, /* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */ a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)} .ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
@@ -421,9 +634,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3} .tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px} .tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)} .tile:hover .txt b{color:var(--accent)}
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
themes, where on the row's own ground they clear it. */
.tag{ .tag{
font-size:11px;font-weight:600; font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none; padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
} }
.art{ .art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none; border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
@@ -444,6 +659,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center; font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
} }
.badge.zero{background:var(--raise);color:var(--faint)} .badge.zero{background:var(--raise);color:var(--faint)}
/* A selected row is --raise too, and the zero count's pill vanished into it. */
.sel .badge.zero{background:var(--bg)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */ /* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums} .badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
@@ -627,7 +844,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
#player{ #player{
border-top:1px solid var(--line);background:var(--panel); border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center; display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4); padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
box-shadow:0 -6px 24px rgba(6,10,16,.4);
} }
#player.on{display:grid} #player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video /* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
@@ -690,6 +909,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap} .logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px} .logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px} .card h3{margin:0 0 14px;font-size:17px}
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
at the foot of a long card. */
.cardx{float:right;margin:-5px -6px 0 8px}
.field{display:grid;gap:4px;margin-bottom:12px} .field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)} .field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)} .field .hint{font-size:11.5px;color:var(--faint)}
@@ -706,14 +928,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
background:var(--raise);border:1px solid var(--line);border-radius:9px; background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px; padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
} }
.toast.bad{border-color:var(--bad);color:var(--bad)} /* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
@keyframes in{from{opacity:0;transform:translateY(6px)}} @keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none} #burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */ /* Totals for what is showing, along the bottom, as the original's status bar. */
#status{ #status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel); padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
} }
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
body:has(#player.on) #status{padding-bottom:3px}
/* The feed's own header, kept to one line so the table starts high, as it did. */ /* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap} .fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none} .fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
@@ -737,7 +965,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)} #sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none} #sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41} #scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px} #player{
position:relative;z-index:39;gap:8px;
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
}
/* The feed list is reachable whether or not anything is playing. */ /* The feed list is reachable whether or not anything is playing. */
#burger{display:grid} #burger{display:grid}
@@ -838,10 +1070,82 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] .ep.sel .st, :root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl, :root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file, :root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff} :root[data-theme="classic"] .ep.sel .kind,
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */ /* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6} :root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad} :root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */ /* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff} :root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px} :root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
/* ---------- Glass: after Apple's Liquid Glass ---------- */
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */
:root[data-theme="glass"] body{
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
background:
radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent),
radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent),
radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent),
var(--bg);
}
:root[data-theme="glass"] #sidebar,
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #player,
:root[data-theme="glass"] #status,
:root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail,
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{
background:color-mix(in srgb,var(--panel) 70%,transparent);
-webkit-backdrop-filter:blur(24px) saturate(180%);
backdrop-filter:blur(24px) saturate(180%);
border-color:var(--glass-edge);
box-shadow:inset 0 1px 0 var(--glass-hi);
}
:root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{
/* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */
background:color-mix(in srgb,var(--bg) 65%,transparent);
-webkit-backdrop-filter:blur(20px) saturate(180%);
backdrop-filter:blur(20px) saturate(180%);
}
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)}
:root[data-theme="glass"] .card{border-radius:20px}
:root[data-theme="glass"] .toast{border-radius:14px}
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
@media (max-width:820px){
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
}
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{
background:color-mix(in srgb,var(--panel2) 60%,transparent);
border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi);
}
:root[data-theme="glass"] .btn,
:root[data-theme="glass"] .sidetools button,
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
:root[data-theme="glass"] body{background:var(--bg)}
:root[data-theme="glass"] #sidebar,
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #player,
:root[data-theme="glass"] #status,
:root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail,
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast,
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
:root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
}

View File

@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
// The files are one script, concatenated in this order, not modules: they share one top-level // The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it. // scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = { const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] }, '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'] }, 'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] }, 'login.html': { script: 'login.js', src: ['login'] },
}; };
@@ -38,7 +38,14 @@ export function buildStyle({ minify = true } = {}) {
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true }); 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'); 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('; ')}`); if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>')); 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. /// The page and its script, built: { html, js, script }, where script is the file's name.

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>iPodderX</title> <title>iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">

View File

@@ -1,3 +1,8 @@
<!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> <title>Sign in — iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -12,7 +17,7 @@
--line:#2c3849; --line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --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 */ --accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */ --ink:#0e131b; /* text on an accent fill */
@@ -22,7 +27,8 @@
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px; --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; --bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3; --panel2:#e9edf3;
@@ -30,15 +36,15 @@
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */ --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 */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10; --accent2:#985e0a;
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#985e0a;
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
body{ body{

View File

@@ -218,7 +218,7 @@ function due(ts){
async function prefsModal(){ async function prefsModal(){
const g = await api('/api/settings'); const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin); const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3> openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label> <div class="field"><label>Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=> <select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div> `<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
@@ -238,8 +238,7 @@ async function prefsModal(){
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div> ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
@@ -371,7 +370,8 @@ function opmlModal(){
}; };
} }
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force: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; $('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal; $('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while

View File

@@ -5,12 +5,8 @@ function connect(){
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true)); const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
let fresh={}; // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const tellNew=soon(()=>{ const mine=id=>S.feeds.some(f=>f.id===id);
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
fresh={};
},900);
sse.onmessage=m=>{ sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){
@@ -23,22 +19,28 @@ function connect(){
} }
else if(ev.ev==='download_done'){ else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds(); // 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'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries(); 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'){ else if(ev.ev==='feed_done'){
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); 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 // 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. // red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error') refreshFeeds(); else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
}; };
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
} }

View File

@@ -156,7 +156,7 @@ function renderGroup(f,kids){
} }
async function feedAction(a,f){ async function feedAction(a,f){
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } 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==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f); if(a==='rm') removeFeed(f);
if(a==='pin'){ if(a==='pin'){

View File

@@ -25,6 +25,22 @@ const VIEWS={
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
/// 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(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; const list=$('#feedlist'); const top=list.scrollTop;
@@ -104,6 +120,7 @@ function renderFeeds(){
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el); list.appendChild(el);
} }
paintScanning();
done(); done();
} }
function selectFeed(id){ function selectFeed(id){

View File

@@ -34,7 +34,6 @@ function pullShow(dy: number){
function refreshFeed(){ function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed); const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return; if(!f && S.feed !== ':all') return;
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
// New items arrive by the event stream when the scan finishes, as they do for a button press. // 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})}) api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true)); .catch(e => toast(e.message, true));

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});
}

View File

@@ -191,8 +191,7 @@ function keysModal(){
['Anywhere'], ['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'], [k('?'),'This list'],[k('Esc'),'Close a dialog'],
]; ];
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined 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> ?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
} }

View File

@@ -5,14 +5,19 @@
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from // 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. // the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = { const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true},
classic: {name: 'Classic, the 2004 Mac app', modes: false},
dracula: {name: 'Dracula', modes: true},
material: {name: 'Material', modes: true},
adwaita: {name: 'Adwaita', modes: true}, 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}, flatremix: {name: 'Flat Remix', modes: true},
paper: {name: 'Paper', modes: false}, 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}, 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'}; 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. // Before themes came in light and dark, ipx.theme in localStorage held one of these.

View File

@@ -26,7 +26,6 @@ const ICON={
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 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 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 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
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
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 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 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 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
@@ -40,7 +39,6 @@ const ICON={
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 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 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 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
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
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 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 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 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