18 Commits

Author SHA1 Message Date
a30edff248 Release 0.5.4: remembered view, Auto theme, Currently Listening
- Remember the feed/place and tab across a reload or new visit; an
  unknown or unsubscribed one lands on All Subscriptions instead of the
  first feed alphabetically.
- Add an Auto theme that follows the system's light/dark setting, and
  move Dark/Light/Classic/Auto into Settings as a dropdown alongside the
  header button's toggle.
- Add Currently Listening below Popular: episodes started and not
  finished, across every subscribed feed, one tap to resume. Reuses the
  existing entries/filter machinery (Filter::InProgress) rather than a
  new endpoint.
- Likely fix for the iOS bug where the topbar stopped responding to taps
  until a hard refresh (100vh -> 100dvh); unverified on a real device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
2026-09-14 15:38:03 +00:00
be3820bbbd Release 0.5.3: OPML orphan scan, feed error UI, small UI fixes
- Stop scanning an OPML/Patreon feed's derived rows once nobody subscribes
  to it; retire them (drop or orphan) the way sync_group already does when
  the list itself drops one. This is what let 922 defunct davewiner feeds
  keep scanning hourly after the OPML left config.
- Repair feed XML with a bare `&`, and give a plain reason (moved web page
  with its new address when linked, or nothing yet for an empty body)
  instead of a raw parser error.
- Show a failing feed's plain-English reason and next step (Unsubscribe /
  Use the new address) in the sidebar and on its own page, once it has
  been down a day.
- Fix four small UI bugs: show-note links open in a new tab, video files
  play as video, an opened item no longer disappears from the Unread tab,
  and Subscribe/Unsubscribe get their own icons.
- Fix Settings disappearing for non-admin accounts: it was hiding the
  whole modal instead of just the admin-only parts (Users, the editable
  schedule/quota, Save), which are the only parts the server actually
  refuses them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
2026-09-14 14:53:45 +00:00
51ce0bf9eb TODO.md: show a publisher's error in the UI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Dcx59boh4pasuNwAVU7un
2026-09-13 13:13:15 +00:00
6ec900e456 TODO.md: the errors in the production log
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Dcx59boh4pasuNwAVU7un
2026-09-13 13:06:16 +00:00
d4304869b6 TODO.md: cleared, the database trim and Popular are done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:30:10 +00:00
9aae3097e7 Release 0.5.2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:27:42 +00:00
2ff2074755 Answer status to the client that asked, not everyone
status is a terminal event. Broadcast, the healthcheck's answer ended any
ipx fetch that was watching a scan, which stopped reading at the next probe
while the scan carried on. It could not happen while status waited behind
the scan; answering it at once made it happen every 30 seconds. Each
connection's writer now takes private replies beside the broadcast, and
the test checks another client hears nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:27:42 +00:00
1698cf8d1e Answer status on the socket instead of queuing it behind the worker
The worker runs one job at a time, and status was one of its jobs, so the
Docker healthcheck waited behind the startup scan (54 seconds of it after
the last deploy) and timed out at 5. Any scan or download longer than
three probes would have had a working daemon marked unhealthy. The socket
now answers status straight away; everything else still queues. A test
fills the queue and checks status comes back anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:19:00 +00:00
b94a74ef15 Sign out through the proxy when the proxy signed you in
Sign out cleared ipx's cookies and showed its password page, while
Cloudflare Access still vouched for the person: nothing was signed out,
and the page looked like the wrong login. /api/me now says, for someone
the proxy signed in, where to go instead ([web] sign_out_url, which is
/cdn-cgi/access/logout behind Access), and /login sends anyone the proxy
vouches for on to their feeds. The header check both use is one function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:10:30 +00:00
9a8a3c696f docs: the ipodderx tile in Authentik's library
A bookmark application with no provider, so ipodderx shows in the library
beside Outline. Recorded with its id and how to delete it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 14:03:06 +00:00
bedf64e645 docs/sso.md: the sign-in setup ipodderx.sdf1.net really runs
Authentik is Cloudflare Access's OpenID Connect identity provider, not
something in the request path, and the tunnel's requests reach ipx from
the content_default gateway, 192.168.16.1, not 127.0.0.1. The page is
rewritten from what was measured, with checks for both the trusted and
the refused path, and docs/history.md records every change made to get
there with how to undo it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:58:18 +00:00
586d2c07a1 ipx user rename: give an account the name the proxy signs it in as
An account made by hand before the proxy was set up is called what it was
given ('rays'), while Cloudflare Access vouches for an email address. With
auto_create_users on, the first visit through the tunnel would make a
second, empty account. Renaming keeps the id, so feeds, read state and
admin rights go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:54:29 +00:00
2ba83c3aed Keep when each account was added and when it last signed in
users.created comes back, beside a new last_login, for whoever maintains
the server. A password sign-in, the token link and a request through the
proxy all count, recorded to the hour so the proxy's per-request vouching
is not a write each time. Settings -> Users and ipx user list show both.
The three user queries now share one row mapping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:37:09 +00:00
1352f0d54d Show notes cut off mid-tag give way to the item's description
libsyn served Daily Meditation Podcast's content:encoded cut at the '>'
inside a Tailwind class pasted from a web app, so 57 items began halfway
through a tag and the page showed the rest of it as text. Their
description was whole. A body that closes an attribute list before any
tag opens now falls back to the description, for RSS and Atom alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:29:00 +00:00
a958f7cb37 Trim the state database; Popular lists feeds the way Directory does
Drops the created columns on users, subscriptions and sessions, which were
written by every insert and read by nothing, and migrate()'s add list, whose
columns all predate 0.3.0. Removes Db::subscribed_feed_ids (no callers),
Db::subscriber_count (one caller wanting > 0) and Managed.orphaned (never
read). The old-database test now builds the tables with foreign keys on.

Popular now lists the feeds inside an OPML or a Patreon creator, never the
collection, as Directory does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 13:15:55 +00:00
457a58dcc5 Directory lists the feeds inside an OPML, not the OPML
Popular still counts an OPML as one feed, since everyone subscribed to it
counts for every feed inside and they would bury the rest. The directory is
for finding a show, so it lists them one by one and never the OPML. A feed
inside an OPML that looks private is hidden with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:26:52 +00:00
2af57065c6 Release 0.5.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:15:53 +00:00
564b011c7a Give the folder triangle a wider gutter
The feed list's left padding grows from 8 to 16 px and the triangle's
button from 16 to 24 px wide, so it is no longer cramped against the
folder's art. Everything in the list shifts together, so feeds still line
up with the places above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
2026-09-12 02:13:32 +00:00
20 changed files with 1515 additions and 436 deletions

View File

