Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30edff248 | |||
| be3820bbbd | |||
| 51ce0bf9eb | |||
| 6ec900e456 | |||
| d4304869b6 | |||
| 9aae3097e7 | |||
| 2ff2074755 | |||
| 1698cf8d1e | |||
| b94a74ef15 | |||
| 9a8a3c696f | |||
| bedf64e645 | |||
| 586d2c07a1 | |||
| 2ba83c3aed | |||
| 1352f0d54d | |||
| a958f7cb37 | |||
| 457a58dcc5 | |||
| 2af57065c6 | |||
| 564b011c7a |
103
CHANGELOG.md
103
CHANGELOG.md
@@ -10,6 +10,103 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 `&` (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
|
## [0.5.0] - 2026-09-12
|
||||||
|
|
||||||
### Added
|
### 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.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.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.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.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
|
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -17,6 +17,7 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
|
|||||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
||||||
|
|
||||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
@@ -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.
|
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 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
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
@@ -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.
|
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
|
* 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.
|
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.
|
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||||
Probe the control socket (`ipx status`) to check that.
|
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
|
* **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
|
`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
|
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.0"
|
version = "0.5.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.0"
|
version = "0.5.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
77
TODO.md
77
TODO.md
@@ -1,30 +1,57 @@
|
|||||||
# To do
|
# 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
|
- [x] **Stop scanning an OPML's feeds once nobody subscribes to it.** 55 of the 57 are feeds from
|
||||||
`entry_state` and the `entries.read`, `flagged` and `position` columns are gone. Its other half,
|
`davewiner` (lists.opml.org/davefeeds.xml). The list left `config.toml` about 14 hours before this
|
||||||
subscribing the first admin to the catalogue, was not dead and stays as `adopt_catalogue`.
|
was written, but its 922 feeds are still in the database and still scanned every hour, with
|
||||||
(`src/db.rs`, `src/main.rs`)
|
auto-download on: `subscriptions()` adds every derived feed, and with no parent to copy from,
|
||||||
- [x] **`contrib/` systemd units.** From before the container; nothing points at them.
|
`auto_download` falls back to `true` (`src/main.rs:937`). `docs/users.md` says a feed stops being
|
||||||
- [x] **`migrate_opml_children`.** A one-time move of OPML children out of `config.toml` that has
|
scanned when its last subscriber leaves. Skip a derived feed whose parent is not in config, and
|
||||||
run. Delete it and its call. (`src/main.rs`)
|
when the last subscriber leaves an OPML, treat its feeds the way `sync_group` treats ones the list
|
||||||
- [x] **The legacy `interval_mins` key.** Production uses `schedule`. Delete the field, the fallback
|
dropped: remove those with nothing downloaded, mark the rest orphaned. `remove_feed` and `ipx rm`
|
||||||
in `General::interval` and its test. (`src/config.rs`)
|
both leave them behind today. (`src/main.rs`, `src/web.rs`)
|
||||||
- [x] **`Db::entries` and `Db::count_entries`.** One-line wrappers only the tests call; the tests
|
- [x] **Read feeds with a bare `&`.** kcpw has `<link>https://kcpw.org/?post_type=post&p=125715</link>`,
|
||||||
call `entries_in` and `count_in` instead. (`src/db.rs`)
|
and both feedland feeds have the same fault. Strict XML refuses them; browsers and other readers do
|
||||||
- [x] **`web::generate_token`.** Repeats `auth::new_session_token`. Use that. (`src/web.rs`)
|
not. When `feed::parse` fails, try once more with every `&` that does not start an entity written
|
||||||
- [x] **Page leftovers.** `globalEvery`, `S.busy`, `S.limit`, `unitOptions`' `firstLabel`, `--r`,
|
as `&`. Nobody subscribes to these three now, but the next feed like them will fail the same
|
||||||
`.ep.open`, the phone `.ep .art`, the duplicate phone `.fhead.slim{flex-wrap}`, the second
|
way. (`src/feed.rs`)
|
||||||
`#sidebar{z-index}`, and the `on()` helper. (`web/index.html`)
|
- [x] **Say what came back when it is not a feed.** Thirteen errors read "not RSS (the input did not
|
||||||
- [x] **`logbuf` visitors.** `record_i64`, `record_u64` and `record_bool` repeat what `Visit`'s
|
begin with an rss tag) and not Atom (...)". Each one checked was a web page: the feed moved and its
|
||||||
defaults already do through `record_debug`. (`src/logbuf.rs`)
|
old URL redirects to the site, or the domain lapsed. Say "got a web page, not a feed", and when the
|
||||||
- [x] **The `infer` dependency.** Its torrent check is the `d8:announce` test on the next line.
|
page links a feed (`<link rel="alternate" type="application/rss+xml">`), name it. That link found
|
||||||
- [x] **The `dirs` dependency.** `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `HOME` from `std::env`.
|
the new feed for om.co, ms.now, Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and
|
||||||
- [x] **The `tokio-stream` dependency.** `futures_util::stream::unfold` over the broadcast receiver.
|
Daily Kos. A `202` with an empty body (British Antarctic Survey) should read as "nothing yet", not
|
||||||
- [x] **The icon inlined four times.** About 94 KB of base64 across both pages; serve it once as
|
as a parse failure. (`src/feed.rs`)
|
||||||
`/icon.png` from `include_bytes!`, open without signing in like `/login`.
|
- [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
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ entries feed_id, guid, title, link, published, description, first_seen,
|
|||||||
image, duration, episode, season PK (feed_id, guid)
|
image, duration, episode, season PK (feed_id, guid)
|
||||||
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
||||||
bytes_done, downloaded_at, last_error
|
bytes_done, downloaded_at, last_error
|
||||||
users id, name, pass_hash, is_admin, created
|
users id, name, pass_hash, is_admin, created, last_login
|
||||||
sessions token, user_id, created, seen
|
sessions token, user_id, seen
|
||||||
subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
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
|
entry_state user_id, feed_id, guid, read, flagged, position
|
||||||
PK (user_id, feed_id, guid)
|
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
|
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
|
||||||
older database.
|
older database.
|
||||||
|
|
||||||
Schema changes: add the table or column to `SCHEMA`, and for a column also to the list in
|
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
|
||||||
`migrate()`, which does `PRAGMA table_info` then `ALTER TABLE ADD COLUMN`. `Db::memory()` runs the
|
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
|
||||||
same path as `Db::open`, so a migration-only column cannot pass tests while missing in production.
|
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
|
## 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`,
|
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
||||||
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
|
`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
|
`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
|
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,
|
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/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
| `GET /api/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/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/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ token = "" # generated and saved on first run
|
|||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,6 +78,9 @@ session_days = 30
|
|||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
|
`/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.
|
* **`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
|
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
|
||||||
|
|||||||
121
docs/history.md
121
docs/history.md
@@ -6,6 +6,127 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
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
|
## 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.
|
A whole-repo audit for over-engineering listed twelve things to cut, and all of them went.
|
||||||
|
|||||||
309
docs/sso.md
309
docs/sso.md
@@ -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
|
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
|
password itself. The proxy authenticates the person and passes the result to ipx in a **header**;
|
||||||
pass the result to the origin in a **header**. ipx reads that header, finds (or creates) the
|
ipx reads it, finds (or creates) the matching account, and gets on with it.
|
||||||
matching account, and gets on with it.
|
|
||||||
|
|
||||||
Read [How this is secured](#how-this-is-secured) before exposing anything. The short version: a
|
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
|
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
|
```toml
|
||||||
[web]
|
[web]
|
||||||
enabled = true
|
enabled = true
|
||||||
bind = "0.0.0.0:8099"
|
bind = "0.0.0.0:8099"
|
||||||
token = "…" # keep it: it is the admin, used by the healthcheck
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||||
# 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.
|
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
|
sign_out_url = "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the daemon after editing. Accounts made this way have **no password**: they can only ever
|
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||||
arrive through the proxy. `ipx user list` marks them `proxy only`.
|
restart ipodderx`.
|
||||||
|
|
||||||
The first account created is an admin. Every later one is an ordinary user, and an ordinary user
|
### What was missing
|
||||||
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).
|
|
||||||
|
|
||||||
Somebody arriving through the proxy for the first time starts with **no feeds**, because
|
Cloudflare and Authentik were already right. Three things on the ipx side were not:
|
||||||
subscriptions are per person. Adding a feed someone else already reads costs no second fetch and no
|
|
||||||
second copy on disk.
|
|
||||||
|
|
||||||
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
|
```sh
|
||||||
ipx user list
|
docker network inspect content_default -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
|
||||||
echo -n 'a good password' | ipx user passwd <name> # optional: also lets them sign in directly
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Local sign-in at `/login` keeps working alongside all of this, which is how you get in from the LAN
|
That was measured, not assumed. ipx does not log where a request came from, so the addresses were
|
||||||
when the tunnel is down. So does the shared `[web] token`, which signs in as the admin: that is
|
read from the kernel's connection table inside the container while the site was open. (`/proc/net/tcp`
|
||||||
what the Docker healthcheck uses, and the way back in if you lock yourself out. A brand new database
|
lists them in hex.)
|
||||||
starts with **admin / ipodderx** — change it.
|
|
||||||
|
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
|
| Key | What it does |
|
||||||
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 |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Subdomain / domain | `ipodderx` / `sdf1.net` |
|
| `trusted_header` | The header the proxy sets. Empty, the default, turns the proxy path off. |
|
||||||
| Type | HTTP |
|
| `trusted_proxies` | The addresses allowed to set it. Nothing else is believed. |
|
||||||
| URL | `localhost:8099` (or the LAN address of the box) |
|
| `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
|
The first account ever created is an admin. Every later one is an ordinary user, who cannot change
|
||||||
coming from `127.0.0.1`, which is already in `trusted_proxies`. If `cloudflared` runs elsewhere (its
|
global settings, a feed's URL or folder, or how often feeds are scanned: the API refuses those with
|
||||||
own container, another host), put **its** address in `trusted_proxies` instead, and make sure
|
a `403`, not just the UI. Everything else about a feed is theirs alone; see [users.md](users.md).
|
||||||
nothing else can reach port 8099.
|
|
||||||
|
|
||||||
### 2. The Access application
|
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
|
||||||
**Zero Trust → Access → Applications → Add an application → Self-hosted**:
|
way back in if you lock yourself out. A brand new database starts with **admin / ipodderx**;
|
||||||
|
change it.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentik
|
## Authentik in the request path instead
|
||||||
|
|
||||||
Authentik does this with a **Proxy Provider** plus an **outpost**, which sits in the request path and
|
Not what ipodderx.sdf1.net uses, and **not verified**. Authentik can also sit in front of ipx
|
||||||
adds `X-authentik-username` (also `X-authentik-email`, `X-authentik-name`, `X-authentik-groups`).
|
itself, with a **Proxy Provider** and an **outpost** that adds `X-authentik-username`:
|
||||||
|
|
||||||
### 1. Provider
|
- Applications → Providers → Create → Proxy Provider; mode **Proxy** (the outpost talks to ipx) or
|
||||||
|
**Forward auth** (an existing reverse proxy asks the outpost).
|
||||||
**Applications → Providers → Create → Proxy Provider**:
|
- 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
|
||||||
- Name: `ipx`
|
in `trusted_proxies`. Measure that address as above rather than guessing it.
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How this is secured
|
## How this is secured
|
||||||
|
|
||||||
**The header is only believed from `trusted_proxies`.** Every other source is ignored, and the
|
**The header is only 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,
|
request falls through to a session cookie or the shared token. That is the whole security boundary.
|
||||||
so:
|
|
||||||
|
|
||||||
- List the **proxy's own address**, not a range. `["127.0.0.1"]` when the tunnel runs beside ipx;
|
With the tunnel reaching ipx through the host's port, `192.168.16.1` means **any container on Tower
|
||||||
the container's IP when it does not.
|
that connects to `192.168.1.130:8099`**, not only `cloudflared`. Machines on the LAN, and Tower
|
||||||
- Never list a LAN subnet. Anyone on your network could then send
|
itself, arrive under their own addresses and cannot set the header; the checks above show both
|
||||||
`Cf-Access-Authenticated-User-Email: admin@…` and be your admin.
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
- Make sure the origin port is not reachable *around* the proxy by anyone you would not admit
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
through it. If it is, bind ipx to `127.0.0.1` and let only the proxy reach it.
|
|
||||||
|
|
||||||
Verify the refusal, don't assume it — set `trusted_proxies = ["10.9.9.9"]` briefly and confirm a
|
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
||||||
header from your machine gets a `401`:
|
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
|
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||||
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
|
|
||||||
in with them until they are given a password (`ipx user passwd <name>`).
|
in with them until they are given a password (`ipx user passwd <name>`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -207,22 +184,20 @@ in with them until they are given a password (`ipx user passwd <name>`).
|
|||||||
## Everyday administration
|
## Everyday administration
|
||||||
|
|
||||||
```sh
|
```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
|
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
|
echo -n 'newsecret' | ipx user passwd sam # change a password
|
||||||
ipx user rm sam # remove the account
|
ipx user rm sam # remove the account
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
|
||||||
proxy vouching for an unknown name is logged and refused, rather than quietly making an account.
|
that read a password.
|
||||||
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).
|
|
||||||
|
|
||||||
Scanning intervals, the disk quota, retention, the download folder and a feed's URL are
|
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
||||||
**admin-only**: the Settings button is hidden for everyone else, and the API refuses the change even
|
proxy vouching for an unknown name is logged and refused. Make people ahead of time instead, with
|
||||||
if the request is made by hand. Everyone controls their own keywords, auto-download, explicit
|
the exact name the header will carry.
|
||||||
setting and per-scan cap, along with their own read state and which feeds they see.
|
|
||||||
|
|
||||||
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
||||||
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
||||||
|
|||||||
@@ -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
|
**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
|
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.
|
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
|
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
||||||
OPML subscription are left out, since they come with the OPML. So is anything that looks private: a
|
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
||||||
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the
|
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
||||||
query, or a feed from a paid-feed service such as Patreon or Supercast, which put the key in the
|
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
|
||||||
path. Those are someone's paid subscriptions, and listing them would let anyone here read what they
|
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
|
||||||
pay for.
|
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
|
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
|
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ pub struct Web {
|
|||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
|
/// 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.
|
/// Sign a session out after this long without a request.
|
||||||
pub session_days: i64,
|
pub session_days: i64,
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,7 @@ impl Default for Web {
|
|||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
343
src/db.rs
343
src/db.rs
@@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS feeds (
|
|||||||
last_checked INTEGER,
|
last_checked INTEGER,
|
||||||
ttl_mins INTEGER,
|
ttl_mins INTEGER,
|
||||||
last_error TEXT,
|
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.
|
-- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||||
orphaned INTEGER NOT NULL DEFAULT 0,
|
orphaned INTEGER NOT NULL DEFAULT 0,
|
||||||
-- The OPML subscription this feed came from.
|
-- The OPML subscription this feed came from.
|
||||||
@@ -73,7 +77,9 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
pass_hash TEXT,
|
pass_hash TEXT,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
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
|
-- 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,
|
auto_download INTEGER,
|
||||||
allow_explicit INTEGER,
|
allow_explicit INTEGER,
|
||||||
max_new_per_check INTEGER,
|
max_new_per_check INTEGER,
|
||||||
created INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, feed_id)
|
PRIMARY KEY (user_id, feed_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,7 +109,6 @@ CREATE TABLE IF NOT EXISTS entry_state (
|
|||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
created INTEGER NOT NULL,
|
|
||||||
seen INTEGER NOT NULL
|
seen INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
";
|
";
|
||||||
@@ -127,6 +131,23 @@ pub struct User {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub pass_hash: Option<String>,
|
pub pass_hash: Option<String>,
|
||||||
pub is_admin: bool,
|
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.
|
/// A feed derived from an OPML subscription rather than written into the config.
|
||||||
@@ -136,30 +157,35 @@ pub struct Managed {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub group_id: String,
|
pub group_id: String,
|
||||||
pub orphaned: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds the columns later versions introduced and drops the ones they retired. CREATE TABLE IF
|
/// 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<()> {
|
fn migrate(conn: &Connection) -> Result<()> {
|
||||||
let wanted: &[(&str, &str, &str)] = &[
|
let wanted: &[(&str, &str, &str)] = &[
|
||||||
("feeds", "image", "TEXT"),
|
// For whoever maintains the server. An audit dropped `created` as unread on 2026-09-12,
|
||||||
("feeds", "orphaned", "INTEGER NOT NULL DEFAULT 0"),
|
// and it came back the same day with `last_login` beside it.
|
||||||
("feeds", "group_id", "TEXT"),
|
("users", "created", "INTEGER"),
|
||||||
("feeds", "managed", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "last_login", "INTEGER"),
|
||||||
("entries", "image", "TEXT"),
|
("feeds", "error_since", "INTEGER"),
|
||||||
("entries", "duration", "INTEGER"),
|
];
|
||||||
("entries", "episode", "INTEGER"),
|
let retired: &[(&str, &str)] = &[
|
||||||
("entries", "season", "INTEGER"),
|
// 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 has = |table: &str, column: &str| -> Result<bool> {
|
||||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
let names: Vec<String> = conn
|
||||||
let names = stmt
|
.prepare(&format!("PRAGMA table_info({table})"))?
|
||||||
.query_map([], |r| r.get::<_, String>(1))?
|
.query_map([], |r| r.get(1))?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(names.iter().any(|c| c == column))
|
Ok(names.iter().any(|c| c == column))
|
||||||
};
|
};
|
||||||
for (table, column, ty) in wanted {
|
for (table, column, ty) in wanted {
|
||||||
@@ -187,6 +213,8 @@ pub struct FeedSummary {
|
|||||||
pub orphaned: bool,
|
pub orphaned: bool,
|
||||||
pub last_checked: Option<i64>,
|
pub last_checked: Option<i64>,
|
||||||
pub last_error: Option<String>,
|
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 entries: i64,
|
||||||
pub downloaded: i64,
|
pub downloaded: i64,
|
||||||
}
|
}
|
||||||
@@ -228,7 +256,7 @@ impl Db {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut sum: FeedSummary = conn
|
let mut sum: FeedSummary = conn
|
||||||
.query_row(
|
.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",
|
FROM feeds WHERE id = ?1",
|
||||||
[feed_id],
|
[feed_id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -238,6 +266,7 @@ impl Db {
|
|||||||
last_checked: r.get(2)?,
|
last_checked: r.get(2)?,
|
||||||
last_error: r.get(3)?,
|
last_error: r.get(3)?,
|
||||||
orphaned: r.get::<_, i64>(4)? != 0,
|
orphaned: r.get::<_, i64>(4)? != 0,
|
||||||
|
error_since: r.get(5)?,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -312,7 +341,8 @@ impl Db {
|
|||||||
last_checked = excluded.last_checked,
|
last_checked = excluded.last_checked,
|
||||||
ttl_mins = excluded.ttl_mins,
|
ttl_mins = excluded.ttl_mins,
|
||||||
image = coalesce(excluded.image, feeds.image),
|
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],
|
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -323,7 +353,8 @@ impl Db {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO feeds (id, url, last_checked) VALUES (?1, ?2, ?3)
|
"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()],
|
rusqlite::params![feed_id, url, now()],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -342,10 +373,15 @@ impl Db {
|
|||||||
|
|
||||||
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
|
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let now = now();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO feeds (id, url, last_checked, last_error) VALUES (?1, ?2, ?3, ?4)
|
"INSERT INTO feeds (id, url, last_checked, last_error, error_since)
|
||||||
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = excluded.last_error",
|
VALUES (?1, ?2, ?3, ?4, ?3)
|
||||||
rusqlite::params![feed_id, url, now(), msg],
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -649,6 +685,10 @@ pub enum Filter {
|
|||||||
Unread,
|
Unread,
|
||||||
Downloaded,
|
Downloaded,
|
||||||
Flagged,
|
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 {
|
impl Filter {
|
||||||
@@ -657,6 +697,7 @@ impl Filter {
|
|||||||
"unread" => Self::Unread,
|
"unread" => Self::Unread,
|
||||||
"downloaded" => Self::Downloaded,
|
"downloaded" => Self::Downloaded,
|
||||||
"flagged" => Self::Flagged,
|
"flagged" => Self::Flagged,
|
||||||
|
"in_progress" => Self::InProgress,
|
||||||
_ => Self::All,
|
_ => Self::All,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,6 +713,7 @@ impl Filter {
|
|||||||
"EXISTS (SELECT 1 FROM enclosures x
|
"EXISTS (SELECT 1 FROM enclosures x
|
||||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
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 {
|
for id in catalogue {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
|
||||||
params![user_id, id, now()],
|
params![user_id, id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
// Feeds that exist only in the database (OPML children) count too.
|
// Feeds that exist only in the database (OPML children) count too.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created)
|
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) SELECT ?1, id FROM feeds",
|
||||||
SELECT ?1, id, ?2 FROM feeds",
|
params![user_id],
|
||||||
params![user_id, now()],
|
|
||||||
)?;
|
)?;
|
||||||
Ok(catalogue.len())
|
Ok(catalogue.len())
|
||||||
}
|
}
|
||||||
@@ -954,8 +995,8 @@ impl Db {
|
|||||||
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
|
||||||
params![user_id, feed_id, now()],
|
params![user_id, feed_id],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -969,16 +1010,6 @@ impl Db {
|
|||||||
Ok(())
|
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.
|
/// 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<()> {
|
pub fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -989,8 +1020,8 @@ impl Db {
|
|||||||
.transpose()?;
|
.transpose()?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO subscriptions
|
"INSERT INTO subscriptions
|
||||||
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check, created)
|
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
ON CONFLICT(user_id, feed_id) DO UPDATE SET
|
ON CONFLICT(user_id, feed_id) DO UPDATE SET
|
||||||
keywords = excluded.keywords,
|
keywords = excluded.keywords,
|
||||||
auto_download = excluded.auto_download,
|
auto_download = excluded.auto_download,
|
||||||
@@ -1003,22 +1034,11 @@ impl Db {
|
|||||||
sub.auto_download.map(|v| v as i64),
|
sub.auto_download.map(|v| v as i64),
|
||||||
sub.allow_explicit.map(|v| v as i64),
|
sub.allow_explicit.map(|v| v as i64),
|
||||||
sub.max_new_per_check,
|
sub.max_new_per_check,
|
||||||
now()
|
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
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 ----
|
// ---- users and sessions ----
|
||||||
|
|
||||||
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
|
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>> {
|
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>> {
|
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>> {
|
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 stmt = conn.prepare(sql)?;
|
||||||
let mut rows = stmt.query(params![key])?;
|
let mut rows = stmt.query(params![key])?;
|
||||||
Ok(match rows.next()? {
|
Ok(match rows.next()? {
|
||||||
Some(r) => Some(User {
|
Some(r) => Some(user_row(r)?),
|
||||||
id: r.get(0)?,
|
|
||||||
name: r.get(1)?,
|
|
||||||
pass_hash: r.get(2)?,
|
|
||||||
is_admin: r.get::<_, i64>(3)? != 0,
|
|
||||||
}),
|
|
||||||
None => None,
|
None => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1056,16 +1071,9 @@ impl Db {
|
|||||||
pub fn users(&self) -> Result<Vec<User>> {
|
pub fn users(&self) -> Result<Vec<User>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt =
|
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
|
let out = stmt
|
||||||
.query_map([], |r| {
|
.query_map([], user_row)?
|
||||||
Ok(User {
|
|
||||||
id: r.get(0)?,
|
|
||||||
name: r.get(1)?,
|
|
||||||
pass_hash: r.get(2)?,
|
|
||||||
is_admin: r.get::<_, i64>(3)? != 0,
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -1082,6 +1090,26 @@ impl Db {
|
|||||||
Ok(())
|
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.
|
/// Sessions go with the user: a deleted account must not leave a usable cookie behind.
|
||||||
pub fn delete_user(&self, id: i64) -> Result<()> {
|
pub fn delete_user(&self, id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -1093,7 +1121,7 @@ impl Db {
|
|||||||
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
|
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
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()],
|
params![token, user_id, now()],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1105,18 +1133,13 @@ impl Db {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let cutoff = now() - max_idle_secs;
|
let cutoff = now() - max_idle_secs;
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token = ?1 AND s.seen >= ?2",
|
WHERE s.token = ?1 AND s.seen >= ?2",
|
||||||
)?;
|
)?;
|
||||||
let mut rows = stmt.query(params![token, cutoff])?;
|
let mut rows = stmt.query(params![token, cutoff])?;
|
||||||
let found = match rows.next()? {
|
let found = match rows.next()? {
|
||||||
Some(r) => Some(User {
|
Some(r) => Some(user_row(r)?),
|
||||||
id: r.get(0)?,
|
|
||||||
name: r.get(1)?,
|
|
||||||
pass_hash: r.get(2)?,
|
|
||||||
is_admin: r.get::<_, i64>(3)? != 0,
|
|
||||||
}),
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
drop(rows);
|
drop(rows);
|
||||||
@@ -1195,7 +1218,7 @@ impl Db {
|
|||||||
.optional()?)
|
.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(
|
pub fn set_entry_flag(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -1270,7 +1293,7 @@ impl Db {
|
|||||||
pub fn managed_feeds(&self) -> Result<Vec<Managed>> {
|
pub fn managed_feeds(&self) -> Result<Vec<Managed>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
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)",
|
WHERE managed = 1 AND group_id IS NOT NULL ORDER BY coalesce(title, id)",
|
||||||
)?;
|
)?;
|
||||||
Ok(stmt
|
Ok(stmt
|
||||||
@@ -1280,7 +1303,6 @@ impl Db {
|
|||||||
url: r.get(1)?,
|
url: r.get(1)?,
|
||||||
title: r.get(2)?,
|
title: r.get(2)?,
|
||||||
group_id: r.get(3)?,
|
group_id: r.get(3)?,
|
||||||
orphaned: r.get::<_, i64>(4)? != 0,
|
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
@@ -1423,8 +1445,8 @@ mod tests {
|
|||||||
fn every_sort_column_runs_and_orders_both_ways() {
|
fn every_sort_column_runs_and_orders_both_ways() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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 subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(1,'g',0);
|
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 feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark');
|
||||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||||
('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300);
|
('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() {
|
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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 users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0);
|
||||||
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0),(3,'f',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 entries (feed_id, guid, first_seen) VALUES ('f','a',0);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1,'f','a','u1','/tmp/a','done');",
|
(1,'f','a','u1','/tmp/a','done');",
|
||||||
@@ -1483,7 +1505,7 @@ mod tests {
|
|||||||
fn read_state_belongs_to_one_person() {
|
fn read_state_belongs_to_one_person() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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
|
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||||
('f','a','One',100),('f','b','Two',200);",
|
('f','a','One',100),('f','b','Two',200);",
|
||||||
)
|
)
|
||||||
@@ -1526,27 +1548,89 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
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(
|
conn.execute_batch(
|
||||||
"CREATE TABLE entries (feed_id TEXT NOT NULL, guid TEXT NOT NULL,
|
"CREATE TABLE entries (feed_id TEXT NOT NULL, guid TEXT NOT NULL,
|
||||||
first_seen INTEGER NOT NULL, read INTEGER NOT NULL DEFAULT 0,
|
first_seen INTEGER NOT NULL, read INTEGER NOT NULL DEFAULT 0,
|
||||||
flagged INTEGER NOT NULL DEFAULT 0, position 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();
|
.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();
|
conn.execute_batch(SCHEMA).unwrap();
|
||||||
migrate(&conn).unwrap();
|
migrate(&conn).unwrap();
|
||||||
let cols: Vec<String> = conn
|
let cols = |table: &str| -> Vec<String> {
|
||||||
.prepare("PRAGMA table_info(entries)")
|
conn.prepare(&format!("PRAGMA table_info({table})"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.query_map([], |r| r.get(1))
|
.query_map([], |r| r.get(1))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.collect::<rusqlite::Result<_>>()
|
.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();
|
.unwrap();
|
||||||
assert!(!cols.iter().any(|c| ["read", "flagged", "position"].contains(&c.as_str())), "{cols:?}");
|
assert_eq!(kept, 1);
|
||||||
assert!(cols.iter().any(|c| c == "image"), "and it still gains the newer ones");
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
@@ -1554,7 +1638,7 @@ mod tests {
|
|||||||
// Cutting this along with the dead read columns left the browser suite's admin with an
|
// 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.
|
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
|
||||||
let db = Db::memory().unwrap();
|
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();
|
.unwrap();
|
||||||
let subs = || -> i64 {
|
let subs = || -> i64 {
|
||||||
db.conn.lock().unwrap().query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0)).unwrap()
|
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
|
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
|
||||||
('f','a','Alpha dive','notes one',100),
|
('f','a','Alpha dive','notes one',100),
|
||||||
('f','b','Beta', 'notes two',200),
|
('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
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1,'f','b','u1','/tmp/b','done');
|
(1,'f','b','u1','/tmp/b','done');
|
||||||
-- Read and starred belong to a person now, so say which one.
|
-- 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 users (id, name, is_admin) VALUES (7,'reader',1);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
|
||||||
(7,'f','b',1,0),
|
(7,'f','b',1,0,0),
|
||||||
(7,'f','c',1,1);",
|
(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();
|
.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.
|
// Both paths must run without erroring, and agree with each other.
|
||||||
let order = order_sql("published", "desc");
|
let order = order_sql("published", "desc");
|
||||||
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap();
|
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!(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::All, None).unwrap(), 6);
|
||||||
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1);
|
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::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::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("dive")).unwrap(), 2);
|
||||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
|
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||||
"search is case-insensitive and covers the description");
|
"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]
|
#[test]
|
||||||
@@ -1652,7 +1749,7 @@ mod tests {
|
|||||||
fn a_show_takes_over_what_its_creator_held() {
|
fn a_show_takes_over_what_its_creator_held() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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
|
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
|
||||||
(1,'creator','a','u1','done','/x/a.mp3',NULL),
|
(1,'creator','a','u1','done','/x/a.mp3',NULL),
|
||||||
(2,'creator','b','u2','skipped',NULL,'explicit'),
|
(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() {
|
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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 subscriptions (user_id, feed_id, allow_explicit, created) VALUES
|
INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES
|
||||||
(1,'group',1,0),(1,'show',NULL,0),(2,'group',1,0),(2,'show',0,0);",
|
(1,'group',1),(1,'show',NULL),(2,'group',1),(2,'show',0);",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let explicit = |group| -> Vec<Option<bool>> {
|
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");
|
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]
|
#[test]
|
||||||
fn enclosure_url_is_the_dedupe_key() {
|
fn enclosure_url_is_the_dedupe_key() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
|
|||||||
245
src/feed.rs
245
src/feed.rs
@@ -87,6 +87,50 @@ pub async fn fetch(
|
|||||||
Ok(Fetched::Body { bytes, etag, last_modified })
|
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.
|
/// 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
|
/// 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)),
|
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||||
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
||||||
Ok(feed) => Ok(from_atom(feed)),
|
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
|
||||||
|
// "&". 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
|
||||||
|
/// (`&`, `<`, `>`, `"`, `'`, or a numeric reference like `'`).
|
||||||
|
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"&");
|
||||||
|
} else {
|
||||||
|
out.push(bytes[i]);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Every `<enclosure>` of every `<item>`, in document order.
|
/// 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
|
/// 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()),
|
link: non_empty(item.link()),
|
||||||
published: item.pub_date().and_then(parse_date),
|
published: item.pub_date().and_then(parse_date),
|
||||||
// Content wins over description, as __getEntries preferred entry.content.
|
// 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: item
|
||||||
.categories()
|
.categories()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -406,11 +549,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
title: non_empty(Some(e.title().as_str())),
|
title: non_empty(Some(e.title().as_str())),
|
||||||
link: alt.map(str::to_owned),
|
link: alt.map(str::to_owned),
|
||||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||||
description: e
|
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
||||||
.content()
|
|
||||||
.and_then(|c| c.value())
|
|
||||||
.or_else(|| e.summary().map(|s| s.as_str()))
|
|
||||||
.map(str::to_owned),
|
|
||||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||||
explicit: false,
|
explicit: false,
|
||||||
image: None,
|
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)
|
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:
|
/// 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
|
/// `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
|
/// 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]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
@@ -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]
|
#[test]
|
||||||
fn parses_atom_enclosure_links() {
|
fn parses_atom_enclosure_links() {
|
||||||
let bytes = include_bytes!("../tests/data/atom.xml");
|
let bytes = include_bytes!("../tests/data/atom.xml");
|
||||||
@@ -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&b <x> ' / c&d");
|
||||||
|
assert_eq!(out, b"a&b <x> ' / c&d");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() {
|
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
|
// Some feeds set a different itunes:title. The displayed title is always the RSS
|
||||||
|
|||||||
57
src/ipc.rs
57
src/ipc.rs
@@ -172,11 +172,18 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
|||||||
UnixStream::connect(path).await.is_ok()
|
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.
|
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
events: broadcast::Sender<Event>,
|
events: broadcast::Sender<Event>,
|
||||||
cmds: mpsc::Sender<Command>,
|
cmds: mpsc::Sender<Command>,
|
||||||
|
status: StatusFn,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// A socket file left by a crashed daemon would block the bind; a live one was already
|
// 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.
|
// rejected by the caller's daemon_is_live() check.
|
||||||
@@ -195,8 +202,9 @@ pub async fn serve(
|
|||||||
let (stream, _) = listener.accept().await?;
|
let (stream, _) = listener.accept().await?;
|
||||||
let rx = events.subscribe();
|
let rx = events.subscribe();
|
||||||
let cmds = cmds.clone();
|
let cmds = cmds.clone();
|
||||||
|
let status = status.clone();
|
||||||
tokio::spawn(async move {
|
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");
|
tracing::debug!(error = %e, "client gone");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -207,12 +215,21 @@ async fn handle(
|
|||||||
stream: UnixStream,
|
stream: UnixStream,
|
||||||
mut rx: broadcast::Receiver<Event>,
|
mut rx: broadcast::Receiver<Event>,
|
||||||
cmds: mpsc::Sender<Command>,
|
cmds: mpsc::Sender<Command>,
|
||||||
|
status: StatusFn,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (read, mut write) = stream.into_split();
|
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 {
|
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();
|
let mut line = serde_json::to_string(&ev).unwrap_or_default();
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
if write.write_all(line.as_bytes()).await.is_err() {
|
if write.write_all(line.as_bytes()).await.is_err() {
|
||||||
@@ -229,6 +246,15 @@ async fn handle(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match serde_json::from_str::<Command>(line) {
|
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) => {
|
Ok(cmd) => {
|
||||||
if cmds.send(cmd).await.is_err() {
|
if cmds.send(cmd).await.is_err() {
|
||||||
break; // Worker is gone; so are we.
|
break; // Worker is gone; so are we.
|
||||||
@@ -329,4 +355,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.is_terminal());
|
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/main.rs
128
src/main.rs
@@ -99,6 +99,9 @@ enum UserCmd {
|
|||||||
Passwd { name: String },
|
Passwd { name: String },
|
||||||
/// Delete an account and everything it knows: its subscriptions and read state
|
/// Delete an account and everything it knows: its subscriptions and read state
|
||||||
Rm { name: String },
|
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
|
/// 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>");
|
println!("no accounts yet: ipx user add <name>");
|
||||||
}
|
}
|
||||||
for u in users {
|
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!(
|
println!(
|
||||||
"{:<20} {:<8} {}",
|
"{:<20} {:<6} {:<11} added {added} {seen}",
|
||||||
u.name,
|
u.name,
|
||||||
if u.is_admin { "admin" } else { "" },
|
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(())
|
Ok(())
|
||||||
@@ -292,6 +300,22 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
println!("password changed for {name}");
|
println!("password changed for {name}");
|
||||||
Ok(())
|
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 } => {
|
UserCmd::Rm { name } => {
|
||||||
let name = name.trim().to_ascii_lowercase();
|
let name = name.trim().to_ascii_lowercase();
|
||||||
let user = ctx
|
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::Reap { dry_run } => reap(ctx, dry_run, true),
|
||||||
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
||||||
Cmd::Status => {
|
Cmd::Status => {
|
||||||
let (pending, downloaded) = ctx.db.counts()?;
|
ctx.out.emit(status(ctx));
|
||||||
let feeds = subscriptions(ctx).map(|s| s.len()).unwrap_or(0);
|
|
||||||
ctx.out.emit(Event::Status { feeds, pending, downloaded });
|
|
||||||
Ok(())
|
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(
|
async fn daemon(
|
||||||
ctx: Arc<Ctx>,
|
ctx: Arc<Ctx>,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
@@ -361,7 +395,12 @@ async fn daemon(
|
|||||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
||||||
|
|
||||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
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.
|
// 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));
|
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)?;
|
cfg.save(config_path)?;
|
||||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||||
println!("removed {feed}; downloads and history kept");
|
println!("removed {feed}; downloads and history kept");
|
||||||
|
retire_group(ctx, feed)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +843,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
|||||||
feed: id.clone(),
|
feed: id.clone(),
|
||||||
reason: "not modified".into(),
|
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 }) => {
|
Ok(Outcome::Opml { added, removed, kept, total }) => {
|
||||||
ctx.out.emit(Event::FeedSkip {
|
ctx.out.emit(Event::FeedSkip {
|
||||||
feed: id.clone(),
|
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
|
continue; // promoted to config at some point; that entry wins
|
||||||
}
|
}
|
||||||
let parent = cfg.feeds.get(&m.group_id);
|
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
|
let base = parent
|
||||||
.and_then(|p| p.folder.clone())
|
.and_then(|p| p.folder.clone())
|
||||||
.or_else(|| ctx.db.feed_summary(&m.group_id).ok().and_then(|s| s.title))
|
.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)
|
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.
|
/// Seconds to wait before re-checking a feed.
|
||||||
///
|
///
|
||||||
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
/// 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.
|
/// What a scan of one feed turned out to be.
|
||||||
enum Outcome {
|
enum Outcome {
|
||||||
NotModified,
|
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),
|
Feed(Scan),
|
||||||
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows.
|
/// 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 },
|
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),
|
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"
|
// 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.
|
// URL; sniffing the body also catches one served from a URL without that extension.
|
||||||
if feed::is_opml(&bytes) {
|
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);
|
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(true), None)], &feed(), 3);
|
||||||
assert!(p.auto_download);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,8 +154,8 @@ mod tests {
|
|||||||
// One file serves both subscribers, so it takes both of them to release it.
|
// One file serves both subscribers, so it takes both of them to release it.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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 subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0);
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'keep', 0),
|
('f', 'keep', 0),
|
||||||
('f', 'half', 0),
|
('f', 'half', 0),
|
||||||
@@ -189,7 +189,7 @@ mod tests {
|
|||||||
fn prune_keeps_entries_that_still_have_a_file() {
|
fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
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 entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
|
|||||||
138
src/web.rs
138
src/web.rs
@@ -91,23 +91,8 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let cfg = state.ctx.cfg();
|
let cfg = state.ctx.cfg();
|
||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
let peer = req
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
.extensions()
|
let vouched = vouched_name(&cfg, &req);
|
||||||
.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();
|
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -133,7 +118,13 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
None
|
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.
|
// 2. A session cookie from signing in here.
|
||||||
if user.is_none() {
|
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)) {
|
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
||||||
user = admin_user(&state);
|
user = admin_user(&state);
|
||||||
if from_query.is_some() {
|
if from_query.is_some() {
|
||||||
|
// The token link is a sign-in; the cookie it leaves behind is not one each time.
|
||||||
|
if let Some(u) = &user {
|
||||||
|
let _ = state.ctx.db.signed_in(u.id);
|
||||||
|
}
|
||||||
set_cookie = Some(format!(
|
set_cookie = Some(format!(
|
||||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
||||||
));
|
));
|
||||||
@@ -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(user);
|
||||||
|
req.extensions_mut().insert(Proxied(by_proxy));
|
||||||
let mut resp = next.run(req).await;
|
let mut resp = next.run(req).await;
|
||||||
if let Some(c) = set_cookie {
|
if let Some(c) = set_cookie {
|
||||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
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";
|
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
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
/// reaches a handler without passing through it.
|
/// reaches a handler without passing through it.
|
||||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
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 user = user.expect("verified above");
|
||||||
let token = crate::auth::new_session_token();
|
let token = crate::auth::new_session_token();
|
||||||
state.ctx.db.create_session(user.id, &token)?;
|
state.ctx.db.create_session(user.id, &token)?;
|
||||||
|
state.ctx.db.signed_in(user.id)?;
|
||||||
tracing::info!(user = %user.name, "signed in");
|
tracing::info!(user = %user.name, "signed in");
|
||||||
|
|
||||||
let days = state.ctx.cfg().web.session_days.max(1);
|
let days = state.ctx.cfg().web.session_days.max(1);
|
||||||
@@ -283,8 +303,15 @@ async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
|||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
/// Who is signed in, and, for someone the proxy signed in, where Sign out should send them.
|
||||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
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 ----
|
// ---- accounts: admin only ----
|
||||||
@@ -317,6 +344,7 @@ async fn list_users(
|
|||||||
.map(|u| {
|
.map(|u| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(),
|
"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();
|
.collect();
|
||||||
@@ -406,8 +434,13 @@ async fn remove_user(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login_page() -> Html<&'static str> {
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
Html(include_str!("../web/login.html"))
|
/// 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
|
/// 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>,
|
last_checked: Option<i64>,
|
||||||
next_check: Option<i64>,
|
next_check: Option<i64>,
|
||||||
last_error: Option<String>,
|
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,
|
entries: i64,
|
||||||
downloaded: i64,
|
downloaded: i64,
|
||||||
unread: i64,
|
unread: i64,
|
||||||
@@ -462,6 +498,15 @@ struct FeedRow {
|
|||||||
subscribers: i64,
|
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(
|
async fn feeds(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
@@ -525,6 +570,12 @@ async fn feeds(
|
|||||||
next_check: s
|
next_check: s
|
||||||
.last_checked
|
.last_checked
|
||||||
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64),
|
.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,
|
last_error: s.last_error,
|
||||||
entries: s.entries,
|
entries: s.entries,
|
||||||
downloaded: s.downloaded,
|
downloaded: s.downloaded,
|
||||||
@@ -580,24 +631,33 @@ struct PopularRow {
|
|||||||
|
|
||||||
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
/// 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
|
/// 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>> {
|
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let counts = db.subscriber_counts()?;
|
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![];
|
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);
|
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
|
// A feed inside an OPML that looks private is as private as the OPML.
|
||||||
// inside, which would bury everything anyone chose on purpose.
|
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
||||||
let from_opml = s.managed || s.cfg.group.is_some();
|
if n == 0
|
||||||
if n == 0 || from_opml || looks_private(&s.cfg) {
|
|| is_folder.contains(s.id.as_str())
|
||||||
|
|| looks_private(&s.cfg)
|
||||||
|
|| folder.is_some_and(|f| looks_private(f))
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sum = db.feed_summary(&s.id)?;
|
let sum = db.feed_summary(&s.id)?;
|
||||||
let subscribed = mine.contains(&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))));
|
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -612,7 +672,7 @@ async fn get_popular(
|
|||||||
Ok(Json(rows))
|
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(
|
async fn get_directory(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
@@ -634,7 +694,7 @@ async fn subscribe_popular(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
if !popular(&state, user.id)?.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)?;
|
state.ctx.db.subscribe(user.id, &id)?;
|
||||||
Ok(Json(serde_json::json!({ "id": id })))
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
@@ -739,7 +799,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_last_admin_is_protected() {
|
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, false)], 1));
|
||||||
assert!(!last_admin(&[u(1, true), u(2, true)], 1), "another admin remains");
|
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");
|
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 =
|
let mut rows =
|
||||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
||||||
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
// 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 {
|
for row in &mut rows {
|
||||||
if let Some(d) = &row.description {
|
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)?;
|
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)?;
|
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);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,6 +1169,7 @@ async fn remove_feed(
|
|||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
cfg.save(&state.config_path)?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
state.ctx.reload_cfg(&state.config_path)?;
|
||||||
|
crate::retire_group(&state.ctx, &id)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// 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.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 = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
await page.reload();
|
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);
|
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
||||||
expect(await theme()).toBe('classic');
|
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 page.reload();
|
||||||
await expect.poll(theme).toBe('classic');
|
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');
|
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 }) => {
|
test('settings opens and saves the global schedule', async ({ page }) => {
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modal.on')).toBeVisible();
|
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 }) => {
|
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.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(page.getByText('First Episode')).toBeVisible();
|
await expect(page.getByText('First Episode')).toBeVisible();
|
||||||
// Newest first, so target the episode by name rather than by position.
|
// 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 }) => {
|
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('#list')).toBeVisible();
|
||||||
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
||||||
await expect(page.locator('#detail')).toContainText('Pick an item');
|
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();
|
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 }) => {
|
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 });
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
const all = await page.locator('.ep').count(); // All is the default tab
|
const all = await page.locator('.ep').count(); // All is the default tab
|
||||||
await expect(page.locator('#count')).toContainText('item');
|
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 }) => {
|
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 page.locator('#content .acts [data-a="settings"]').click();
|
||||||
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
||||||
await expect(page.locator('#scopy')).toBeVisible();
|
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 = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
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' });
|
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
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 }) => {
|
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' });
|
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||||
// Nothing selected, nothing to act on.
|
// 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.
|
// 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('#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.
|
// Hiding the button is not the guard; the server is.
|
||||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||||
await expect(page.locator('#logs')).toBeHidden();
|
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 }) => {
|
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.
|
// 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();
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||||
const row = page.locator('.ep').first();
|
const row = page.locator('.ep').first();
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
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');
|
expect(seen[1]).toContain('one copy of this file');
|
||||||
|
|
||||||
await page.reload();
|
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 page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
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();
|
await page.locator('#uadd').click();
|
||||||
const row = userRow(page, 'pat');
|
const row = userRow(page, 'pat');
|
||||||
await expect(row).toBeVisible();
|
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 expect(row.locator('[data-a="admin"]')).not.toBeChecked();
|
||||||
|
|
||||||
await row.locator('[data-a="admin"]').check();
|
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();
|
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||||
const offered = piper.locator('#popular .childrow');
|
const offered = piper.locator('#popular .childrow');
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
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.
|
// 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: /Grouped Show|grouped-show/ })).toHaveCount(0);
|
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).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.
|
// 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(listed).not.toContain('.xml');
|
||||||
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
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 dir = await (await piper.request.get('/api/directory')).json();
|
||||||
const top = await (await piper.request.get('/api/popular')).json();
|
const top = await (await piper.request.get('/api/popular')).json();
|
||||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||||
expect(names).toEqual([...names].sort());
|
expect(names).toEqual([...names].sort());
|
||||||
|
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.length).toBe(Math.min(10, dir.length));
|
||||||
expect(top.every(t => dir.some(d => d.id === t.id))).toBe(true);
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||||
expect(dir.map(p => p.id)).not.toContain('paid-show');
|
expect(ids).not.toContain('paid-show');
|
||||||
|
|
||||||
// Subscribe from the directory this time; the popular list shares the same rows.
|
// Subscribe from the directory this time; the popular list shares the same rows.
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
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);
|
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
|
||||||
const row = async () =>
|
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);
|
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 }) => {
|
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,
|
// 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.
|
// 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('.ep', { has: page.locator('.kind.here') }).first().click();
|
||||||
await page.locator('#files [data-a="play"]').click();
|
await page.locator('#files [data-a="play"]').click();
|
||||||
await expect(page.locator('#player')).toBeVisible();
|
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();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ enabled = false
|
|||||||
enabled = true
|
enabled = true
|
||||||
bind = "127.0.0.1:8791"
|
bind = "127.0.0.1:8791"
|
||||||
token = "${TOKEN}"
|
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]
|
[feeds.test-show]
|
||||||
url = "http://127.0.0.1:8792/show.xml"
|
url = "http://127.0.0.1:8792/show.xml"
|
||||||
|
|||||||
229
web/index.html
229
web/index.html
@@ -43,6 +43,29 @@
|
|||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--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. */
|
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||||
:root[data-theme="classic"] {
|
:root[data-theme="classic"] {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
@@ -69,7 +92,14 @@ html,body{height:100%}
|
|||||||
body{
|
body{
|
||||||
margin:0;background:var(--bg);color:var(--fg);
|
margin:0;background:var(--bg);color:var(--fg);
|
||||||
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
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}
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||||
a{color:var(--accent)}
|
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;
|
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
|
||||||
}
|
}
|
||||||
input:focus,select:focus{outline:0;border-color:var(--accent)}
|
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{
|
.feed{
|
||||||
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
||||||
cursor:pointer;margin-bottom:1px;position:relative;
|
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{margin-left:44px}
|
||||||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
.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
|
/* 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{
|
.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);
|
border-radius:4px;color:var(--faint);
|
||||||
}
|
}
|
||||||
.chev:hover{color:var(--fg)}
|
.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)}
|
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
||||||
/* An icon in place of a word; the word is in its tooltip. */
|
/* 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}
|
.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. */
|
/* 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}
|
.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
|
/* 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);
|
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||||
}
|
}
|
||||||
#player.on{display:grid}
|
#player.on{display:grid}
|
||||||
|
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||||
|
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{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
|
||||||
#pnow .art{width:44px;height:44px;font-size:13px}
|
#pnow .art{width:44px;height:44px;font-size:13px}
|
||||||
#pnow .txt{min-width:0}
|
#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. -->
|
A phone hides the item group, which it has no table for. -->
|
||||||
<div class="tgroup">
|
<div class="tgroup">
|
||||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="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>
|
<button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tgroup item">
|
<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="modal"><div class="card" id="modalCard"></div></div>
|
||||||
<div id="toasts"></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>
|
<script>
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -622,6 +669,7 @@ const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}
|
|||||||
const ICON={
|
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
|
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
|
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
|
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
|
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
|
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 ---------------- */
|
/* ---------------- state ---------------- */
|
||||||
const S = {
|
const S = {
|
||||||
feeds:[], feed:null, entries:[], total:0, offset:0,
|
feeds:[],
|
||||||
filter:'all', q:'', sel:null, me:null,
|
// 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.
|
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||||
||{col:'published',dir:'desc'},
|
||{col:'published',dir:'desc'},
|
||||||
@@ -763,7 +816,13 @@ async function loadFeeds(keepSel){
|
|||||||
S.feeds = await api('/api/feeds');
|
S.feeds = await api('/api/feeds');
|
||||||
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||||
renderFeeds();
|
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
|
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||||
// server's alphabetical order still holds within each half.
|
// server's alphabetical order still holds within each half.
|
||||||
@@ -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.
|
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||||
const VIEWS={
|
const VIEWS={
|
||||||
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
':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',
|
':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},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
@@ -825,6 +884,9 @@ function renderFeeds(){
|
|||||||
const [unread,eps,saved]=mine.length
|
const [unread,eps,saved]=mine.length
|
||||||
? [sum('unread'),sum('entries'),sum('downloaded')]
|
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||||
: [f.unread,f.entries,f.downloaded];
|
: [f.unread,f.entries,f.downloaded];
|
||||||
|
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||||
|
// 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');
|
const el=document.createElement('div');
|
||||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
||||||
el.tabIndex=0; el.dataset.id=f.id;
|
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`+
|
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||||
`</small></div>`+
|
`</small></div>`+
|
||||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
|
(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>`;
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
@@ -844,10 +907,26 @@ function renderFeeds(){
|
|||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
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();
|
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 ---------------- */
|
/* ---------------- feed page ---------------- */
|
||||||
function renderFeed(){
|
function renderFeed(){
|
||||||
const box=$('#content');
|
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)}`:''}">${
|
<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)}${
|
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.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
|
${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>`:''}
|
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>`:''}
|
${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="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="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" 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>` : `
|
</div>` : `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
@@ -915,7 +994,12 @@ function renderFeed(){
|
|||||||
showDetail(null);
|
showDetail(null);
|
||||||
|
|
||||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{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
|
/// 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>
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
<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>
|
· ${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
|
${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>`:''}
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
</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 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="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" 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>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -971,6 +1055,7 @@ function renderGroup(f,kids){
|
|||||||
</div>
|
</div>
|
||||||
<div class="childlist" id="kidlist"></div>`;
|
<div class="childlist" id="kidlist"></div>`;
|
||||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||||
|
wireFailBanner($('#content'),f);
|
||||||
const draw=()=>{
|
const draw=()=>{
|
||||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||||
const box=$('#kidlist'); box.innerHTML='';
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
@@ -982,8 +1067,10 @@ function renderGroup(f,kids){
|
|||||||
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||||
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||||
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
`<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.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>`;
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||||
el.onclick=()=>selectFeed(c.id);
|
el.onclick=()=>selectFeed(c.id);
|
||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
@@ -1024,7 +1111,15 @@ async function loadEntries(append){
|
|||||||
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||||
S.total=r.total;
|
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();
|
renderEntries();
|
||||||
}
|
}
|
||||||
function 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;
|
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||||
if(!resuming){
|
if(!resuming){
|
||||||
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
|
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.src=`/media/${enc.id}`;
|
||||||
audio.currentTime=0;
|
audio.currentTime=0;
|
||||||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
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};
|
$('#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)};
|
$('#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)};
|
$('#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(){
|
(function restore(){
|
||||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
if(r){$('#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>`+
|
`<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.
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||||
// Yours already: the row opens it instead.
|
// Yours already: the row opens it instead.
|
||||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
||||||
$('[data-a="sub"]',el).onclick=async()=>{
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
@@ -1585,17 +1681,42 @@ async function renderListed(v){
|
|||||||
$('#tbRemove').disabled=true;
|
$('#tbRemove').disabled=true;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
$('#epSearch').placeholder='Search items…';
|
$('#epSearch').placeholder='Search items…';
|
||||||
|
const listening=v===VIEWS[':popular'];
|
||||||
box.innerHTML=`
|
box.innerHTML=`
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${v.icon}</div>
|
<div class="art">${v.icon}</div>
|
||||||
<div class="meta"><h2>${v.title}</h2>
|
<div class="meta"><h2>${v.title}</h2>
|
||||||
<div class="sub">${v.blurb} Everyone counts, you included. Feeds inside an OPML
|
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
|
||||||
subscription, and private feeds, are never listed.</div></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;
|
$('#count').textContent=v.title;
|
||||||
const n=await listFeeds(v.url);
|
const n=await listFeeds(v.url);
|
||||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
|
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.
|
// 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';
|
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(){
|
async function prefsModal(){
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const gs = splitEvery(g.every_mins);
|
const gs = splitEvery(g.every_mins);
|
||||||
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<h3>Settings</h3>
|
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="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}">
|
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||||
<select id="gunit">${unitOptions(gs.u)}</select>
|
<select id="gunit">${unitOptions(gs.u)}</select>
|
||||||
</div>
|
</div>
|
||||||
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
<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>
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span>`
|
||||||
<div class="field"><label>Max new downloads per scan, per feed</label>
|
:`<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}">
|
<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
|
<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
|
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
|
<span class="hint">Over this, the oldest played items are deleted first. Kept
|
||||||
items are never touched.</span></div>
|
items are never touched.</span></div>
|
||||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
<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>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
<div class="field"><label>Subscriptions</label>
|
<div class="field"><label>Subscriptions</label>
|
||||||
@@ -1680,12 +1814,14 @@ async function prefsModal(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
subscribes you to every feed in one.</span></div>
|
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>
|
<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>
|
<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>
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
|
||||||
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
${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;
|
$('#gopml').onclick=opmlModal;
|
||||||
|
if(!admin) return;
|
||||||
$('#gusers').onclick=usersModal;
|
$('#gusers').onclick=usersModal;
|
||||||
$('#gsave').onclick=async()=>{
|
$('#gsave').onclick=async()=>{
|
||||||
try{
|
try{
|
||||||
@@ -1706,7 +1842,9 @@ async function usersModal(){
|
|||||||
const users = await api('/api/users') || [];
|
const users = await api('/api/users') || [];
|
||||||
openModal(`<h3>Users</h3>
|
openModal(`<h3>Users</h3>
|
||||||
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
|
${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>'}
|
${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>
|
<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('')}
|
<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);
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
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
|
${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>
|
<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="field"><label>Feed URL</label>
|
||||||
<div class="inline">
|
<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>
|
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="hint">${S.me&&S.me.admin
|
<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.
|
reading it keeps it, along with their own read state.
|
||||||
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button></div>`);
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||||
$('#rgo').onclick=async()=>{
|
$('#rgo').onclick=async()=>{
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||||
closeModal(); toast('Unsubscribed'); S.feed=null;
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||||
@@ -1865,13 +2003,16 @@ function opmlModal(){
|
|||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#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=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
|
// The log names every account and every failed sign-in; that alone is the operator's
|
||||||
// them too; hiding the buttons just stops offering what would fail.
|
// business, and the server refuses it from anyone else. Settings stays -- it opens the
|
||||||
if(!u.admin) $('#admintools').hidden=true;
|
// same modal for everyone, just without the admin-only fields (see prefsModal).
|
||||||
|
if(!u.admin) $('#logs').hidden=true;
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
$('#logs').onclick=logsModal;
|
$('#logs').onclick=logsModal;
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
@@ -1888,14 +2029,18 @@ $('#feedlist').onkeydown=ev=>{
|
|||||||
};
|
};
|
||||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
$('#scrim').onclick=()=>nav(false);
|
$('#scrim').onclick=()=>nav(false);
|
||||||
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
|
// Dark, light, the 2004 Mac app, and following the system. The header button steps through
|
||||||
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'};
|
// 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 THEME_ORDER=Object.keys(THEMES);
|
||||||
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
|
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
|
||||||
function setTheme(t){
|
function setTheme(t){
|
||||||
if(!THEMES[t]) t='dark';
|
if(!THEMES[t]) t='dark';
|
||||||
document.documentElement.dataset.theme=t;
|
document.documentElement.dataset.theme=t;
|
||||||
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(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{}
|
try{ localStorage.setItem('ipx.theme',t); }catch{}
|
||||||
}
|
}
|
||||||
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));
|
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));
|
||||||
|
|||||||
Reference in New Issue
Block a user