@@ -10,6 +10,103 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased]
## [0.5.4] - 2026-09-14
### Added
- Currently Listening, below Popular: episodes you started and have not finished, across every
feed you subscribe to. Tap one to pick up where you left off.
- Theme has an Auto option, alongside Dark, Light and Classic, that follows your system's
light/dark setting. All four are now also in Settings, as a dropdown next to the header
button's one-click-at-a-time toggle -- the same setting either way.
### Changed
- The feed (or Directory/Popular/All Subscriptions) and the tab you had open are remembered
across a reload or a new visit. A feed you no longer subscribe to, or a first visit with
nothing remembered yet, lands on All Subscriptions instead of the first feed alphabetically.
### Fixed
- On iOS, the topbar (the hamburger menu included) could stop responding to taps until a hard
refresh. The page sized itself with `100vh`, which iOS Safari measures against the address
bar's collapsed state rather than what is actually visible; `100dvh` tracks the real viewport
as the bar shows and hides.
## [0.5.3] - 2026-09-14
### Added
- A feed that has been failing for a day shows a plain-English reason in the sidebar and on its
own page, sorted from a 404, a 401/403, a 402, a name that no longer resolves, or a web page in
place of the feed -- with Unsubscribe or, when the page links its new feed, Use the new address.
A feed that fails once and reads fine again within a day is never flagged.
### Changed
- Unsubscribing from the last person's OPML or Patreon subscription now retires the feeds it
listed, the same as a feed the list itself drops: removed if nothing was downloaded, kept and
marked orphaned otherwise. Until now they stayed in the database and kept being scanned hourly
with auto-download on, which is how 922 defunct `davewiner` feeds outlived the OPML that listed
them.
### Fixed
- A feed whose XML uses a bare `&` instead of `&amp;` (kcpw, both feedland feeds) is now read
instead of refused.
- A feed URL that now serves a web page says so, and names the feed the page links to when it has
one, instead of a raw XML parser error.
- A publisher answering with an empty body (British Antarctic Survey's 202) is read as nothing new
to report, not a parse failure.
- A link in an item's show notes opens in a new tab instead of navigating away from ipx.
- A video file plays as video, in a small floating pane above the player bar, instead of silently
as sound only.
- On the Unread tab, opening an item no longer makes it disappear from the list -- it stays until
you open a different one, even if a scan finishes and refreshes the list while it is open.
- Subscribe and Unsubscribe have their own icons (a circled check and a circled minus) instead of
sharing the generic plus and minus used for adding feeds, users and imports.
- Settings no longer disappears for a non-admin account. It was hiding the whole Settings modal
along with the log and the users screen, but a non-admin has settings of their own in there --
their subscriptions' Export and Import, and the schedule and quota are worth seeing even without
a say in them. Only the log and the users screen, which the server also refuses them, are gone.
## [0.5.2] - 2026-09-12
### Added
- Settings → Users and `ipx user list` show when each account was added and when it last signed
in, to the hour.
- `ipx user rename <name> <new name>` renames an account and keeps its feeds, read state and admin
rights. An account made before the proxy was set up can take the name the proxy signs it in as.
### Changed
- Directory and Popular list the feeds inside an OPML one by one, and no longer the OPML itself,
so you can subscribe to just the shows you want.
- The database no longer records when subscriptions and sign-in sessions were created. Nothing
ever read it, and an existing database drops the columns on its next start.
### Fixed
- Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of
HTML: the item's other copy of its notes is used instead, from the next time the feed changes.
Daily Meditation Podcast had 57.
- Docker no longer shows ipodderx as starting, or calls it unhealthy, while it scans or downloads:
`ipx status` answers at once instead of waiting for the job in progress to finish.
- Signing out after signing in through Cloudflare Access no longer lands on ipodderx's own password
page. With the new `sign_out_url` set, Sign out ends the Access session, and the password page
sends anyone the proxy signs in straight to their feeds.
- The sign-in guide, `docs/sso.md`, describes the setup ipodderx.sdf1.net really runs: Authentik as
Cloudflare Access's identity provider, and how to find the address ipx has to trust. It had never
been checked against a real setup, and pointed at the wrong address.
## [0.5.1] - 2026-09-12
### Fixed
- The triangle that opens an OPML or Patreon folder was cramped against the folder's art. It has
more room now, and a wider target to click.
## [0.5.0] - 2026-09-12
### Added
@@ -258,7 +355,11 @@ The long form, with what was wrong before and how it was found, is in
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...main
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...main
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0

View File

@@ -17,6 +17,7 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
Deploying a change is: build and push the image, then pull it and recreate the container.
@@ -45,7 +46,10 @@ docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
Run those again now and then, or the local copies go stale.
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
the worker is alive, not just the web port. The container restarts on its own after a reboot.
the daemon answers there and can read its database, not just that the web port is up. The socket
answers `status` itself instead of queuing it behind the worker's current job, so a long scan or
download does not fail the check; it also means a worker stuck on one job would still pass. The
container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
@@ -113,8 +117,9 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
fighting over the database, with the stale one still holding the port.
* `/api/settings` answering `200` does **not** mean the worker is alive — it is a different task.
Probe the control socket (`ipx status`) to check that.
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log.
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait

2
Cargo.lock generated
View File

@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.5.0"
version = "0.5.4"
dependencies = [
"ammonia",
"anyhow",

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.5.0"
version = "0.5.4"
edition = "2024"
[dependencies]

77
TODO.md
View File

@@ -1,30 +1,57 @@
# To do
## Cut what is no longer needed
## Errors in the log
From a whole-repo audit for over-engineering on 2026-09-12. Biggest cut first.
From the production log and the feeds' stored errors on 2026-09-13. The Docker log only reached back
to 12:32 UTC, so the list comes from `feeds.last_error`: 57 of 1,059 feeds. None of it is ipx's
User-Agent; a browser gets the same answers.
- [x] **Pre-accounts adoption and the dead `entries` columns.** The copy of the old read state into
`entry_state` and the `entries.read`, `flagged` and `position` columns are gone. Its other half,
subscribing the first admin to the catalogue, was not dead and stays as `adopt_catalogue`.
(`src/db.rs`, `src/main.rs`)
- [x] **`contrib/` systemd units.** From before the container; nothing points at them.
- [x] **`migrate_opml_children`.** A one-time move of OPML children out of `config.toml` that has
run. Delete it and its call. (`src/main.rs`)
- [x] **The legacy `interval_mins` key.** Production uses `schedule`. Delete the field, the fallback
in `General::interval` and its test. (`src/config.rs`)
- [x] **`Db::entries` and `Db::count_entries`.** One-line wrappers only the tests call; the tests
call `entries_in` and `count_in` instead. (`src/db.rs`)
- [x] **`web::generate_token`.** Repeats `auth::new_session_token`. Use that. (`src/web.rs`)
- [x] **Page leftovers.** `globalEvery`, `S.busy`, `S.limit`, `unitOptions`' `firstLabel`, `--r`,
`.ep.open`, the phone `.ep .art`, the duplicate phone `.fhead.slim{flex-wrap}`, the second
`#sidebar{z-index}`, and the `on()` helper. (`web/index.html`)
- [x] **`logbuf` visitors.** `record_i64`, `record_u64` and `record_bool` repeat what `Visit`'s
defaults already do through `record_debug`. (`src/logbuf.rs`)
- [x] **The `infer` dependency.** Its torrent check is the `d8:announce` test on the next line.
- [x] **The `dirs` dependency.** `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `HOME` from `std::env`.
- [x] **The `tokio-stream` dependency.** `futures_util::stream::unfold` over the broadcast receiver.
- [x] **The icon inlined four times.** About 94 KB of base64 across both pages; serve it once as
`/icon.png` from `include_bytes!`, open without signing in like `/login`.
- [x] **Stop scanning an OPML's feeds once nobody subscribes to it.** 55 of the 57 are feeds from
`davewiner` (lists.opml.org/davefeeds.xml). The list left `config.toml` about 14 hours before this
was written, but its 922 feeds are still in the database and still scanned every hour, with
auto-download on: `subscriptions()` adds every derived feed, and with no parent to copy from,
`auto_download` falls back to `true` (`src/main.rs:937`). `docs/users.md` says a feed stops being
scanned when its last subscriber leaves. Skip a derived feed whose parent is not in config, and
when the last subscriber leaves an OPML, treat its feeds the way `sync_group` treats ones the list
dropped: remove those with nothing downloaded, mark the rest orphaned. `remove_feed` and `ipx rm`
both leave them behind today. (`src/main.rs`, `src/web.rs`)
- [x] **Read feeds with a bare `&`.** kcpw has `<link>https://kcpw.org/?post_type=post&p=125715</link>`,
and both feedland feeds have the same fault. Strict XML refuses them; browsers and other readers do
not. When `feed::parse` fails, try once more with every `&` that does not start an entity written
as `&amp;`. Nobody subscribes to these three now, but the next feed like them will fail the same
way. (`src/feed.rs`)
- [x] **Say what came back when it is not a feed.** Thirteen errors read "not RSS (the input did not
begin with an rss tag) and not Atom (...)". Each one checked was a web page: the feed moved and its
old URL redirects to the site, or the domain lapsed. Say "got a web page, not a feed", and when the
page links a feed (`<link rel="alternate" type="application/rss+xml">`), name it. That link found
the new feed for om.co, ms.now, Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and
Daily Kos. A `202` with an empty body (British Antarctic Survey) should read as "nothing yet", not
as a parse failure. (`src/feed.rs`)
- [x] **Show a publisher's error in the UI.** Today a failing feed shows its raw error in red only
once you open it (`web/index.html:877`, `:960`); the OPML view marks a failing child "error"
(`:988`), and the sidebar shows nothing. Mark a failing feed in the sidebar too, and say whose
problem it is and what to do, in plain words: a 404 means the publisher took the feed down or moved
it, 401 and 403 that the site refuses ipx, 402 that the feed needs a paid plan, a name that no
longer resolves that the site is gone, and a web page instead of a feed that it moved (with the new
URL when the page links one). Offer Unsubscribe, or Use the new address. Keep timeouts, 5xx and
429 out of it, and flag a feed only once it has failed for a day: macmanx failed once and read
fine an hour later. That needs the time a feed started failing, kept beside `last_error` and
cleared when a check succeeds. (`web/index.html`, `src/db.rs`, `src/main.rs`)
- [ ] **Westenberg.** The only failing feed anyone subscribes to. The Hacker News stars list still
gives `joanwestenberg.com/rss`, which is a 404; the feed is now `joanwestenberg.com/feed`.
Nothing for ipx to fix; subscribe to the new URL directly.
After these: `cargo test`, `node tests/page-smoke.js`, `npx playwright test`.
## Other Fixes and Features
- [x] Remember which feed is selected and view (all, unread, flagged, etc) user as selected between visits. If unknown default to All Subscriptions
- [x] When clicking any link it should open in a new tab
- [x] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh — likely fixed (100dvh instead of 100vh), unverified on a real device; reopen if it still happens
- [x] video files play as audio files, they should play as video.
- [x] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes
- [x] Below Popular, have a currently listening section to show what podcasts have been started and not finnished
- [x] Update subscribe/unsubscribe icons to be circle-minus (unsubscribe) and circle-check (subscribe)
- [x] If I'm on the Unread tab, and I click to read an item the entry in the list will disappear. it should remain until I click to another item.
## Directory Overhaul
- [ ] Directory needs to be more functional, with categories and a more interesting layout. use /frontend-design to help

View File

@@ -50,10 +50,10 @@ entries feed_id, guid, title, link, published, description, first_seen,
image, duration, episode, season PK (feed_id, guid)
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
bytes_done, downloaded_at, last_error
users id, name, pass_hash, is_admin, created
sessions token, user_id, created, seen
users id, name, pass_hash, is_admin, created, last_login
sessions token, user_id, seen
subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
max_new_per_check, created PK (user_id, feed_id)
max_new_per_check PK (user_id, feed_id)
entry_state user_id, feed_id, guid, read, flagged, position
PK (user_id, feed_id, guid)
```
@@ -62,9 +62,11 @@ Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
older database.
Schema changes: add the table or column to `SCHEMA`, and for a column also to the list in
`migrate()`, which does `PRAGMA table_info` then `ALTER TABLE ADD COLUMN`. `Db::memory()` runs the
same path as `Db::open`, so a migration-only column cannot pass tests while missing in production.
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
path as `Db::open`, so a migration cannot pass the tests while missing in production.
## Control socket
@@ -85,7 +87,9 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
there.
there. Commands run one at a time, in the order they arrive, except `status`: the socket answers it
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
answers only the client that asked, since `status` would end any other client's session.
Progress carries the enclosure id, without which a UI cannot tell one download from another and
ends up animating every pending row. It is throttled to whole percents. The stream is a broadcast,
@@ -115,7 +119,7 @@ else a `401`.
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
| `GET /api/events` | SSE, the same broadcast the socket carries |

View File

@@ -67,6 +67,7 @@ token = "" # generated and saved on first run
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"]
auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30
```
@@ -77,6 +78,9 @@ session_days = 30
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
for it. Name the proxy, never a subnet.
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
the proxy signs them straight back in.
* **`session_days`** — sign a session out after this long without a request.
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can

View File

@@ -6,6 +6,127 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is.
## 2026-09-14 — Settings, for everyone with an account
A user reported that Settings disappeared shortly after they signed in: it showed for a moment,
then was gone. `#prefs` sat inside the same `.tgroup` as `#logs`, and `api('/api/me')` hid the
whole group -- `$('#admintools').hidden=true` -- the moment it learned the account was not an
admin. Nothing wrong with that check timing; it was hiding the wrong thing.
The Settings modal is not actually all-or-nothing. `GET /api/settings`, and Export and Import
OPML, carry no admin check server-side -- `export_opml` and `import_opml` work from a user's own
subscriptions, and the schedule/quota page is read-only information, not a control. Only the
`PATCH` that changes those settings, and the Users screen behind it, return 403 for anyone but an
admin. The comment above the old hide -- "scanning, quotas, accounts and the log are the
operator's business" -- was wrong about quotas and half wrong about accounts: reading them is
everyone's; changing them is the operator's.
`prefsModal()` now branches on `S.me.admin` the way the per-feed settings modal already does for
its URL field: a non-admin gets the schedule and quota as text, Subscriptions (Export/Import)
in full, and no Users section or Save button. Only `#logs` stays hidden, since the log names every
account and every failed sign-in. The browser test for a second account asserted the old
behaviour outright (`#prefs` hidden, not an admin) rather than what the server actually allows;
fixing the UI meant fixing the test's premise too, not just the assertion.
## 2026-09-12 — Healthy while busy
After a deploy the container sat at "starting" for a minute, and Docker's health log showed two
`ipx status` probes exceeding their 5-second timeout. The daemon's own log explained it. The first
scan after the start fetched 23 feeds, from 14:10:41 to 14:11:35, and both probes' `status`
commands waited in the job queue behind it; they were answered together at 14:11:35, straight after
`scan_done`. The worker runs one job at a time and `status` was one of its jobs, so any scan or
download longer than about a minute and a half, three 30-second probes, would have had Docker call
a working daemon unhealthy.
The socket now answers `status` itself, from two short queries, and only real work goes through the
queue. The trade is that healthy now means the daemon answers on its socket and can read its
database; a worker stuck on one job would still pass. Asking a daemon that downloads hour-long
podcasts to be idle within five seconds was never a fair test of whether it was alive. A test holds
the queue full and checks `status` still comes back.
The first version broadcast the answer, as the queued one had been. Timing `status` during a forced
scan in production showed the catch: `status` is a terminal event, so the `ipx fetch` watching that
scan stopped reading at the first probe and printed the status line as its last, while the scan
carried on. When `status` waited behind the scan it could never arrive first, so this had never
shown. The answer now goes only to the client that asked, and the test checks that another client
hears nothing.
## 2026-09-12 — Signing in through Authentik, for real
Ray could not get Authentik's sign-in to reach ipx, following `docs/sso.md`, which had been written
without ever being tried. Looking at the Cloudflare account through its API showed that side was
already complete. Authentik is Zero Trust's OpenID Connect identity provider; the Access application
`ipodderx` allows only it and a list of five addresses; the tunnel `rays-unraid` routes
`ipodderx.sdf1.net` to `192.168.1.130:8099`; DNS is a proxied CNAME to the tunnel. Access's log
showed `rays@sdf1.net` signing in through it. Nothing on Cloudflare was changed, so no other site
was touched.
The gaps were all at ipx's end: `trusted_header` was empty, `trusted_proxies` held only loopback,
and the account was called `rays` while the header carries `rays@sdf1.net`.
Finding the address to trust took the most time. The page said `127.0.0.1`, but `cloudflared` runs in
its own container and reaches ipx through the host's published port. ipx logs no peer addresses, so
the address was read from `/proc/net/tcp` inside the ipx container: `192.168.16.1`, the gateway of
`content_default`, where Docker's masquerade puts traffic crossing from another bridge. A request
from Tower's own shell arrived as `192.168.1.130` instead, and a throwaway `busybox` on the default
bridge as `192.168.16.1`: the first was refused with the header, the second believed. LAN machines
keep their own addresses, since Docker forwards published ports with iptables (the userland proxy
only handles loopback).
Every change, in order, with how to undo it:
1. **Code**, commit `586d2c0`: `ipx user rename`, deployed. Revert the commit and redeploy to
remove it; nothing depends on it once used.
2. **Account**: `docker exec iPodderX ipx user rename rays rays@sdf1.net`. Same id, so its feeds,
read state, password and admin rights stayed. Undo: `docker exec iPodderX ipx user rename
rays@sdf1.net rays`. Signing in at `/login` now takes the new name.
3. **Config**, `/mnt/fast/appdata/ipodderx/config.toml`, `[web]`: `trusted_header` from `""` to
`"Cf-Access-Authenticated-User-Email"`, and `"192.168.16.1"` added to `trusted_proxies`. The
file as it was is `config.toml.2026-09-12-sso.bak` beside it. Undo: copy the backup back and
`docker compose -f /mnt/fast/arcane/projects/content/compose.yaml restart ipodderx`.
4. **Cloudflare, Docker networks and other containers**: unchanged. The `busybox` test container
was removed when it exited, and its image afterwards.
5. **Authentik**, later the same day, because ipodderx had no tile in its library while Outline
did: a bookmark application `ipodderx` (pk `5854a98e-816a-4c4f-9f27-63e69dc29d1d`), made
through the API with a token of Ray's. No provider and no policy bindings, like Outline's, the
iPodderX icon, and a link to `https://ipodderx.sdf1.net`. It changes nothing about who can sign
in. Undo: delete it under Applications → Applications, or
`DELETE /api/v3/core/applications/ipodderx/`.
6. **Signing out**, later again. Sign out landed on ipx's password page while Access still vouched
for Ray, so it signed nothing out, and the page looked like the wrong login. Cloudflare's
`/cdn-cgi/access/logout` ends the Access session for every Access application at once (there is
no per-application sign-out, and it takes no redirect), and Authentik's end-session only ends
one application's session unless single logout is set up there. Ray chose Access's sign-out. New
`[web] sign_out_url`, set to `/cdn-cgi/access/logout` in production (the file as it was is
`config.toml.2026-09-12-signout.bak`), and `/login` now sends anyone the proxy vouches for on to
`/`. Undo: take the key out and restart; the code does nothing without it.
What the address trusts is any container on Tower that connects through the host's port, not only
`cloudflared`. Verifying Cloudflare's signed `Cf-Access-Jwt-Assertion` would remove that, and is
the upgrade if it matters.
## 2026-09-12 — Trimming the state database
An audit of the database layer, with a read-only copy of production to check it against. The
data was already clean: no tables or indexes left from older versions, 47 free pages after the
column drops earlier the same day, and one stray `entry_state` row. The code had five things:
- `migrate()` still added eight columns to any table missing them. All eight shipped in 0.2.0 and
upgrades now start from 0.3.0 at the oldest, so the list and its loop went; the `retired` drop
list stays, since a database coming from 0.4.0 still has the old read columns.
- `created` on `users`, `subscriptions` and `sessions` was written by every insert and read by
nothing. They joined `retired`. The old-database test now builds all three tables, foreign keys
included, since `DROP COLUMN` on a table that references another was the part worth proving.
- `Db::subscribed_feed_ids` had no callers, though its doc said the scanner walked it.
`Db::subscriber_count` had one caller asking whether it was above zero, which
`subscriber_counts().contains_key` answers. `Managed.orphaned` was selected and never read.
- `users.created` came back the same afternoon, with `last_login` beside it. Nothing read it, but
when an account was made and when it last signed in is what you want to know when tidying
accounts, and it cannot be recovered later. Both existing accounts got their creation times back
from the backup taken before the drop, and a last sign-in from their newest session in it.
`last_login` is kept to the hour, because the proxy vouches for every request and that would
otherwise be a write each time.
## 2026-09-12 — Cutting what had outlived its reason
A whole-repo audit for over-engineering listed twelve things to cut, and all of them went.

View File

@@ -1,9 +1,8 @@
# Signing in through Cloudflare Zero Trust or Authentik
# Signing in through Cloudflare Access and Authentik
ipx can take the signed-in identity from whatever sits in front of it, instead of asking for a
password itself. Both products below do the same thing in the end: they authenticate the person and
pass the result to the origin in a **header**. ipx reads that header, finds (or creates) the
matching account, and gets on with it.
password itself. The proxy authenticates the person and passes the result to ipx in a **header**;
ipx reads it, finds (or creates) the matching account, and gets on with it.
Read [How this is secured](#how-this-is-secured) before exposing anything. The short version: a
header is worth exactly as much as the hop that set it, so ipx only believes one from an address you
@@ -11,195 +10,173 @@ list.
---
## The ipx side (both setups)
## How ipodderx.sdf1.net does it
Checked end to end on 2026-09-12. An earlier version of this page had never been tried against a
real setup and pointed at the wrong address.
```
browser ─► Cloudflare Access, app "ipodderx" ─── sign in ───► Authentik (OpenID Connect)
─► tunnel "rays-unraid" (the cloudflared container on Tower)
─► http://192.168.1.130:8099 ─► ipx
```
Authentik is not in the request path. It is the identity provider Cloudflare Access asks. Access
then adds `Cf-Access-Authenticated-User-Email`, the email address Authentik gave it, to every
request it forwards through the tunnel, and ipx signs that person in.
| Piece | Where | Setting |
|---|---|---|
| Identity provider | Zero Trust → Settings → Authentication | `Authentik`, OpenID Connect; scopes `openid email profile` |
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
```toml
[web]
enabled = true
bind = "0.0.0.0:8099"
token = "…" # keep it: it is the admin, used by the healthcheck
# The header your proxy sets. Empty (the default) disables this whole path.
trusted_header = "Cf-Access-Authenticated-User-Email" # Authentik: "X-authentik-username"
# Addresses allowed to assert that header -- the proxy, and nothing else.
trusted_proxies = ["127.0.0.1", "::1"]
# Create an account the first time the proxy vouches for a name ipx has not seen.
bind = "0.0.0.0:8099"
trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
auto_create_users = true
sign_out_url = "/cdn-cgi/access/logout"
session_days = 30
```
Restart the daemon after editing. Accounts made this way have **no password**: they can only ever
arrive through the proxy. `ipx user list` marks them `proxy only`.
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
restart ipodderx`.
The first account created is an admin. Every later one is an ordinary user, and an ordinary user
cannot change global settings, a feed's URL or folder, or how often feeds are scanned: the API
refuses those with a `403`, not just the UI. Everything else about a feed (which items they want,
whether to fetch them, how many at a time) is theirs alone; see [users.md](users.md).
### What was missing
Somebody arriving through the proxy for the first time starts with **no feeds**, because
subscriptions are per person. Adding a feed someone else already reads costs no second fetch and no
second copy on disk.
Cloudflare and Authentik were already right. Three things on the ipx side were not:
Promote someone with:
1. **`trusted_header` was empty**, which switches the whole proxy path off. ipx ignored the header
and asked for a password.
2. **`trusted_proxies` listed only `127.0.0.1`.** The tunnel's requests do not come from there;
see the next section.
3. **The account had the wrong name.** It was made by hand as `rays`, but the header carries
`rays@sdf1.net`. With `auto_create_users` on, the first visit would have made a second, empty
account. `ipx user rename rays rays@sdf1.net` fixed that without losing anything.
### The address to trust, and why it is 192.168.16.1
`cloudflared` runs in its own container and reaches ipx through the host's published port. Docker
(iptables firewall backend) masquerades traffic between its bridge networks, so the tunnel's
requests arrive from the **gateway of ipx's own network**, `content_default`:
```sh
ipx user list
echo -n 'a good password' | ipx user passwd <name> # optional: also lets them sign in directly
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
```
Local sign-in at `/login` keeps working alongside all of this, which is how you get in from the LAN
when the tunnel is down. So does the shared `[web] token`, which signs in as the admin: that is
what the Docker healthcheck uses, and the way back in if you lock yourself out. A brand new database
starts with **admin / ipodderx** — change it.
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
lists them in hex.)
If the `content` project's network is ever recreated, its gateway can change. Check it again, and
update `trusted_proxies` to match.
### Names
The username is the email address, lower-cased: `rays@sdf1.net`. To sign in at `/login` with a
password from the LAN, use that name too.
To let someone else in, add their address to the Access policy; they need an Authentik account with
that email. With `auto_create_users = true` they get an ipx account on their first visit, as an
ordinary user with no feeds. An account made before the proxy can be given the name the proxy will
send:
```sh
docker exec iPodderX ipx user rename <old name> <email address>
```
### Signing out
**Sign out** sends someone the proxy signed in to `sign_out_url`, here Cloudflare's
`/cdn-cgi/access/logout`. That ends your Access session for **every** Access application,
`code.sdf1.net` included: Cloudflare has no way to end just one, and its sign-out page does not send
you anywhere afterwards. The next visit goes back through Authentik, which lets you straight in if
you are still signed in there. Signing out of Authentik itself is Authentik's own sign-out.
ipx never shows its password page to someone the proxy vouches for: `/login` sends them on to their
feeds.
### The tile in Authentik's library
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
tile. Who actually gets in is still up to the Access policy.
### Check it
```sh
# From Tower itself: not a trusted address, so the header is ignored.
curl -s -H 'Accept: application/json' -H 'Cf-Access-Authenticated-User-Email: rays@sdf1.net' \
http://192.168.1.130:8099/api/me # -> sign in
# From a container on a Docker bridge, as cloudflared is: believed.
docker run --rm --network bridge mirror.gcr.io/library/busybox wget -qO- \
--header 'Accept: application/json' --header 'Cf-Access-Authenticated-User-Email: rays@sdf1.net' \
http://192.168.1.130:8099/api/me # -> {"admin":true,"name":"rays@sdf1.net"}
```
Then open `https://ipodderx.sdf1.net` in a private window. Authentik should ask who you are, and
ipx should show `rays@sdf1.net` in the sidebar footer without asking for a password.
---
## Cloudflare Zero Trust
## The ipx settings
This is what runs `ipodderx.sdf1.net`: a `cloudflared` tunnel to the origin, with an Access
application in front of it. Cloudflare authenticates the visitor and adds
`Cf-Access-Authenticated-User-Email` to every request it forwards.
### 1. The tunnel
In **Zero Trust → Networks → Tunnels**, either use the existing tunnel or create one, then add a
public hostname:
| Field | Value |
| Key | What it does |
|---|---|
| Subdomain / domain | `ipodderx` / `sdf1.net` |
| Type | HTTP |
| URL | `localhost:8099` (or the LAN address of the box) |
| `trusted_header` | The header the proxy sets. Empty, the default, turns the proxy path off. |
| `trusted_proxies` | The addresses allowed to set it. Nothing else is believed. |
| `auto_create_users` | Make an account the first time the proxy vouches for a name ipx has not seen. |
| `sign_out_url` | Where Sign out sends someone the proxy signed in: the proxy's own sign-out. Empty sends them to the sign-in page, where the proxy signs them straight back in. |
| `session_days` | How long a password sign-in lasts without use. |
Use `localhost` when `cloudflared` runs on the same machine as ipx — that keeps the origin request
coming from `127.0.0.1`, which is already in `trusted_proxies`. If `cloudflared` runs elsewhere (its
own container, another host), put **its** address in `trusted_proxies` instead, and make sure
nothing else can reach port 8099.
The first account ever created is an admin. Every later one is an ordinary user, who cannot change
global settings, a feed's URL or folder, or how often feeds are scanned: the API refuses those with
a `403`, not just the UI. Everything else about a feed is theirs alone; see [users.md](users.md).
### 2. The Access application
**Zero Trust → Access → Applications → Add an application → Self-hosted**:
- Application domain: `ipodderx.sdf1.net`
- Session duration: whatever suits; ipx keeps its own 30-day session on top.
- Add a policy — *Allow*, with a rule such as `Emails` → your address, or `Emails ending in` →
your domain. Anyone this policy admits gets an ipx account when `auto_create_users` is on, so keep
the policy as narrow as the people you actually want reading your feeds.
### 3. Point ipx at the header
```toml
trusted_header = "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"]
```
The username becomes the email address, lower-cased (`ray@example.com`). That is what shows in the
sidebar and what `ipx user list` prints.
### 4. Check it
```sh
# From the box itself: no header, no session -> the sign-in page.
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' -H 'Accept: text/html' http://127.0.0.1:8099/
# Pretending to be the tunnel (only works because 127.0.0.1 is trusted):
curl -s -H 'Cf-Access-Authenticated-User-Email: you@example.com' http://127.0.0.1:8099/api/me
```
Then load `https://ipodderx.sdf1.net` in a browser: Cloudflare should ask who you are, and ipx
should show your address in the sidebar footer without ever asking for a password.
Local sign-in at `/login` keeps working alongside the proxy, which is how you get in from the LAN
when the tunnel is down. So does the shared `[web] token`, which signs in as the admin and is the
way back in if you lock yourself out. A brand new database starts with **admin / ipodderx**;
change it.
---
## Authentik
## Authentik in the request path instead
Authentik does this with a **Proxy Provider** plus an **outpost**, which sits in the request path and
adds `X-authentik-username` (also `X-authentik-email`, `X-authentik-name`, `X-authentik-groups`).
Not what ipodderx.sdf1.net uses, and **not verified**. Authentik can also sit in front of ipx
itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-username`:
### 1. Provider
**Applications → Providers → Create → Proxy Provider**:
- Name: `ipx`
- Authorization flow: your usual (`default-provider-authorization-implicit-consent`)
- Mode: **Forward auth (single application)** if an existing reverse proxy fronts ipx, or
**Proxy** to let the outpost talk to ipx directly.
- External host: `https://ipodderx.example.net`
- Internal host (Proxy mode): `http://<ip of the ipx box>:8099`
### 2. Application and outpost
**Applications → Create**, bind it to that provider, and give it a policy so only the people you
mean are let through. Then add the provider to an outpost (**Applications → Outposts**, the embedded
one is fine).
### 3. Forward auth, if you use nginx/SWAG in front
In the server block for ipx:
```nginx
location /outpost.goauthentik.io {
proxy_pass http://authentik-server:9000/outpost.goauthentik.io;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
add_header Set-Cookie $auth_cookie;
auth_request_set $auth_cookie $upstream_http_set_cookie;
}
location / {
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
# This is the line that matters to ipx.
auth_request_set $authentik_username $upstream_http_x_authentik_username;
proxy_set_header X-authentik-username $authentik_username;
proxy_pass http://ipx:8099;
}
```
### 4. Point ipx at the header
```toml
trusted_header = "X-authentik-username"
trusted_proxies = ["172.18.0.5"] # the outpost or nginx container, NOT a whole subnet
```
Usernames arrive as Authentik knows them (`ray`), lower-cased.
- Applications → Providers → Create → Proxy Provider; mode **Proxy** (the outpost talks to ipx) or
**Forward auth** (an existing reverse proxy asks the outpost).
- Applications → Create, bound to that provider, with a policy; add the provider to an outpost.
- In ipx: `trusted_header = "X-authentik-username"`, and the outpost's or reverse proxy's address
in `trusted_proxies`. Measure that address as above rather than guessing it.
---
## How this is secured
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the
request falls through to a session cookie or the shared token. This is the whole security boundary,
so:
request falls through to a session cookie or the shared token. That is the whole security boundary.
- List the **proxy's own address**, not a range. `["127.0.0.1"]` when the tunnel runs beside ipx;
the container's IP when it does not.
- Never list a LAN subnet. Anyone on your network could then send
`Cf-Access-Authenticated-User-Email: admin@…` and be your admin.
- Make sure the origin port is not reachable *around* the proxy by anyone you would not admit
through it. If it is, bind ipx to `127.0.0.1` and let only the proxy reach it.
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
itself, arrive under their own addresses and cannot set the header; the checks above show both
sides. Never list a LAN address or range: anyone there could then send
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
Verify the refusal, don't assume it — set `trusted_proxies = ["10.9.9.9"]` briefly and confirm a
header from your machine gets a `401`:
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
boundary, and is the upgrade if that ever matters.
```sh
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Cf-Access-Authenticated-User-Email: someone@example.com' http://127.0.0.1:8099/api/me
```
**What ipx does not do:** it does not verify Cloudflare's `Cf-Access-Jwt-Assertion` signature or
Authentik's session. It trusts the hop. That is a deliberate trade — it keeps the configuration to
three lines — and it is sound exactly as long as the point above holds.
**Turning it off:** clear `trusted_header`. Existing proxy-only 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>`).
---
@@ -207,22 +184,20 @@ in with them until they are given a password (`ipx user passwd <name>`).
## Everyday administration
```sh
ipx user list # who exists, and how each one signs in
ipx user list # who exists, how each signs in, and when
echo -n 'secret123' | ipx user add sam # local account, password on stdin
ipx user add sam --no-password # proxy-only account, created ahead of time
ipx user add sam@example.com --no-password # proxy-only account, made ahead of time
ipx user rename sam sam@example.com # give an account the name the proxy sends
echo -n 'newsecret' | ipx user passwd sam # change a password
ipx user rm sam # remove the account
```
Set `auto_create_users = false` once everyone who should have an account has one. After that the
proxy vouching for an unknown name is logged and refused, rather than quietly making an account.
Pre-create people instead with `ipx user add <name> --no-password`, using exactly the name the
header will carry (Cloudflare sends the email address, lower-cased).
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
that read a password.
Scanning intervals, the disk quota, retention, the download folder and a feed's URL are
**admin-only**: the Settings button is hidden for everyone else, and the API refuses the change even
if the request is made by hand. Everyone controls their own keywords, auto-download, explicit
setting and per-scan cap, along with their own read state and which feeds they see.
Set `auto_create_users = false` once everyone who should have an account has one. After that the
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
the exact name the header will carry.
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.

View File

@@ -73,12 +73,13 @@ re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. Feeds from an
OPML subscription are left out, since they come with the OPML. So is anything that looks private: a
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the
query, or a feed from a paid-feed service such as Patreon or Supercast, which put the key in the
path. Those are someone's paid subscriptions, and listing them would let anyone here read what they
pay for.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
they pay for.
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its

View File

@@ -80,6 +80,10 @@ pub struct Web {
pub trusted_proxies: Vec<String>,
/// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool,
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
/// while the proxy still vouches for them, so this is the proxy's own sign-out:
/// `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to /login.
pub sign_out_url: String,
/// Sign a session out after this long without a request.
pub session_days: i64,
}
@@ -93,6 +97,7 @@ impl Default for Web {
trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
auto_create_users: true,
sign_out_url: String::new(),
session_days: 30,
}
}

343
src/db.rs
View File

@@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS feeds (
last_checked INTEGER,
ttl_mins INTEGER,
last_error TEXT,
-- 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.
error_since INTEGER,
-- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
orphaned INTEGER NOT NULL DEFAULT 0,
-- The OPML subscription this feed came from.
@@ -73,7 +77,9 @@ CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
pass_hash TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created INTEGER NOT NULL
-- For whoever maintains the server. NULL where it is not known.
created INTEGER,
last_login INTEGER
);
-- What one person wants from a feed. The feed, its items and its files are shared; this
@@ -85,7 +91,6 @@ CREATE TABLE IF NOT EXISTS subscriptions (
auto_download INTEGER,
allow_explicit INTEGER,
max_new_per_check INTEGER,
created INTEGER NOT NULL,
PRIMARY KEY (user_id, feed_id)
);
@@ -104,7 +109,6 @@ CREATE TABLE IF NOT EXISTS entry_state (
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created INTEGER NOT NULL,
seen INTEGER NOT NULL
);
";
@@ -127,6 +131,23 @@ pub struct User {
pub name: String,
pub pass_hash: Option<String>,
pub is_admin: bool,
/// When the account was made and when it last signed in, for whoever maintains the server.
pub created: Option<i64>,
pub last_login: Option<i64>,
}
/// The columns `user_row` reads, in its order.
const USER_COLS: &str = "id, name, pass_hash, is_admin, created, last_login";
fn user_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<User> {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
pass_hash: r.get(2)?,
is_admin: r.get::<_, i64>(3)? != 0,
created: r.get(4)?,
last_login: r.get(5)?,
})
}
/// A feed derived from an OPML subscription rather than written into the config.
@@ -136,30 +157,35 @@ pub struct Managed {
pub url: String,
pub title: Option<String>,
pub group_id: String,
pub orphaned: bool,
}
/// Adds the columns later versions introduced and drops the ones they retired. CREATE TABLE IF
/// NOT EXISTS does nothing to a table that already exists, so an installed database needs both.
/// NOT EXISTS leaves a table that already exists alone, so an installed database needs both done
/// explicitly. Columns from before 0.3.0, the oldest version an upgrade may start from, need no
/// entry.
fn migrate(conn: &Connection) -> Result<()> {
let wanted: &[(&str, &str, &str)] = &[
("feeds", "image", "TEXT"),
("feeds", "orphaned", "INTEGER NOT NULL DEFAULT 0"),
("feeds", "group_id", "TEXT"),
("feeds", "managed", "INTEGER NOT NULL DEFAULT 0"),
("entries", "image", "TEXT"),
("entries", "duration", "INTEGER"),
("entries", "episode", "INTEGER"),
("entries", "season", "INTEGER"),
// For whoever maintains the server. An audit dropped `created` as unread on 2026-09-12,
// and it came back the same day with `last_login` beside it.
("users", "created", "INTEGER"),
("users", "last_login", "INTEGER"),
("feeds", "error_since", "INTEGER"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
// queries still reading these after they stopped meaning anything.
("entries", "read"),
("entries", "flagged"),
("entries", "position"),
// Written by every insert and read by nothing.
("subscriptions", "created"),
("sessions", "created"),
];
// Read state from before accounts, long since moved to entry_state. Two bugs came from
// queries still reading these after they stopped meaning anything, so they go.
let retired: &[(&str, &str)] = &[("entries", "read"), ("entries", "flagged"), ("entries", "position")];
let has = |table: &str, column: &str| -> Result<bool> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let names = stmt
.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let names: Vec<String> = conn
.prepare(&format!("PRAGMA table_info({table})"))?
.query_map([], |r| r.get(1))?
.collect::<rusqlite::Result<_>>()?;
Ok(names.iter().any(|c| c == column))
};
for (table, column, ty) in wanted {
@@ -187,6 +213,8 @@ pub struct FeedSummary {
pub orphaned: bool,
pub last_checked: Option<i64>,
pub last_error: Option<String>,
/// When this run of failures began; see the `error_since` column.
pub error_since: Option<i64>,
pub entries: i64,
pub downloaded: i64,
}
@@ -228,7 +256,7 @@ impl Db {
let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn
.query_row(
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0)
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since
FROM feeds WHERE id = ?1",
[feed_id],
|r| {
@@ -238,6 +266,7 @@ impl Db {
last_checked: r.get(2)?,
last_error: r.get(3)?,
orphaned: r.get::<_, i64>(4)? != 0,
error_since: r.get(5)?,
..Default::default()
})
},
@@ -312,7 +341,8 @@ impl Db {
last_checked = excluded.last_checked,
ttl_mins = excluded.ttl_mins,
image = coalesce(excluded.image, feeds.image),
last_error = NULL",
last_error = NULL,
error_since = NULL",
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
)?;
Ok(())
@@ -323,7 +353,8 @@ impl Db {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO feeds (id, url, last_checked) VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = NULL",
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked,
last_error = NULL, error_since = NULL",
rusqlite::params![feed_id, url, now()],
)?;
Ok(())
@@ -342,10 +373,15 @@ impl Db {
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
let now = now();
conn.execute(
"INSERT INTO feeds (id, url, last_checked, last_error) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = excluded.last_error",
rusqlite::params![feed_id, url, now(), msg],
"INSERT INTO feeds (id, url, last_checked, last_error, error_since)
VALUES (?1, ?2, ?3, ?4, ?3)
ON CONFLICT(id) DO UPDATE SET
last_checked = excluded.last_checked,
last_error = excluded.last_error,
error_since = coalesce(feeds.error_since, excluded.error_since)",
rusqlite::params![feed_id, url, now, msg],
)?;
Ok(())
}
@@ -649,6 +685,10 @@ pub enum Filter {
Unread,
Downloaded,
Flagged,
/// Started (a saved playback position past the first few seconds) but not finished
/// (`markPlayed` in the UI marks an item read at 90% played, so unread is "not finished"
/// here too). Currently Listening, below Popular, is this filter on every feed at once.
InProgress,
}
impl Filter {
@@ -657,6 +697,7 @@ impl Filter {
"unread" => Self::Unread,
"downloaded" => Self::Downloaded,
"flagged" => Self::Flagged,
"in_progress" => Self::InProgress,
_ => Self::All,
}
}
@@ -672,6 +713,7 @@ impl Filter {
"EXISTS (SELECT 1 FROM enclosures x
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
}
Self::InProgress => "coalesce(s.position, 0) > 5 AND coalesce(s.read, 0) = 0",
}
}
}
@@ -829,15 +871,14 @@ impl Db {
}
for id in catalogue {
conn.execute(
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
params![user_id, id, now()],
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
params![user_id, id],
)?;
}
// Feeds that exist only in the database (OPML children) count too.
conn.execute(
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created)
SELECT ?1, id, ?2 FROM feeds",
params![user_id, now()],
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) SELECT ?1, id FROM feeds",
params![user_id],
)?;
Ok(catalogue.len())
}
@@ -954,8 +995,8 @@ impl Db {
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
params![user_id, feed_id, now()],
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
params![user_id, feed_id],
)?;
Ok(())
}
@@ -969,16 +1010,6 @@ impl Db {
Ok(())
}
/// How many people want this feed. Nobody means it stops being scanned.
pub fn subscriber_count(&self, feed_id: &str) -> Result<i64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(
"SELECT count(*) FROM subscriptions WHERE feed_id = ?1",
[feed_id],
|r| r.get(0),
)?)
}
/// Overwrites one person's settings for a feed. A None field means: follow the feed.
pub fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -989,8 +1020,8 @@ impl Db {
.transpose()?;
conn.execute(
"INSERT INTO subscriptions
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check, created)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(user_id, feed_id) DO UPDATE SET
keywords = excluded.keywords,
auto_download = excluded.auto_download,
@@ -1003,22 +1034,11 @@ impl Db {
sub.auto_download.map(|v| v as i64),
sub.allow_explicit.map(|v| v as i64),
sub.max_new_per_check,
now()
],
)?;
Ok(())
}
/// Feeds with at least one subscriber. What the scanner walks.
pub fn subscribed_feed_ids(&self) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT DISTINCT feed_id FROM subscriptions")?;
let out = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(out)
}
// ---- users and sessions ----
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
@@ -1031,11 +1051,11 @@ impl Db {
}
pub fn user_by_name(&self, name: &str) -> Result<Option<User>> {
self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE name = ?1", name)
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE name = ?1"), name)
}
pub fn user_by_id(&self, id: i64) -> Result<Option<User>> {
self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE id = ?1", id)
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), id)
}
fn one_user<P: rusqlite::ToSql>(&self, sql: &str, key: P) -> Result<Option<User>> {
@@ -1043,12 +1063,7 @@ impl Db {
let mut stmt = conn.prepare(sql)?;
let mut rows = stmt.query(params![key])?;
Ok(match rows.next()? {
Some(r) => Some(User {
id: r.get(0)?,
name: r.get(1)?,
pass_hash: r.get(2)?,
is_admin: r.get::<_, i64>(3)? != 0,
}),
Some(r) => Some(user_row(r)?),
None => None,
})
}
@@ -1056,16 +1071,9 @@ impl Db {
pub fn users(&self) -> Result<Vec<User>> {
let conn = self.conn.lock().unwrap();
let mut stmt =
conn.prepare("SELECT id, name, pass_hash, is_admin FROM users ORDER BY name")?;
conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY name"))?;
let out = stmt
.query_map([], |r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
pass_hash: r.get(2)?,
is_admin: r.get::<_, i64>(3)? != 0,
})
})?
.query_map([], user_row)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(out)
}
@@ -1082,6 +1090,26 @@ impl Db {
Ok(())
}
/// The proxy signs people in by the name it vouches for, so an account made before the proxy
/// was set up has to take that name to be found by it. The name is UNIQUE, so a taken one is
/// refused here as well as by the caller.
pub fn rename_user(&self, id: i64, name: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("UPDATE users SET name = ?2 WHERE id = ?1", params![id, name])?;
Ok(())
}
/// Records a sign-in, to the hour: the proxy vouches for every request, and writing each one
/// would buy nothing.
pub fn signed_in(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE users SET last_login = ?2 WHERE id = ?1 AND coalesce(last_login, 0) <= ?2 - 3600",
params![id, now()],
)?;
Ok(())
}
/// Sessions go with the user: a deleted account must not leave a usable cookie behind.
pub fn delete_user(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -1093,7 +1121,7 @@ impl Db {
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sessions (token, user_id, created, seen) VALUES (?1, ?2, ?3, ?3)",
"INSERT INTO sessions (token, user_id, seen) VALUES (?1, ?2, ?3)",
params![token, user_id, now()],
)?;
Ok(())
@@ -1105,18 +1133,13 @@ impl Db {
let conn = self.conn.lock().unwrap();
let cutoff = now() - max_idle_secs;
let mut stmt = conn.prepare(
"SELECT u.id, u.name, u.pass_hash, u.is_admin
"SELECT u.id, u.name, u.pass_hash, u.is_admin, u.created, u.last_login
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?1 AND s.seen >= ?2",
)?;
let mut rows = stmt.query(params![token, cutoff])?;
let found = match rows.next()? {
Some(r) => Some(User {
id: r.get(0)?,
name: r.get(1)?,
pass_hash: r.get(2)?,
is_admin: r.get::<_, i64>(3)? != 0,
}),
Some(r) => Some(user_row(r)?),
None => None,
};
drop(rows);
@@ -1195,7 +1218,7 @@ impl Db {
.optional()?)
}
/// Read and starred, per person. The row is created on first touch.
/// Read and kept, per person. The row is created on first touch.
pub fn set_entry_flag(
&self,
user_id: i64,
@@ -1270,7 +1293,7 @@ impl Db {
pub fn managed_feeds(&self) -> Result<Vec<Managed>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, url, title, group_id, orphaned FROM feeds
"SELECT id, url, title, group_id FROM feeds
WHERE managed = 1 AND group_id IS NOT NULL ORDER BY coalesce(title, id)",
)?;
Ok(stmt
@@ -1280,7 +1303,6 @@ impl Db {
url: r.get(1)?,
title: r.get(2)?,
group_id: r.get(3)?,
orphaned: r.get::<_, i64>(4)? != 0,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?)
@@ -1423,8 +1445,8 @@ mod tests {
fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(1,'g',0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g');
INSERT INTO feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark');
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300);
@@ -1457,8 +1479,8 @@ mod tests {
fn deleting_a_shared_file_asks_about_everyone_else() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0),(3,'kit',0,0);
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0),(3,'f',0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'),(3,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','u1','/tmp/a','done');",
@@ -1483,7 +1505,7 @@ mod tests {
fn read_state_belongs_to_one_person() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
('f','a','One',100),('f','b','Two',200);",
)
@@ -1526,27 +1548,89 @@ mod tests {
}
#[test]
fn an_old_database_loses_the_retired_read_columns() {
fn an_old_database_loses_its_retired_columns() {
let conn = Connection::open_in_memory().unwrap();
// As open() has it: a DROP COLUMN on a table that references another is the part worth
// proving, and it has to work with the foreign keys switched on.
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
conn.execute_batch(
"CREATE TABLE entries (feed_id TEXT NOT NULL, guid TEXT NOT NULL,
first_seen INTEGER NOT NULL, read INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (feed_id, guid));",
PRIMARY KEY (feed_id, guid));
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
pass_hash TEXT, is_admin INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL);
CREATE TABLE subscriptions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
feed_id TEXT NOT NULL, created INTEGER NOT NULL, PRIMARY KEY (user_id, feed_id));
CREATE TABLE sessions (token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created INTEGER NOT NULL, seen INTEGER NOT NULL);
INSERT INTO users VALUES (1, 'ray', NULL, 1, 0);
INSERT INTO subscriptions VALUES (1, 'f', 0);
INSERT INTO sessions VALUES ('t', 1, 0, 0);",
)
.unwrap();
// The same order as open(): the schema leaves the old table alone, migrate() fixes it.
// The same order as open(): the schema leaves the old tables alone, migrate() fixes them.
conn.execute_batch(SCHEMA).unwrap();
migrate(&conn).unwrap();
let cols: Vec<String> = conn
.prepare("PRAGMA table_info(entries)")
.unwrap()
.query_map([], |r| r.get(1))
.unwrap()
.collect::<rusqlite::Result<_>>()
let cols = |table: &str| -> Vec<String> {
conn.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |r| r.get(1))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
};
for (table, gone) in [
("entries", &["read", "flagged", "position"][..]),
("subscriptions", &["created"][..]),
("sessions", &["created"][..]),
] {
let cols = cols(table);
assert!(!cols.iter().any(|c| gone.contains(&c.as_str())), "{table}: {cols:?}");
}
// users.created is not retired: it keeps what it held, and last_login joins it.
let users = cols("users");
assert!(users.iter().any(|c| c == "last_login"), "{users:?}");
assert_eq!(conn.query_row("SELECT created FROM users", [], |r| r.get::<_, i64>(0)).unwrap(), 0);
// And the rows come through it.
let kept: i64 = conn
.query_row("SELECT count(*) FROM subscriptions JOIN sessions USING (user_id)", [], |r| r.get(0))
.unwrap();
assert!(!cols.iter().any(|c| ["read", "flagged", "position"].contains(&c.as_str())), "{cols:?}");
assert!(cols.iter().any(|c| c == "image"), "and it still gains the newer ones");
assert_eq!(kept, 1);
}
#[test]
fn a_renamed_account_keeps_everything_but_its_name() {
let db = Db::memory().unwrap();
let ray = db.create_user("rays", None, true).unwrap();
db.create_user("sam", None, false).unwrap();
db.subscribe(ray, "f").unwrap();
db.rename_user(ray, "rays@sdf1.net").unwrap();
assert!(db.user_by_name("rays").unwrap().is_none());
let renamed = db.user_by_name("RAYS@sdf1.net").unwrap().unwrap();
assert_eq!((renamed.id, renamed.is_admin), (ray, true), "same account, still the admin");
assert_eq!(db.subscriptions_for(ray).unwrap().len(), 1, "and still subscribed");
assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused");
}
#[test]
fn an_account_knows_when_it_was_made_and_last_signed_in() {
let db = Db::memory().unwrap();
let id = db.create_user("ray", None, true).unwrap();
let get = || db.user_by_id(id).unwrap().unwrap();
assert!(get().created.is_some_and(|t| t > 0));
assert_eq!(get().last_login, None, "made, but never signed in");
db.signed_in(id).unwrap();
let first = get().last_login.unwrap();
// Within the hour, the proxy vouching again writes nothing; after it, it does.
db.exec_for_test(&format!("UPDATE users SET last_login = {} WHERE id = {id}", first - 60)).unwrap();
db.signed_in(id).unwrap();
assert_eq!(get().last_login, Some(first - 60));
db.exec_for_test(&format!("UPDATE users SET last_login = {} WHERE id = {id}", first - 7200)).unwrap();
db.signed_in(id).unwrap();
assert!(get().last_login.unwrap() >= first);
}
#[test]
@@ -1554,7 +1638,7 @@ mod tests {
// Cutting this along with the dead read columns left the browser suite's admin with an
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
let db = Db::memory().unwrap();
db.exec_for_test("INSERT INTO users (id, name, is_admin, created) VALUES (1,'admin',1,0);")
db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',1);")
.unwrap();
let subs = || -> i64 {
db.conn.lock().unwrap().query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0)).unwrap()
@@ -1578,18 +1662,27 @@ mod tests {
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','a','Alpha dive','notes one',100),
('f','b','Beta', 'notes two',200),
('f','c','Gamma dive','notes three',300);
('f','c','Gamma dive','notes three',300),
('f','d','Delta', 'notes four',400),
('f','e','Epsilon', 'notes five',500),
('f','g','Gimel', 'notes six',600);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','b','u1','/tmp/b','done');
-- Read and starred belong to a person now, so say which one.
INSERT INTO users (id, name, is_admin, created) VALUES (7,'reader',1,0);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(7,'f','b',1,0),
(7,'f','c',1,1);",
INSERT INTO users (id, name, is_admin) VALUES (7,'reader',1);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
(7,'f','b',1,0,0),
(7,'f','c',1,1,0),
-- Started and not finished: this is Currently Listening.
(7,'f','d',0,0,42),
-- Already finished: not Currently Listening, however far it got.
(7,'f','e',1,0,42),
-- Barely touched (opened, closed within seconds): not Currently Listening.
(7,'f','g',0,0,3);",
)
.unwrap();
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] {
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
// Both paths must run without erroring, and agree with each other.
let order = order_sql("published", "desc");
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap();
@@ -1601,13 +1694,17 @@ mod tests {
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
}
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 3);
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 6);
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 3);
assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("dive")).unwrap(), 2);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
"search is case-insensitive and covers the description");
// Currently Listening: started, not finished, and not just an accidental tap.
let listening = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order_sql("published", "desc")).unwrap();
assert_eq!(listening.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["d"]);
}
#[test]
@@ -1652,7 +1749,7 @@ mod tests {
fn a_show_takes_over_what_its_creator_held() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
(1,'creator','a','u1','done','/x/a.mp3',NULL),
(2,'creator','b','u2','skipped',NULL,'explicit'),
@@ -1688,9 +1785,9 @@ mod tests {
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
INSERT INTO subscriptions (user_id, feed_id, allow_explicit, created) VALUES
(1,'group',1,0),(1,'show',NULL,0),(2,'group',1,0),(2,'show',0,0);",
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES
(1,'group',1),(1,'show',NULL),(2,'group',1),(2,'show',0);",
)
.unwrap();
let explicit = |group| -> Vec<Option<bool>> {
@@ -1703,6 +1800,26 @@ mod tests {
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited");
}
#[test]
fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
let db = Db::memory().unwrap();
db.set_feed_error("f", "http://x", "HTTP 404").unwrap();
// Backdate it, as if this feed had already been failing a while, so a second
// failure landing "now" is distinguishable from the first.
db.exec_for_test("UPDATE feeds SET error_since = error_since - 3600 WHERE id = 'f'").unwrap();
let first = db.feed_summary("f").unwrap().error_since.unwrap();
// macmanx: failed once, read fine an hour later. A second failure must not push
// error_since forward -- the UI decides "failing for a day" from the first one.
db.set_feed_error("f", "http://x", "HTTP 404").unwrap();
assert_eq!(db.feed_summary("f").unwrap().error_since, Some(first));
db.touch_feed("f", "http://x").unwrap();
let after = db.feed_summary("f").unwrap();
assert_eq!(after.last_error, None);
assert_eq!(after.error_since, None, "a clean check ends the run of failures");
}
#[test]
fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap();

View File

@@ -87,6 +87,50 @@ pub async fn fetch(
Ok(Fetched::Body { bytes, etag, last_modified })
}
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
/// it is, and whether there is a new address to switch to.
pub struct Failure {
pub reason: &'static str,
pub new_url: Option<String>,
}
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
/// comes back `None`: transient by nature, or with nothing more useful to say than the raw
/// text already shown once a feed is open.
///
/// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP
/// {status}")`, and the "got a web page" message above) plus the substrings a DNS failure
/// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing
/// nothing extra, so a miss costs a clearer message, not a wrong one.
pub fn explain_failure(msg: &str) -> Option<Failure> {
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
let new_url = rest
.strip_prefix("; it links ")
.and_then(|r| r.strip_suffix(" as its feed"))
.map(str::to_owned);
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
}
let low = msg.to_ascii_lowercase();
if low.contains("http 404") {
return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None });
}
if low.contains("http 401") || low.contains("http 403") {
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
}
if low.contains("http 402") {
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
}
if low.contains("dns error")
|| low.contains("failed to lookup address")
|| low.contains("no address associated")
{
return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None });
}
None
}
/// True when a body is an OPML document rather than a feed.
///
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
@@ -220,11 +264,110 @@ pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
Ok(ch) => Ok(from_rss(ch, bytes)),
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
Ok(feed) => Ok(from_atom(feed)),
Err(atom_err) => Err(anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})")),
Err(atom_err) => {
// Some publishers (kcpw, feedland) write a bare "&" in a URL instead of
// "&amp;". Strict XML parsers refuse it; browsers don't. Retry once with
// every offending "&" escaped rather than fail outright.
let escaped = escape_bare_ampersands(bytes);
if escaped != bytes {
if let Ok(ch) = rss::Channel::read_from(escaped.as_slice()) {
return Ok(from_rss(ch, &escaped));
}
if let Ok(feed) = atom_syndication::Feed::read_from(escaped.as_slice()) {
return Ok(from_atom(feed));
}
}
Err(match alternate_feed_link(bytes) {
Some(href) if looks_like_html(bytes) => {
anyhow!("got a web page, not a feed; it links {href} as its feed")
}
None if looks_like_html(bytes) => anyhow!("got a web page, not a feed"),
_ => anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})"),
})
}
},
}
}
/// Whether a body is a web page rather than a feed: most of the errors traced back to a feed
/// that moved or a domain that lapsed, with the old URL now serving the site instead (or a
/// redirect to it). `is_opml` already sniffs the other "not actually a feed" case.
fn looks_like_html(bytes: &[u8]) -> bool {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]).to_lowercase();
head.contains("<!doctype html") || head.contains("<html")
}
/// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml"
/// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now,
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let lower = text.to_lowercase();
let mut pos = 0;
while let Some(rel) = lower[pos..].find("<link") {
let start = pos + rel;
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
pos = end + 1;
let tag = &text[start..end];
let tag_lower = &lower[start..end];
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
if is_alternate && is_feed_type
&& let Some(href) = tag_attr(tag, "href")
{
return Some(href);
}
}
None
}
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
fn tag_attr(tag: &str, name: &str) -> Option<String> {
let key = format!("{name}=");
let idx = tag.to_lowercase().find(&key)?;
let after = &tag[idx + key.len()..];
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = &after[1..];
let close = rest.find(quote)?;
Some(rest[..close].trim().to_owned())
}
/// Escapes every `&` that does not already start a recognized XML entity
/// (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, or a numeric reference like `&#39;`).
fn escape_bare_ampersands(bytes: &[u8]) -> Vec<u8> {
fn is_entity_start(rest: &[u8]) -> bool {
for named in [&b"amp;"[..], b"lt;", b"gt;", b"quot;", b"apos;"] {
if rest.starts_with(named) {
return true;
}
}
let digits = if rest.starts_with(b"#x") || rest.starts_with(b"#X") {
&rest[2..]
} else if rest.starts_with(b"#") {
&rest[1..]
} else {
return false;
};
let len = digits.iter().take_while(|b| b.is_ascii_alphanumeric()).count();
len > 0 && digits.get(len) == Some(&b';')
}
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'&' && !is_entity_start(&bytes[i + 1..]) {
out.extend_from_slice(b"&amp;");
} else {
out.push(bytes[i]);
}
i += 1;
}
out
}
/// Every `<enclosure>` of every `<item>`, in document order.
///
/// The `rss` crate models an item as having at most one enclosure -- which is what RSS 2.0
@@ -340,7 +483,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
link: non_empty(item.link()),
published: item.pub_date().and_then(parse_date),
// Content wins over description, as __getEntries preferred entry.content.
description: non_empty(item.content()).or_else(|| non_empty(item.description())),
description: body(item.content(), item.description()),
categories: item
.categories()
.iter()
@@ -406,11 +549,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
title: non_empty(Some(e.title().as_str())),
link: alt.map(str::to_owned),
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
description: e
.content()
.and_then(|c| c.value())
.or_else(|| e.summary().map(|s| s.as_str()))
.map(str::to_owned),
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
explicit: false,
image: None,
@@ -455,6 +594,25 @@ fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
}
/// An item's show notes: its full body when that is whole, else its description.
///
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
/// pasted from a web app (`[&:has([data-writing-block])>*]:pointer-events-auto`), so the body
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
/// `description` was whole. With no description to fall back on, a damaged body beats none.
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
non_empty(content)
.filter(|c| !starts_mid_tag(c))
.or_else(|| non_empty(description))
.or_else(|| non_empty(content))
}
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
/// start was cut off.
fn starts_mid_tag(html: &str) -> bool {
html[..html.find('<').unwrap_or(html.len())].contains("\">")
}
/// The picture to show beside an item, in order of how deliberate it is:
/// `itunes:image`, then Media RSS `media:thumbnail`, then a `media:content` that is an
/// image, and finally an image enclosure -- which is how a blog's article picture arrives
@@ -548,6 +706,18 @@ mod tests {
);
}
#[test]
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
}
#[test]
fn feed_level_explicit_overrides_entries() {
let xml = br#"<?xml version="1.0"?>
@@ -564,6 +734,46 @@ mod tests {
);
}
#[test]
fn explain_failure_translates_the_errors_the_ui_should_flag() {
assert_eq!(
explain_failure("HTTP 404 Not Found").unwrap().reason,
"The publisher took this feed down, or moved it."
);
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap();
assert_eq!(dns.reason, "This address no longer resolves; the site is gone.");
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
for transient in ["HTTP 500 Internal Server Error", "HTTP 429 Too Many Requests", "operation timed out"] {
assert!(explain_failure(transient).is_none(), "{transient} must not be flagged");
}
}
#[test]
fn a_web_page_says_so_and_names_the_feed_it_links() {
let html = br#"<!doctype html><html><head>
<link rel="alternate" type="application/rss+xml" href="https://x.example/feed">
</head><body>not a feed</body></html>"#;
let err = parse(html).unwrap_err().to_string();
assert_eq!(err, "got a web page, not a feed; it links https://x.example/feed as its feed");
}
#[test]
fn a_web_page_with_no_feed_link_still_says_so() {
let html = b"<!doctype html><html><body>moved</body></html>";
assert_eq!(parse(html).unwrap_err().to_string(), "got a web page, not a feed");
}
#[test]
fn garbage_that_is_not_html_gets_the_original_parser_errors() {
let err = parse(b"not xml at all").unwrap_err().to_string();
assert!(err.starts_with("not RSS ("), "{err}");
}
#[test]
fn parses_atom_enclosure_links() {
let bytes = include_bytes!("../tests/data/atom.xml");
@@ -586,6 +796,27 @@ mod tests {
);
}
#[test]
fn a_bare_ampersand_in_a_link_is_repaired_and_parsed() {
// kcpw.org: <link>https://kcpw.org/?post_type=post&p=125715</link> -- a bare "&"
// that strict XML rejects but browsers accept.
let xml = br#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>X</title><link>https://x</link><description>d</description>
<item><title>a</title><guid>g1</guid>
<link>https://kcpw.org/?post_type=post&p=125715</link>
<enclosure url="https://x/a.mp3?a=1&b=2" length="1" type="audio/mpeg"/></item>
</channel></rss>"#;
let feed = parse(xml).unwrap();
assert_eq!(feed.entries[0].link.as_deref(), Some("https://kcpw.org/?post_type=post&p=125715"));
assert_eq!(feed.entries[0].enclosures[0].url, "https://x/a.mp3?a=1&b=2");
}
#[test]
fn escape_bare_ampersands_leaves_real_entities_alone() {
let out = escape_bare_ampersands(b"a&amp;b &lt;x&gt; &#39; &#x2F; c&d");
assert_eq!(out, b"a&amp;b &lt;x&gt; &#39; &#x2F; c&amp;d");
}
#[test]
fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() {
// Some feeds set a different itunes:title. The displayed title is always the RSS

View File

@@ -172,11 +172,18 @@ pub async fn daemon_is_live(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok()
}
/// Answers `status` for the socket, without the worker. The worker runs one job at a time, and a
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
pub async fn serve(
path: PathBuf,
events: broadcast::Sender<Event>,
cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> {
// A socket file left by a crashed daemon would block the bind; a live one was already
// rejected by the caller's daemon_is_live() check.
@@ -195,8 +202,9 @@ pub async fn serve(
let (stream, _) = listener.accept().await?;
let rx = events.subscribe();
let cmds = cmds.clone();
let status = status.clone();
tokio::spawn(async move {
if let Err(e) = handle(stream, rx, cmds).await {
if let Err(e) = handle(stream, rx, cmds, status).await {
tracing::debug!(error = %e, "client gone");
}
});
@@ -207,12 +215,21 @@ async fn handle(
stream: UnixStream,
mut rx: broadcast::Receiver<Event>,
cmds: mpsc::Sender<Command>,
status: StatusFn,
) -> Result<()> {
let (read, mut write) = stream.into_split();
// Events out.
// Events out: everything broadcast, and the answers meant for this client alone.
let (reply, mut replies) = mpsc::channel::<Event>(4);
let writer = tokio::spawn(async move {
while let Ok(ev) = rx.recv().await {
loop {
let ev = tokio::select! {
Some(ev) = replies.recv() => ev,
got = rx.recv() => match got {
Ok(ev) => ev,
Err(_) => break,
},
};
let mut line = serde_json::to_string(&ev).unwrap_or_default();
line.push('\n');
if write.write_all(line.as_bytes()).await.is_err() {
@@ -229,6 +246,15 @@ async fn handle(
continue;
}
match serde_json::from_str::<Command>(line) {
// Answered here, not queued behind whatever the worker is on: see StatusFn.
Ok(Command::Status) => {
tracing::info!(target: "ipx::io", "-> {line}");
let ev = status();
if let Ok(json) = serde_json::to_string(&ev) {
tracing::info!(target: "ipx::io", "<- {json}");
}
let _ = reply.send(ev).await;
}
Ok(cmd) => {
if cmds.send(cmd).await.is_err() {
break; // Worker is gone; so are we.
@@ -329,4 +355,29 @@ mod tests {
}
.is_terminal());
}
#[tokio::test]
async fn status_is_answered_while_the_worker_is_busy() {
// The queue is full and nobody drains it, as when the worker is deep in a long download:
// anything sent to it would wait for ever.
let (cmds, _worker) = mpsc::channel::<Command>(1);
cmds.send(Command::Reap { dry_run: true }).await.unwrap();
let (events, _) = broadcast::channel::<Event>(8);
// Another client, watching a scan: it must not be handed someone else's answer, which
// would end its session.
let mut watcher = events.subscribe();
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
let (client, server) = UnixStream::pair().unwrap();
tokio::spawn(handle(server, events.subscribe(), cmds, status));
let (read, mut write) = client.into_split();
write.write_all(b"{\"cmd\":\"status\"}\n").await.unwrap();
let line = tokio::time::timeout(std::time::Duration::from_secs(2), BufReader::new(read).lines().next_line())
.await
.expect("status waited behind the worker")
.unwrap()
.unwrap();
assert!(line.contains(r#""ev":"status""#), "{line}");
assert!(watcher.try_recv().is_err(), "the answer went to every client, not just the one asking");
}
}

View File

@@ -99,6 +99,9 @@ enum UserCmd {
Passwd { name: String },
/// Delete an account and everything it knows: its subscriptions and read state
Rm { name: String },
/// Rename an account, keeping its feeds, read state and admin rights. This is how an
/// account made before the proxy takes the name the proxy signs it in as
Rename { name: String, new_name: String },
}
/// What a brand new database starts with, so there is always a way in. Announced loudly
@@ -273,11 +276,16 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
println!("no accounts yet: ipx user add <name>");
}
for u in users {
let added = u
.created
.and_then(|t| chrono::DateTime::from_timestamp(t, 0))
.map_or("?".into(), |d| d.format("%Y-%m-%d").to_string());
let seen = u.last_login.map_or("never signed in".into(), |t| format!("signed in {}", ago(Some(t))));
println!(
"{:<20} {:<8} {}",
"{:<20} {:<6} {:<11} added {added} {seen}",
u.name,
if u.is_admin { "admin" } else { "" },
if u.pass_hash.is_some() { "password" } else { "proxy only" }
if u.pass_hash.is_some() { "password" } else { "proxy only" },
);
}
Ok(())
@@ -292,6 +300,22 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
println!("password changed for {name}");
Ok(())
}
UserCmd::Rename { name, new_name } => {
let name = name.trim().to_ascii_lowercase();
// The same rules as a name the proxy vouches for, or the proxy would never find it.
let new_name = crate::auth::name_from_header(&new_name)
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
let user = ctx
.db
.user_by_name(&name)?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
if ctx.db.user_by_name(&new_name)?.is_some() {
anyhow::bail!("{new_name} already exists");
}
ctx.db.rename_user(user.id, &new_name)?;
println!("renamed {name} to {new_name}");
Ok(())
}
UserCmd::Rm { name } => {
let name = name.trim().to_ascii_lowercase();
let user = ctx
@@ -315,14 +339,24 @@ async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
Cmd::Reap { dry_run } => reap(ctx, dry_run, true),
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
Cmd::Status => {
let (pending, downloaded) = ctx.db.counts()?;
let feeds = subscriptions(ctx).map(|s| s.len()).unwrap_or(0);
ctx.out.emit(Event::Status { feeds, pending, downloaded });
ctx.out.emit(status(ctx));
Ok(())
}
}
}
/// The counts `ipx status` prints. A running daemon's socket answers with this directly rather
/// than through the job queue.
fn status(ctx: &Ctx) -> Event {
match ctx.db.counts() {
Ok((pending, downloaded)) => {
let feeds = subscriptions(ctx).map(|s| s.len()).unwrap_or(0);
Event::Status { feeds, pending, downloaded }
}
Err(e) => Event::Error { msg: format!("{e:#}") },
}
}
async fn daemon(
ctx: Arc<Ctx>,
config_path: PathBuf,
@@ -361,7 +395,12 @@ async fn daemon(
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd));
// status is answered by the socket itself; everything else waits its turn in the queue.
let answer: ipc::StatusFn = {
let ctx = ctx.clone();
Arc::new(move || status(&ctx))
};
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
// One command at a time: the queue is what keeps two scans from overlapping.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
@@ -598,6 +637,7 @@ fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
cfg.save(config_path)?;
// State and files stay: re-adding the feed should not re-download its back catalogue.
println!("removed {feed}; downloads and history kept");
retire_group(ctx, feed)?;
Ok(())
}
@@ -803,6 +843,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
feed: id.clone(),
reason: "not modified".into(),
}),
Ok(Outcome::Empty) => ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
reason: "nothing yet".into(),
}),
Ok(Outcome::Opml { added, removed, kept, total }) => {
ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
@@ -880,6 +924,12 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
continue; // promoted to config at some point; that entry wins
}
let parent = cfg.feeds.get(&m.group_id);
if parent.is_none() {
// The OPML or Patreon feed this was derived from is no longer in config --
// removing it should have retired these rows too (see `retire_group`), but
// skip them here regardless so a row that slips through is never scanned.
continue;
}
let base = parent
.and_then(|p| p.folder.clone())
.or_else(|| ctx.db.feed_summary(&m.group_id).ok().and_then(|s| s.title))
@@ -908,6 +958,22 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
Ok(out)
}
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
/// itself is removed, since `subscriptions()` would otherwise keep scanning them under a
/// fallback policy meant for a feed with no parent at all.
pub fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
for m in ctx.db.managed_feeds()?.into_iter().filter(|m| m.group_id == parent_id) {
if ctx.db.downloaded_count(&m.id).unwrap_or(1) > 0 {
ctx.db.set_orphaned(&m.id, true)?;
} else {
ctx.db.drop_managed(&m.id)?;
}
}
Ok(())
}
/// Seconds to wait before re-checking a feed.
///
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
@@ -932,6 +998,9 @@ struct Scan {
/// What a scan of one feed turned out to be.
enum Outcome {
NotModified,
/// A response with nothing in it -- the British Antarctic Survey answers a 202 with an
/// empty body when it has nothing new to publish. Not a parse failure; try again later.
Empty,
Feed(Scan),
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows.
Opml { added: Vec<String>, removed: usize, kept: usize, total: usize },
@@ -994,6 +1063,11 @@ async fn scan_one(
feed::Fetched::Body { bytes, etag, last_modified } => (bytes, etag, last_modified),
};
if bytes.iter().all(u8::is_ascii_whitespace) {
ctx.db.touch_feed(id, &feed_cfg.url)?;
return Ok(Outcome::Empty);
}
// A subscribed OPML is a list of feeds, not a feed. The original matched on a ".opml"
// URL; sniffing the body also catches one served from a URL without that extension.
if feed::is_opml(&bytes) {
@@ -1596,4 +1670,46 @@ mod tests {
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(true), None)], &feed(), 3);
assert!(p.auto_download);
}
fn test_ctx(cfg: config::Config) -> Ctx {
Ctx {
cfg: std::sync::RwLock::new(Arc::new(cfg)),
db: db::Db::memory().unwrap(),
client: reqwest::Client::new(),
out: Emitter::terminal(),
torrents: tokio::sync::OnceCell::new(),
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
config_path: PathBuf::new(),
detach_torrents: false,
}
}
#[test]
fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() {
// davewiner: the OPML subscription left config.toml, but its 922 derived rows
// stayed in the database and kept being scanned under the no-parent fallback.
let ctx = test_ctx(config::Config::default());
ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").unwrap();
assert!(
subscriptions(&ctx).unwrap().iter().all(|s| s.id != "child"),
"a derived feed whose parent is gone from config must not be scanned"
);
}
#[test]
fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
let ctx = test_ctx(config::Config::default());
ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").unwrap();
ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").unwrap();
let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None };
ctx.db.record_enclosure("has-file", "g1", &enc).unwrap();
ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).unwrap();
retire_group(&ctx, "parent").unwrap();
let managed = ctx.db.managed_feeds().unwrap();
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
assert!(managed.iter().any(|m| m.id == "has-file"), "has a file on disk, so it is kept");
assert!(ctx.db.feed_summary("has-file").unwrap().orphaned, "and flagged as orphaned");
}
}

View File

@@ -154,8 +154,8 @@ mod tests {
// One file serves both subscribers, so it takes both of them to release it.
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'keep', 0),
('f', 'half', 0),
@@ -189,7 +189,7 @@ mod tests {
fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100),

View File

@@ -91,23 +91,8 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let cfg = state.ctx.cfg();
let token = cfg.web.token.clone();
let peer = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default();
// 1. A header, but only from a hop we were told to believe. Anyone able to reach the
// port could otherwise send it and be whoever they liked.
let vouched = (!cfg.web.trusted_header.is_empty()
&& cfg.web.trusted_proxies.iter().any(|p| p == &peer))
.then(|| {
req.headers()
.get(&cfg.web.trusted_header)
.and_then(|v| v.to_str().ok())
.and_then(crate::auth::name_from_header)
})
.flatten();
// 1. A header, but only from a hop we were told to believe.
let vouched = vouched_name(&cfg, &req);
let mut set_cookie: Option<String> = None;
let mut user = None;
@@ -133,7 +118,13 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
None
}
};
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
// must not turn anyone away, so its error goes unanswered.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id);
}
}
let by_proxy = user.is_some();
// 2. A session cookie from signing in here.
if user.is_none() {
@@ -157,6 +148,10 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
user = admin_user(&state);
if from_query.is_some() {
// The token link is a sign-in; the cookie it leaves behind is not one each time.
if let Some(u) = &user {
let _ = state.ctx.db.signed_in(u.id);
}
set_cookie = Some(format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
));
@@ -180,6 +175,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
};
req.extensions_mut().insert(user);
req.extensions_mut().insert(Proxied(by_proxy));
let mut resp = next.run(req).await;
if let Some(c) = set_cookie {
if let Ok(v) = header::HeaderValue::from_str(&c) {
@@ -191,6 +187,29 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
const SESSION_COOKIE: &str = "ipx_session";
/// Whether the proxy signed this request in, rather than a session or the token: signing out
/// has to go through the proxy then, or its next request signs the person straight back in.
#[derive(Clone, Copy)]
struct Proxied(bool);
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
/// be whoever they liked.
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
let peer = req
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default();
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return None;
}
req.headers()
.get(&cfg.web.trusted_header)
.and_then(|v| v.to_str().ok())
.and_then(crate::auth::name_from_header)
}
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
/// reaches a handler without passing through it.
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
@@ -253,6 +272,7 @@ async fn login(
let user = user.expect("verified above");
let token = crate::auth::new_session_token();
state.ctx.db.create_session(user.id, &token)?;
state.ctx.db.signed_in(user.id)?;
tracing::info!(user = %user.name, "signed in");
let days = state.ctx.cfg().web.session_days.max(1);
@@ -283,8 +303,15 @@ async fn logout(State(state): State<WebState>, req: Request) -> Response {
resp
}
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
/// Who is signed in, and, for someone the proxy signed in, where Sign out should send them.
async fn me(
State(state): State<WebState>,
user: crate::db::User,
axum::Extension(Proxied(by_proxy)): axum::Extension<Proxied>,
) -> Json<serde_json::Value> {
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
}
// ---- accounts: admin only ----
@@ -317,6 +344,7 @@ async fn list_users(
.map(|u| {
serde_json::json!({
"id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(),
"created": u.created, "last_login": u.last_login,
})
})
.collect();
@@ -406,8 +434,13 @@ async fn remove_user(
Ok(StatusCode::NO_CONTENT)
}
async fn login_page() -> Html<&'static str> {
Html(include_str!("../web/login.html"))
/// The password form, except for someone the proxy vouches for: they are signed in already, and
/// the form only made it look as if they were not.
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state.ctx.cfg(), &req).is_some() {
return Redirect::to("/").into_response();
}
Html(include_str!("../web/login.html")).into_response()
}
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
@@ -455,6 +488,9 @@ struct FeedRow {
last_checked: Option<i64>,
next_check: Option<i64>,
last_error: Option<String>,
/// Set once `last_error` is a kind worth telling someone about and it has held for a
/// day -- a feed that fails once and reads fine an hour later (macmanx) never gets here.
failing: Option<FailingRow>,
entries: i64,
downloaded: i64,
unread: i64,
@@ -462,6 +498,15 @@ struct FeedRow {
subscribers: i64,
}
#[derive(Serialize)]
struct FailingRow {
reason: &'static str,
new_url: Option<String>,
}
/// A day, in seconds: how long an error has to hold before the UI mentions it.
const FLAG_AFTER_SECS: i64 = 86_400;
async fn feeds(
State(state): State<WebState>,
user: crate::db::User,
@@ -525,6 +570,12 @@ async fn feeds(
next_check: s
.last_checked
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64),
failing: s
.error_since
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
.and_then(|_| s.last_error.as_deref())
.and_then(crate::feed::explain_failure)
.map(|f| FailingRow { reason: f.reason, new_url: f.new_url }),
last_error: s.last_error,
entries: s.entries,
downloaded: s.downloaded,
@@ -580,24 +631,33 @@ struct PopularRow {
/// Every feed that may be listed, with everyone counted, you included, most subscribers
/// first. Popular is the top of it, the directory is all of it, and it is all that
/// `subscribe_popular` will subscribe you to.
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
/// feeds inside it and never itself: both lists are for finding a show.
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db;
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?;
let catalogue = crate::subscriptions(&state.ctx)?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_folder: std::collections::HashSet<&str> =
catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
let mut out = vec![];
for s in crate::subscriptions(&state.ctx)? {
for s in &catalogue {
let n = counts.get(&s.id).copied().unwrap_or(0);
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed
// inside, which would bury everything anyone chose on purpose.
let from_opml = s.managed || s.cfg.group.is_some();
if n == 0 || from_opml || looks_private(&s.cfg) {
// A feed inside an OPML that looks private is as private as the OPML.
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
if n == 0
|| is_folder.contains(s.id.as_str())
|| looks_private(&s.cfg)
|| folder.is_some_and(|f| looks_private(f))
{
continue;
}
let sum = db.feed_summary(&s.id)?;
let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed });
out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed });
}
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
Ok(out)
@@ -612,7 +672,7 @@ async fn get_popular(
Ok(Json(rows))
}
/// Every feed that may be listed, A to Z.
/// Every feed that may be listed, A to Z, with the feeds inside an OPML in place of the OPML.
async fn get_directory(
State(state): State<WebState>,
user: crate::db::User,
@@ -634,7 +694,7 @@ async fn subscribe_popular(
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
return Err(ApiError::bad_request(format!("{id:?} is not on the popular list")));
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
}
state.ctx.db.subscribe(user.id, &id)?;
Ok(Json(serde_json::json!({ "id": id })))
@@ -739,7 +799,14 @@ mod tests {
#[test]
fn only_the_last_admin_is_protected() {
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };
let u = |id, is_admin| crate::db::User {
id,
name: format!("u{id}"),
pass_hash: None,
is_admin,
created: None,
last_login: None,
};
assert!(last_admin(&[u(1, true), u(2, false)], 1));
assert!(!last_admin(&[u(1, true), u(2, true)], 1), "another admin remains");
assert!(!last_admin(&[u(1, true), u(2, false)], 2), "not an admin at all");
@@ -868,9 +935,13 @@ fn entry_page(
let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
// keeps that safe -- so following one in show notes never navigates away from ipx.
let mut sanitizer = ammonia::Builder::new();
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d));
row.description = Some(sanitizer.clean(d).to_string());
}
}
let total = db.count_in(user_id, feed, filter, search)?;
@@ -1083,7 +1154,7 @@ async fn remove_feed(
{
state.ctx.db.unsubscribe(user.id, &child.id)?;
}
if state.ctx.db.subscriber_count(&id)? > 0 {
if state.ctx.db.subscriber_counts()?.contains_key(&id) {
return Ok(StatusCode::NO_CONTENT);
}
@@ -1098,6 +1169,7 @@ async fn remove_feed(
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
crate::retire_group(&state.ctx, &id)?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -12,7 +12,7 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
// empty, with every handler below the error dead. Server-side checks all passed.
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
await expect(page.getByText('Test Show')).toBeVisible();
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.reload();
@@ -33,7 +33,7 @@ test('the theme button steps through dark, light and classic, and remembers', as
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
expect(await theme()).toBe('classic');
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Dark/);
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
await page.reload();
await expect.poll(theme).toBe('classic');
@@ -41,6 +41,28 @@ test('the theme button steps through dark, light and classic, and remembers', as
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
});
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
await page.locator('#prefs').click();
await expect(page.locator('#stheme')).toHaveValue(await theme());
await page.locator('#stheme').selectOption('auto');
await expect.poll(theme).toBe('auto');
// Auto follows the system; emulating a light system must show the light palette live,
// no reload needed, since it is a media query rather than something JS picks per click.
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
// The header button and the dropdown are the same one setting, not two.
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
await page.locator('#theme').click();
expect(await theme()).toBe('dark');
});
test('settings opens and saves the global schedule', async ({ page }) => {
await page.locator('#prefs').click();
await expect(page.locator('#modal.on')).toBeVisible();
@@ -57,7 +79,7 @@ test('settings opens and saves the global schedule', async ({ page }) => {
});
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('First Episode')).toBeVisible();
// Newest first, so target the episode by name rather than by position.
@@ -74,7 +96,7 @@ test('episodes show with their metadata, and the text opens below', async ({ pag
});
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
await expect(page.locator('#detail')).toContainText('Pick an item');
@@ -133,8 +155,34 @@ test('an item with several enclosures lists them all', async ({ page }) => {
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
});
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
// test may have opened -- and so read -- this one already; reset it before relying on it.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row).toContainText('0:42 of 15:00');
await row.click();
await expect(page.locator('#player')).toBeVisible();
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
await page.locator('#pclose').click();
// Finished (read) drops it from the list, however far it got.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
});
test('the filter tabs change what is listed', async ({ page }) => {
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
const all = await page.locator('.ep').count(); // All is the default tab
await expect(page.locator('#count')).toContainText('item');
@@ -147,7 +195,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
});
test('a feed URL is editable and has a copy button', async ({ page }) => {
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
await expect(page.locator('#scopy')).toBeVisible();
@@ -288,7 +336,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 });
@@ -307,7 +355,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
});
test('the toolbar acts on the selected item', async ({ page }) => {
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 });
// Nothing selected, nothing to act on.
@@ -359,7 +407,15 @@ test('a second person has their own feeds and their own read state', async ({ br
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
await expect(page.locator('#feedlist')).toContainText('No feeds.');
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
// Settings stays: Sam has their own subscriptions to export and import, and the
// schedule and quota are worth seeing even without a say in them. Only the log and the
// users screen -- and the server -- are an admin's alone.
await expect(page.locator('#prefs')).toBeVisible();
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#gsave')).toBeHidden();
await expect(page.locator('#gusers')).toBeHidden();
await page.locator('#modalCard .cardacts .btn').first().click();
// Hiding the button is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403);
await expect(page.locator('#logs')).toBeHidden();
@@ -387,7 +443,7 @@ test('a second person has their own feeds and their own read state', async ({ br
test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => {
// Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file.
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
const row = page.locator('.ep').first();
await expect(row).toBeVisible({ timeout: 20_000 });
@@ -411,7 +467,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
expect(seen[1]).toContain('one copy of this file');
await page.reload();
await page.getByText('Test Show').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
});
@@ -433,6 +489,9 @@ test('an admin adds someone, makes them an admin, and removes them', async ({ pa
await page.locator('#uadd').click();
const row = userRow(page, 'pat');
await expect(row).toBeVisible();
// When each account was added and last signed in; the admin signed in with the token link.
await expect(row).toContainText(/Added .* never signed in/);
await expect(userRow(page, 'admin')).toContainText(/signed in \d+m ago/);
await expect(row.locator('[data-a="admin"]')).not.toBeChecked();
await row.locator('[data-a="admin"]').check();
@@ -613,8 +672,8 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toHaveCount(0);
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
@@ -623,19 +682,25 @@ test('Popular lists what everyone here reads, but never a private feed', async (
expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
// Popular is the top ten of the directory, and the directory is every listed feed, A to Z.
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
// with an OPML's feeds in place of the OPML in both.
const dir = await (await piper.request.get('/api/directory')).json();
const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase());
expect(names).toEqual([...names].sort());
const ids = dir.map(p => p.id);
expect(ids).not.toContain('test-subscriptions');
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
expect(top.length).toBe(Math.min(10, dir.length));
expect(top.every(t => dir.some(d => d.id === t.id))).toBe(true);
expect(dir.map(p => p.id)).not.toContain('paid-show');
expect(top.every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show');
// Subscribe from the directory this time; the popular list shares the same rows.
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
const row = async () =>
@@ -774,6 +839,22 @@ test('the item table sorts by any column, both ways, and remembers', async ({ pa
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
});
test('the selected feed and tab are remembered across a reload', async ({ page }) => {
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
await page.reload();
await expect(page.locator('#content h2')).toHaveText('Test Show');
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
// A feed that is gone -- unsubscribed, or never visited on this browser -- lands on All
// Subscriptions, not the first feed alphabetically.
await page.evaluate(() => localStorage.setItem('ipx.feed', 'no-such-feed'));
await page.reload();
await expect(page.locator('#feedlist .place.sel')).toContainText('All Subscriptions');
});
test('play in the Files pane plays once, in the player bar', async ({ page }) => {
// Regression: the pane had an <audio> of its own, and playing it started the player bar too,
// so the same file played twice at once.
@@ -781,6 +862,25 @@ test('play in the Files pane plays once, in the player bar', async ({ page }) =>
await page.locator('.ep', { has: page.locator('.kind.here') }).first().click();
await page.locator('#files [data-a="play"]').click();
await expect(page.locator('#player')).toBeVisible();
await expect(page.locator('audio')).toHaveCount(1); // the player bar's, and nothing else
// The player bar's element doubles as a <video> so a video file has somewhere to show its
// picture (see #audio's own comment), but there is still exactly one of it, and nothing else.
await expect(page.locator('#audio')).toHaveCount(1);
await page.locator('#pclose').click();
});
test('someone the proxy signs in never sees the password page, and signs out through the proxy', async ({ page, browser }) => {
// Signed in with the token, not by the proxy: Sign out stays ipx's own.
expect((await (await page.request.get('/api/me')).json()).sign_out).toBeNull();
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': 'proxied@example.com' } });
const proxied = await ctx.newPage();
// Regression: after Sign out, the password form showed to someone the proxy still vouched for.
await proxied.goto('/login');
await expect(proxied).toHaveURL(/:8791\/$/);
await expect(proxied.locator('#who')).toContainText('proxied@example.com');
expect(await (await proxied.request.get('/api/me')).json())
.toMatchObject({ name: 'proxied@example.com', sign_out: '/signed-out-by-the-proxy' });
await proxied.locator('#signout').click();
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
await ctx.close();
});

View File

@@ -37,6 +37,10 @@ enabled = false
enabled = true
bind = "127.0.0.1:8791"
token = "${TOKEN}"
# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1.
trusted_header = "X-Test-User"
trusted_proxies = ["127.0.0.1"]
sign_out_url = "/signed-out-by-the-proxy"
[feeds.test-show]
url = "http://127.0.0.1:8792/show.xml"

View File

@@ -43,6 +43,29 @@
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
/* Auto: the same palette as Light, but only while the system is set to light -- the default
:root above is already dark, so nothing is needed for the dark half of Auto. Duplicated
rather than shared with [data-theme="light"], the same way Classic repeats its own values;
CSS custom properties have no way to say "these vars, but only under this media query". */
@media (prefers-color-scheme: light) {
:root[data-theme="auto"] {
--bg:#f2f4f7;
--panel:#ffffff;
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6;
--fg:#1a1a1a;
--dim:#606060;
--faint:#767676;
--accent:#2d5391;
--accent2:#9a5f0a;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
@@ -69,7 +92,14 @@ html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;height:100vh;overflow:hidden;
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
the fallback for a browser that does not know dvh. */
height:100vh;height:100dvh;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)}
@@ -134,7 +164,9 @@ input[type=search],input[type=text],input[type=password],input[type=number],sele
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
#feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
feeds still line up with the places above. */
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
.feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;position:relative;
@@ -145,9 +177,10 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.feed.child{margin-left:44px}
.feed.child .art{width:28px;height:28px;font-size:11px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
above it. The button is the row's full height: a near miss used to open the folder's page. */
above it. The button is the row's full height and 24 px wide: a near miss used to open the
folder's page, and 16 px left the triangle cramped against the art. */
.chev{
position:absolute;left:-8px;top:0;bottom:0;width:16px;display:grid;place-items:center;
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
border-radius:4px;color:var(--faint);
}
.chev:hover{color:var(--fg)}
@@ -249,6 +282,8 @@ a.btn{text-decoration:none;color:inherit}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
/* An icon in place of a word; the word is in its tooltip. */
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* Inline in a sentence, next to a plain-word error explanation. */
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
@@ -334,6 +369,15 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
}
#player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
layout is unchanged. */
#audio{display:none}
body.has-video #audio{
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
}
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0}
@@ -543,7 +587,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
A phone hides the item group, which it has no table for. -->
<div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="minus" disabled></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
<button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></button>
</div>
<div class="tgroup item">
@@ -607,7 +651,10 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<div id="modal"><div class="card" id="modalCard"></div></div>
<div id="toasts"></div>
<audio id="audio" preload="metadata"></audio>
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
the current file is video -- see body.has-video below. -->
<video id="audio" preload="metadata" playsinline></video>
<script>
'use strict';
@@ -622,6 +669,7 @@ const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}
const ICON={
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
minus:fa('0 0 448 512','<path fill="currentColor" d="M0 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-32z"/>'), // solid/minus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
@@ -750,8 +798,13 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
/* ---------------- state ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0,
filter:'all', q:'', sel:null, me:null,
feeds:[],
// Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
entries:[], total:0, offset:0,
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null,
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
||{col:'published',dir:'desc'},
@@ -763,7 +816,13 @@ async function loadFeeds(keepSel){
S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all');
}
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half.
@@ -772,9 +831,9 @@ const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0);
// an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
blurb:'Every feed anyone on this server subscribes to, A to Z.'},
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here.'},
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
':all':{title:'All Subscriptions',icon:ICON.all},
};
function renderFeeds(){
@@ -825,6 +884,9 @@ function renderFeeds(){
const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded];
// A group's own row has no error of its own worth mentioning if the OPML itself
// reads fine; it is failing when any feed inside it is.
const failing=mine.length ? mine.find(c=>c.failing)?.failing : f.failing;
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
el.tabIndex=0; el.dataset.id=f.id;
@@ -836,6 +898,7 @@ function renderFeeds(){
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
`</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
(failing?`<span class="tag" style="color:var(--bad)" title="${esc(failing.reason)}">Error</span>`:'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
@@ -844,10 +907,26 @@ function renderFeeds(){
done();
}
function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries();
}
/// A failing feed's error, in plain words with something to do about it, once `failing` is
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
/// src/feed.rs). Anything else still shows the raw error, as before.
function failBannerHTML(f){
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
return '';
}
function wireFailBanner(box,f){
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
}
/* ---------------- feed page ---------------- */
function renderFeed(){
const box=$('#content');
@@ -871,7 +950,7 @@ function renderFeed(){
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${failBannerHTML(f)}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
@@ -881,7 +960,7 @@ function renderFeed(){
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>` : `
<div class="fhead slim">
@@ -915,7 +994,12 @@ function renderFeed(){
showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries();
});
if(f) wireFailBanner(box,f);
}
/// The item table's headings, each a button that sorts by its column. The first click goes the
@@ -954,7 +1038,7 @@ function renderGroup(f,kids){
<h2>${esc(f.title||f.id)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${failBannerHTML(f)}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div>
@@ -962,7 +1046,7 @@ function renderGroup(f,kids){
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div>
</div>
<div class="toolbar">
@@ -971,6 +1055,7 @@ function renderGroup(f,kids){
</div>
<div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
wireFailBanner($('#content'),f);
const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML='';
@@ -982,8 +1067,10 @@ function renderGroup(f,kids){
el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">Gone</span>':'')+
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id);
box.appendChild(el);
@@ -1024,7 +1111,15 @@ async function loadEntries(append){
const r=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
S.total=r.total;
S.entries = append ? S.entries.concat(r.entries) : r.entries;
let entries = append ? S.entries.concat(r.entries) : r.entries;
// A background scan finishing refreshes the list from the server, which -- on the Unread
// tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it.
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
const open=S.entries.find(e=>e.guid===S.sel);
if(open) entries=[open,...entries];
}
S.entries = entries;
renderEntries();
}
function renderEntries(){
@@ -1345,6 +1440,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
const resuming = player.guid===e.guid && player.enc===enc.id;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
@@ -1399,7 +1495,7 @@ $('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');renderEntries()};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
@@ -1563,7 +1659,7 @@ async function listFeeds(url){
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[data-a="sub"]',el).onclick=async()=>{
@@ -1585,17 +1681,42 @@ async function renderListed(v){
$('#tbRemove').disabled=true;
syncTools(null);
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':popular'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Feeds inside an OPML
subscription, and private feeds, are never listed.</div></div>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
</div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>`;
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
$('#count').textContent=v.title;
const n=await listFeeds(v.url);
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
if(listening) renderListening();
}
/// Below Popular: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse.
async function renderListening(){
const box=$('#listening');
let rows=[];
try{ rows=(await api('/api/entries?filter=in_progress&limit=10')).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
const el=document.createElement('div');
el.className='childrow';
const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
el.onclick=()=>play(e);
box.appendChild(el);
}
}
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
@@ -1643,18 +1764,31 @@ function due(ts){
return 'in '+Math.round(d/86400)+'d';
}
/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the
/// Users screen behind it, are the operator's alone (the server refuses both from anyone
/// else). A non-admin gets the same modal minus those two parts, not no settings at all --
/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a
/// feed is polled when it is.
async function prefsModal(){
const g = await api('/api/settings');
const gs = splitEvery(g.every_mins);
const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3>
<div class="field"><label>Theme</label>
<select id="stheme">${THEME_ORDER.map(t=>
`<option value="${t}"${document.documentElement.dataset.theme===t?' selected':''}>${THEMES[t]}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting. The same toggle is in
the header, one click at a time; this jumps straight to the one you want.</span></div>
<div class="field"><label>Check feeds every</label>
<div class="inline">
${admin?`<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label>
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span>`
:`<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its
own. Only an admin changes this.</span>`}</div>
${admin?`<div class="field"><label>Max new downloads per scan, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
@@ -1669,7 +1803,7 @@ async function prefsModal(){
<span class="hint">Over this, the oldest played items are deleted first. Kept
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>`:''}
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="field"><label>Subscriptions</label>
@@ -1680,12 +1814,14 @@ async function prefsModal(){
</div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div>
<div class="field"><label>Users</label>
${admin?`<div class="field"><label>Users</label>
<div class="inline"><button class="btn ico" id="gusers" title="Manage users…" aria-label="Manage users">${ICON.users}</button></div>
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
$('#stheme').onchange=e=>setTheme(e.target.value);
$('#gopml').onclick=opmlModal;
if(!admin) return;
$('#gusers').onclick=usersModal;
$('#gsave').onclick=async()=>{
try{
@@ -1706,7 +1842,9 @@ async function usersModal(){
const users = await api('/api/users') || [];
openModal(`<h3>Users</h3>
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
<small style="display:block;color:var(--faint)">${u.created?`Added ${dateOf(u.created)}`:'Added before this was kept'} · ${
u.last_login?`signed in ${ago(u.last_login)}`:'never signed in'}</small></div>
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
@@ -1747,7 +1885,7 @@ async function usersModal(){
};
}
function settingsModal(f){
function settingsModal(f,newUrl){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
@@ -1768,7 +1906,7 @@ function settingsModal(f){
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
</div>
<span class="hint">${S.me&&S.me.admin
@@ -1825,7 +1963,7 @@ function removeFeed(f){
reading it keeps it, along with their own read state.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button></div>`);
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
$('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null;
@@ -1865,13 +2003,16 @@ function opmlModal(){
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal;
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; };
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
// the proxy still vouches for them. /api/me says where, when that is the case.
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
// them too; hiding the buttons just stops offering what would fail.
if(!u.admin) $('#admintools').hidden=true;
// The log names every account and every failed sign-in; that alone is the operator's
// business, and the server refuses it from anyone else. Settings stays -- it opens the
// same modal for everyone, just without the admin-only fields (see prefsModal).
if(!u.admin) $('#logs').hidden=true;
}).catch(()=>{});
$('#logs').onclick=logsModal;
$('#feedFilter').oninput=renderFeeds;
@@ -1888,14 +2029,18 @@ $('#feedlist').onkeydown=ev=>{
};
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'};
// Dark, light, the 2004 Mac app, and following the system. The header button steps through
// them and Settings offers the same four as a dropdown; either one sets ipx.theme and both
// read it, so they never disagree. Auto last in the cycle keeps the existing three clicks in
// "steps through dark, light and classic" reaching classic exactly where that test expects.
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'};
const THEME_ORDER=Object.keys(THEMES);
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
function setTheme(t){
if(!THEMES[t]) t='dark';
document.documentElement.dataset.theme=t;
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`;
const sel=$('#stheme'); if(sel) sel.value=t;
try{ localStorage.setItem('ipx.theme',t); }catch{}
}
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));