19 Commits
v0.8.1 ... main

Author SHA1 Message Date
380a552913 Share a feed, an item or a file (#48)
A share button in the feed's header, beside an item's "Open the original", and on each file.
It uses the Web Share API where the browser has it, which is the share sheet on a phone, and
copies the link where it does not. A file is shared by the publisher's address, not /media/,
which only someone signed in here can open.

Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one
gives the subscription away. An address that looks like that asks first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:17:52 +00:00
bdda9b3d2e Block lists: words that hide items and keep them from downloading (#47)
Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:03 +00:00
868dd673f3 Swipes take a longer drag and slide the reader across (#49)
At a flat 60px, a thumb scrolling slightly on the diagonal moved on to the next item by
accident. A swipe now has to cover a quarter of the reader's width, and never less than 100px,
and the reader follows the finger while it is down, so it is plain before letting go whether it
will move on. It slides off on a swipe and the next item slides in from the other side; a short
one springs back. The CHANGELOG also gets back the [0.8.3] link a stray edit had broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:04:30 +00:00
2930be37ad Release 0.8.4
The Glass theme (#43), playback handed to a native shell (#45), and the
bottom bar kept clear of the home indicator (#46), which had no changelog
entry of its own. The unreleased compare link had been left at v0.6.1 since
0.7.0; it points at the new tag now.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:24:56 +00:00
36 changed files with 1837 additions and 166 deletions

View File

@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
list of its own; an item with one of those words or phrases in its title or text is hidden
from you and is not downloaded on your account. Other people's lists never hide anything from
you.
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
elsewhere they copy the link. A link that looks like it carries your own access to a paid
feed asks before it goes anywhere.
### Fixed
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
across as it changes.
## [0.8.4] - 2026-09-19
### Added
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
goes solid when the system asks for less transparency or more contrast.
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
plays through the host's own player instead of the page's, so it keeps going when the screen
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
work as they always did. Video still plays in the page. In a browser nothing changes.
### Fixed
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
home screen. In landscape the bars keep clear of the notch as well.
## [0.8.3] - 2026-09-19
### Security
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
says where the token is kept instead: `[web] token` in config.toml.
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
the email header. Without it, anything on the same Docker host as ipx could send the header. See
docs/sso.md.
### Fixed
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
now on have it.
## [0.8.2] - 2026-09-19
### Added
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
contrast, black and white with every colour at 7:1 or more.
### Changed
- The themes in Settings are listed in alphabetical order.
### Fixed
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
read comfortably. A failed-action message is easier to read in every theme.
## [0.8.1] - 2026-09-19 ## [0.8.1] - 2026-09-19
### Changed ### Changed
@@ -523,7 +588,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout. - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main [unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1 [0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0 [0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0 [0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0

View File

@@ -18,11 +18,31 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` | | Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` | | Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | | Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway | | Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`. `TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
`main` and pushed, comment on the issue with what changed and the commit, then close it:
```sh
R="--login git.sdf1.net --repo rays/ipodderx-rs"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
t issues close $R 40
```
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
for later stays open, and that is how it gets picked up again.
Deploying a change is: build and push the image, then pull it and recreate the container. Deploying a change is: build and push the image, then pull it and recreate the container.
```sh ```sh
@@ -57,8 +77,10 @@ container restarts on its own after a reboot.
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run `/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
the shell running the command and kills the session (exit 144). This has happened more than once. `kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
running the command and kills the session (exit 144), which has happened more than once.
## Before you touch the page ## Before you touch the page
@@ -100,6 +122,8 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src npx tsc -p . # type-checks web/src
node tests/page-smoke.js node tests/page-smoke.js
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 40 browser tests against a real daemon on fixture feeds npx playwright test # 40 browser tests against a real daemon on fixture feeds
``` ```
@@ -182,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
## Known gaps ## Known gaps
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies` * They are the open issues in Gitea, not a list here: a limitation known and left in place is an
(documented in [docs/sso.md](docs/sso.md)). issue left open.
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
Substack-style subtitles.
<!-- rtk-instructions v2 --> <!-- rtk-instructions v2 -->
# Command output # Command output

42
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.8.1" version = "0.8.4"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
@@ -1830,6 +1830,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"futures-util", "futures-util",
"jsonwebtoken",
"librqbit", "librqbit",
"opml", "opml",
"percent-encoding", "percent-encoding",
@@ -2007,6 +2008,22 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"serde",
"serde_json",
"signature",
"zeroize",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -3786,6 +3803,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -5183,6 +5209,20 @@ name = "zeroize"
version = "1.9.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.8.1" version = "0.8.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -12,6 +12,7 @@ axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"

View File

@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
```sh ```sh
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js # the page script loads without throwing node tests/page-smoke.js # the page script loads without throwing
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds npx playwright test # a real browser against a real daemon on fixture feeds
``` ```

View File

@@ -141,6 +141,7 @@ before they reach the page.
```sh ```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
node tests/page-smoke.js # the page script loads and every selector it wires at load exists node tests/page-smoke.js # the page script loads and every selector it wires at load exists
node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds npx playwright test # a real browser against a real daemon on fixture feeds
``` ```

View File

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

View File

@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
token = "" # generated and saved on first run token = "" # generated and saved on first run
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email" trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
trusted_proxies = ["127.0.0.1", "::1"] trusted_proxies = ["127.0.0.1", "::1"]
access_team = "" # e.g. "<team>.cloudflareaccess.com"
access_aud = "" # the Access application's AUD tag
auto_create_users = true auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout" sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30 session_days = 30
@@ -94,6 +96,9 @@ session_days = 30
disables that path. See [sso.md](sso.md). disables that path. See [sso.md](sso.md).
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary * **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
for it. Name the proxy, never a subnet. for it. Name the proxy, never a subnet.
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name. * **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out, * **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where

View File

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

215
src/access.rs Normal file
View File

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

View File

@@ -78,6 +78,14 @@ pub struct Web {
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could /// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon. /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>, pub trusted_proxies: Vec<String>,
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
/// host any container can send one from the gateway's address.
pub access_team: String,
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
/// leaves the signature unchecked.
pub access_aud: String,
/// Create an account the first time the proxy vouches for a name it has not seen. /// Create an account the first time the proxy vouches for a name it has not seen.
pub auto_create_users: bool, pub auto_create_users: bool,
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick /// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
@@ -96,6 +104,8 @@ impl Default for Web {
token: String::new(), token: String::new(),
trusted_header: String::new(), trusted_header: String::new(),
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
access_team: String::new(),
access_aud: String::new(),
auto_create_users: true, auto_create_users: true,
sign_out_url: String::new(), sign_out_url: String::new(),
session_days: 30, session_days: 30,
@@ -107,6 +117,12 @@ impl Web {
pub fn binds_publicly(&self) -> bool { pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost") !self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
} }
/// Both halves of the Access check, or None while either is unset.
pub fn access(&self) -> Option<(&str, &str)> {
(!self.access_team.is_empty() && !self.access_aud.is_empty())
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
}
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]

199
src/db.rs
View File

@@ -2,7 +2,7 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat. //! per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::entity::{catalogue, enclosures, entries, feeds, sessions, settings, subscriptions, users}; use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
use sea_orm::sea_query::{Expr, Func}; use sea_orm::sea_query::{Expr, Func};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -57,6 +57,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(sessions::Entity), schema.create_table_from_entity(sessions::Entity),
schema.create_table_from_entity(catalogue::Entity), schema.create_table_from_entity(catalogue::Entity),
schema.create_table_from_entity(settings::Entity), schema.create_table_from_entity(settings::Entity),
schema.create_table_from_entity(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity),
] { ] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?; orm.execute(table.if_not_exists()).await.context("creating the schema")?;
} }
@@ -156,6 +158,10 @@ pub struct Sub {
pub auto_download: Option<bool>, pub auto_download: Option<bool>,
pub allow_explicit: Option<bool>, pub allow_explicit: Option<bool>,
pub max_new_per_check: Option<i64>, pub max_new_per_check: Option<i64>,
/// Words that keep an item from being downloaded for this person: their list for every feed
/// and their list for this one together. Filled by `subscribers` only; it is kept apart from
/// the settings above (`Db::set_blocklist`).
pub blocked: Vec<String>,
} }
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives /// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
@@ -200,6 +206,7 @@ impl From<subscriptions::Model> for Sub {
auto_download: s.auto_download, auto_download: s.auto_download,
allow_explicit: s.allow_explicit, allow_explicit: s.allow_explicit,
max_new_per_check: s.max_new_per_check, max_new_per_check: s.max_new_per_check,
blocked: vec![],
} }
} }
} }
@@ -620,8 +627,11 @@ impl Db {
FROM enclosures e FROM enclosures e
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
ON subs.feed_id = e.feed_id ON subs.feed_id = e.feed_id
LEFT JOIN (SELECT feed_id, guid, count(*) AS n FROM entry_state -- Someone an item is hidden from is as done with it as someone who read it.
WHERE read GROUP BY feed_id, guid) readers LEFT JOIN (SELECT feed_id, guid, count(*) AS n
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
UNION SELECT user_id, feed_id, guid FROM hidden) done
GROUP BY feed_id, guid) readers
ON readers.feed_id = e.feed_id AND readers.guid = e.guid ON readers.feed_id = e.feed_id AND readers.guid = e.guid
WHERE e.path IS NOT NULL WHERE e.path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entry_state s AND NOT EXISTS (SELECT 1 FROM entry_state s
@@ -683,13 +693,17 @@ impl Db {
.await?; .await?;
// Whatever went takes everyone's read state with it, rather than leaving rows // Whatever went takes everyone's read state with it, rather than leaving rows
// pointing at an item that no longer exists. // pointing at an item that no longer exists.
self.exec( for table in ["entry_state", "hidden"] {
"DELETE FROM entry_state WHERE NOT EXISTS ( self.exec(
SELECT 1 FROM entries e &format!(
WHERE e.feed_id = entry_state.feed_id AND e.guid = entry_state.guid)", "DELETE FROM {table} WHERE NOT EXISTS (
vec![], SELECT 1 FROM entries e
) WHERE e.feed_id = {table}.feed_id AND e.guid = {table}.guid)"
.await?; ),
vec![],
)
.await?;
}
Ok(n as usize) Ok(n as usize)
} }
} }
@@ -701,7 +715,9 @@ impl Db {
"SELECT count(*) AS n FROM entries e "SELECT count(*) AS n FROM entries e
LEFT JOIN entry_state s LEFT JOIN entry_state s
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)", WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = $2 AND h.feed_id = e.feed_id AND h.guid = e.guid)",
vec![feed_id.into(), user_id.into()], vec![feed_id.into(), user_id.into()],
) )
.await?; .await?;
@@ -776,8 +792,16 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
format!( format!(
"FROM entries e "FROM entries e
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE {scope} AND {} AND {search}", WHERE {scope} AND {} AND {search} AND {}",
filter.sql() filter.sql(),
not_hidden(&user)
)
}
/// Not hidden from this person by their block lists. `e` is entries.
fn not_hidden(user: &str) -> String {
format!(
"NOT EXISTS (SELECT 1 FROM hidden h WHERE h.user_id = {user} AND h.feed_id = e.feed_id AND h.guid = e.guid)"
) )
} }
@@ -1079,6 +1103,8 @@ impl Db {
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?), ("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?), ("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?), ("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
("blocklists", copy_table::<blocklists::Entity>(&from.orm, &tx).await?),
("hidden", copy_table::<hidden::Entity>(&from.orm, &tx).await?),
]; ];
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres { if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
// The copied ids came with the rows; the counters that hand out new ones start past // The copied ids came with the rows; the counters that hand out new ones start past
@@ -1166,9 +1192,12 @@ impl Db {
"SELECT coalesce(c.keywords, p.keywords) AS keywords, "SELECT coalesce(c.keywords, p.keywords) AS keywords,
coalesce(c.auto_download, p.auto_download) AS auto_download, coalesce(c.auto_download, p.auto_download) AS auto_download,
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit, coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check,
g.words AS global_blocked, b.words AS blocked
FROM subscriptions c FROM subscriptions c
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2 LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
LEFT JOIN blocklists g ON g.user_id = c.user_id AND g.feed_id = ''
LEFT JOIN blocklists b ON b.user_id = c.user_id AND b.feed_id = c.feed_id
WHERE c.feed_id = $1", WHERE c.feed_id = $1",
vec![feed_id.into(), group.map(str::to_owned).into()], vec![feed_id.into(), group.map(str::to_owned).into()],
) )
@@ -1181,6 +1210,10 @@ impl Db {
auto_download: r.try_get("", "auto_download")?, auto_download: r.try_get("", "auto_download")?,
allow_explicit: r.try_get("", "allow_explicit")?, allow_explicit: r.try_get("", "allow_explicit")?,
max_new_per_check: r.try_get("", "max_new_per_check")?, max_new_per_check: r.try_get("", "max_new_per_check")?,
blocked: [r.try_get("", "global_blocked")?, r.try_get("", "blocked")?]
.into_iter()
.flat_map(|w| keywords(w).unwrap_or_default())
.collect(),
}) })
}) })
.collect() .collect()
@@ -1217,11 +1250,13 @@ impl Db {
let r = self let r = self
.rows( .rows(
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred, "SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
sum(CASE WHEN st.read THEN 0 ELSE 1 END) AS unread sum(CASE WHEN st.read OR h.guid IS NOT NULL THEN 0 ELSE 1 END) AS unread
FROM enclosures e FROM enclosures e
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2 JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
LEFT JOIN entry_state st LEFT JOIN entry_state st
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
LEFT JOIN hidden h
ON h.user_id = s.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid
WHERE e.id = $1", WHERE e.id = $1",
vec![enclosure_id.into(), user_id.into()], vec![enclosure_id.into(), user_id.into()],
) )
@@ -1234,11 +1269,99 @@ impl Db {
} }
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> { pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
self.exec( let new = self
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", .exec(
vec![user_id.into(), feed_id.into()], "INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
) vec![user_id.into(), feed_id.into()],
.await?; )
.await?;
if new > 0 {
// Their list for every feed applies to this one from the start.
self.rehide(feed_id).await?;
}
Ok(())
}
/// Someone's block list: for one feed, or with `feed_id` empty, for every feed.
pub async fn blocklist(&self, user_id: i64, feed_id: &str) -> Result<Vec<String>> {
Ok(blocklists::Entity::find_by_id((user_id, feed_id.to_owned()))
.one(&self.orm)
.await?
.and_then(|b| keywords(Some(b.words)))
.unwrap_or_default())
}
/// Replaces a block list and works out again what it hides: in that feed, or with
/// `feed_id` empty, in every feed the person reads.
pub async fn set_blocklist(&self, user_id: i64, feed_id: &str, words: &[String]) -> Result<()> {
if words.is_empty() {
blocklists::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
} else {
self.exec(
"INSERT INTO blocklists (user_id, feed_id, words) VALUES ($1, $2, $3)
ON CONFLICT (user_id, feed_id) DO UPDATE SET words = excluded.words",
vec![user_id.into(), feed_id.into(), serde_json::to_string(words)?.into()],
)
.await?;
}
if feed_id.is_empty() {
for sub in self.subscriptions_for(user_id).await? {
self.rehide(&sub.feed_id).await?;
}
} else {
self.rehide(feed_id).await?;
}
Ok(())
}
/// Works out again which of a feed's items each subscriber's block lists hide from them.
///
/// ponytail: every item of the feed for every subscriber, each time the feed is scanned with
/// something new or a list changes. Fine at hundreds of items a feed; look only at new items
/// on a scan if a feed with thousands makes scans slow.
pub async fn rehide(&self, feed_id: &str) -> Result<()> {
let lists: Vec<(i64, Vec<String>)> = self
.rows(
"SELECT s.user_id, g.words AS global, b.words AS feed
FROM subscriptions s
LEFT JOIN blocklists g ON g.user_id = s.user_id AND g.feed_id = ''
LEFT JOIN blocklists b ON b.user_id = s.user_id AND b.feed_id = s.feed_id
WHERE s.feed_id = $1",
vec![feed_id.into()],
)
.await?
.iter()
.map(|r| {
let words = [r.try_get("", "global")?, r.try_get("", "feed")?]
.into_iter()
.flat_map(|w: Option<String>| keywords(w).unwrap_or_default())
.collect();
Ok((r.try_get("", "user_id")?, words))
})
.collect::<Result<_>>()?;
hidden::Entity::delete_many().filter(hidden::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
let lists: Vec<_> = lists.into_iter().filter(|(_, w)| !w.is_empty()).collect();
if lists.is_empty() {
return Ok(());
}
let items = entries::Entity::find().filter(entries::Column::FeedId.eq(feed_id)).all(&self.orm).await?;
let rows: Vec<hidden::ActiveModel> = items
.iter()
.flat_map(|e| {
let hay = [e.title.as_deref().unwrap_or(""), e.description.as_deref().unwrap_or("")];
lists
.iter()
.filter(move |(_, words)| crate::download::blocked(words, &hay))
.map(|(user, _)| hidden::ActiveModel {
user_id: Set(*user),
feed_id: Set(feed_id.to_owned()),
guid: Set(e.guid.clone()),
})
})
.collect();
if !rows.is_empty() {
hidden::Entity::insert_many(rows).exec_without_returning(&self.orm).await?;
}
Ok(()) Ok(())
} }
@@ -2050,6 +2173,42 @@ mod tests {
assert_eq!(listening().await, ["h", "e"]); assert_eq!(listening().await, ["h", "e"]);
} }
#[tokio::test]
async fn block_lists_hide_items_from_whoever_keeps_them() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (2,'f');
INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','x','Election night','',1),
('f','y','Baking bread','<p>No politics here, honest</p>',2),
('f','z','Gardening','',3);",
)
.await
.unwrap();
let shown = async |user| db.count_in(user, Some("f"), Filter::All, None).await.unwrap();
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
db.set_blocklist(1, "", &words(&["election"])).await.unwrap();
db.set_blocklist(1, "f", &words(&["politics"])).await.unwrap();
assert_eq!(shown(1).await, 1, "a title and a body each hide an item, both lists apply");
assert_eq!(db.unread_count(1, "f").await.unwrap(), 1, "hidden is not unread");
assert_eq!(shown(2).await, 3, "someone else's list hides nothing from you");
db.set_blocklist(1, "", &[]).await.unwrap();
assert_eq!(shown(1).await, 2, "emptying a list brings its items back");
assert!(db.blocklist(1, "").await.unwrap().is_empty());
// Someone subscribing starts with their list applied, and the scanner sees it.
db.set_blocklist(2, "", &words(&["gardening"])).await.unwrap();
db.exec_for_test("INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('g','w','Gardening',4)")
.await
.unwrap();
db.subscribe(2, "g").await.unwrap();
assert_eq!(db.count_in(2, Some("g"), Filter::All, None).await.unwrap(), 0);
assert_eq!(db.subscribers("g", None).await.unwrap()[0].blocked, ["gardening"]);
}
#[tokio::test] #[tokio::test]
async fn pending_takes_the_latest_episodes_first() { async fn pending_takes_the_latest_episodes_first() {
// A cap of 3 must mean the three newest, not the three recorded first. // A cap of 3 must mean the three newest, not the three recorded first.

View File

@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
}) })
} }
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
/// "Trump's" is by "trump".
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
// Letters and digits only, each run of anything else one space, padded so a phrase matches
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
let norm = |s: &str| {
let mut out = String::from(" ");
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
out.push_str(&w.to_lowercase());
out.push(' ');
}
out
};
let hay = norm(&haystacks.join(" "));
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -404,4 +423,16 @@ mod tests {
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match"); assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything"); assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
} }
#[test]
fn blocking_matches_whole_words_and_phrases() {
let words = vec!["AI".to_string(), "new york".to_string()];
assert!(blocked(&words, &["What AI means now"]));
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
assert!(!blocked(&words, &["New Yorkshire"]));
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
} }

View File

@@ -228,6 +228,46 @@ pub mod entry_state {
owned_by_user!(); owned_by_user!();
} }
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
/// every feed they read.
pub mod blocklists {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "blocklists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub words: String,
}
owned_by_user!();
}
/// The items someone's block lists hide from them, worked out whenever a list or the feed
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
pub mod hidden {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "hidden")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
}
owned_by_user!();
}
pub mod sessions { pub mod sessions {
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;

View File

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

View File

@@ -1,3 +1,4 @@
mod access;
mod auth; mod auth;
mod config; mod config;
mod db; mod db;
@@ -550,11 +551,16 @@ async fn start_web(
// The token is config.toml's, not the database's: it decides who gets in. // The token is config.toml's, not the database's: it decides who gets in.
fresh.save_bootstrap(config_path)?; fresh.save_bootstrap(config_path)?;
ctx.set_cfg(fresh.clone()); ctx.set_cfg(fresh.clone());
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token); // The token signs in as the admin, and whatever reads this process's output (docker logs,
// for one) is wider than who reads config.toml. So say where it is, never what it is.
println!(
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
config_path.display()
);
} else { } else {
println!( println!(
"web ui at http://{bind}/?token={}", "web ui at http://{bind}/ (the sign-in token is [web] token in {})",
ctx.cfg().web.token config_path.display()
); );
} }
@@ -562,10 +568,16 @@ async fn start_web(
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it"); tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
} }
let access = Arc::new(access::Keys::default());
if let Some((team, _)) = ctx.cfg().web.access() {
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
}
let state = web::WebState { let state = web::WebState {
ctx: ctx.clone(), ctx: ctx.clone(),
cmds: cmds.clone(), cmds: cmds.clone(),
events: events.clone(), events: events.clone(),
access,
}; };
Ok(Some(tokio::spawn(async move { Ok(Some(tokio::spawn(async move {
if let Err(e) = web::serve(state, &bind).await { if let Err(e) = web::serve(state, &bind).await {
@@ -1242,6 +1254,10 @@ async fn scan_one(
} }
} }
if scan.new_entries > 0 {
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
}
let budget = policy.budget; let budget = policy.budget;
if policy.auto_download && budget > 0 { if policy.auto_download && budget > 0 {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
@@ -1446,15 +1462,10 @@ fn reject(
entry.description.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or(""),
categories.as_str(), categories.as_str(),
]; ];
// One file serves everyone subscribed, so an item is wanted if it is wanted by let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
// anyone: any one person's keyword set matching is enough. if !policy.wanted(&haystacks, &text) {
let wanted_by_someone = policy.keyword_sets.is_empty() // Worded for whichever filter could have let it through, the keywords if none blocked it.
|| policy return Some(if policy.wanted(&haystacks, &[]) { "blocked word" } else { "no keyword match" });
.keyword_sets
.iter()
.any(|set| download::matches_keywords(set, &haystacks));
if !wanted_by_someone {
return Some("no keyword match");
} }
None None
} }
@@ -1468,11 +1479,28 @@ fn reject(
pub struct Policy { pub struct Policy {
pub auto_download: bool, pub auto_download: bool,
pub allow_explicit: bool, pub allow_explicit: bool,
/// Empty means take everything. Otherwise one set per subscriber who filters. /// What each subscriber fetching the feed wants.
pub keyword_sets: Vec<Vec<String>>, pub wants: Vec<Want>,
pub budget: usize, pub budget: usize,
} }
/// One person's filters: an item is theirs if it matches their keywords (all of it, with none)
/// and none of their blocked words.
pub struct Want {
pub keywords: Vec<String>,
pub blocked: Vec<String>,
}
impl Policy {
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
self.wants.iter().any(|w| {
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
})
}
}
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> { async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
let global = ctx.cfg().general.max_new_per_check; let global = ctx.cfg().general.max_new_per_check;
Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global)) Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global))
@@ -1485,11 +1513,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
return Policy { return Policy {
auto_download: feed_cfg.auto_download, auto_download: feed_cfg.auto_download,
allow_explicit: feed_cfg.allow_explicit, allow_explicit: feed_cfg.allow_explicit,
keyword_sets: if feed_cfg.keywords.is_empty() { wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
vec![]
} else {
vec![feed_cfg.keywords.clone()]
},
budget: cap(feed_cfg.max_new_per_check), budget: cap(feed_cfg.max_new_per_check),
}; };
} }
@@ -1497,7 +1521,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
let mut policy = Policy { let mut policy = Policy {
auto_download: false, auto_download: false,
allow_explicit: false, allow_explicit: false,
keyword_sets: vec![], wants: vec![],
budget: 0, budget: 0,
}; };
for sub in subs { for sub in subs {
@@ -1509,12 +1533,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
policy.budget = policy policy.budget = policy
.budget .budget
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check))); .max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
let kw = sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()); policy.wants.push(Want {
if kw.is_empty() { keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
// Somebody takes everything, so no filter can apply to the shared copy. blocked: sub.blocked.clone(),
return Policy { keyword_sets: vec![], ..policy }; });
}
policy.keyword_sets.push(kw);
} }
policy policy
} }
@@ -1783,6 +1805,7 @@ mod tests {
auto_download: auto, auto_download: auto,
allow_explicit: None, allow_explicit: None,
max_new_per_check: max, max_new_per_check: max,
blocked: vec![],
} }
} }
@@ -1792,7 +1815,7 @@ mod tests {
let p = merge_policy(&[], &feed(), 3); let p = merge_policy(&[], &feed(), 3);
assert!(p.auto_download); assert!(p.auto_download);
assert_eq!(p.budget, 3); assert_eq!(p.budget, 3);
assert!(p.keyword_sets.is_empty()); assert!(p.wanted(&["anything"], &[]));
// Two filters: an item wanted by either of them is fetched, since one file serves // Two filters: an item wanted by either of them is fetched, since one file serves
// both. The larger per-scan cap wins for the same reason. // both. The larger per-scan cap wins for the same reason.
@@ -1801,12 +1824,21 @@ mod tests {
&feed(), &feed(),
3, 3,
); );
assert_eq!(p.keyword_sets.len(), 2); assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
assert!(!p.wanted(&["python"], &[]));
assert_eq!(p.budget, 9); assert_eq!(p.budget, 9);
// One person taking everything removes the filter for the shared copy. // One person taking everything removes the filter for the shared copy.
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3); let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
assert!(p.keyword_sets.is_empty()); assert!(p.wanted(&["python"], &[]));
// A blocked word keeps an item from being fetched for that person, not for the rest.
let blocking = |w: &str| db::Sub { blocked: vec![w.into()], ..sub(None, None, None) };
let p = merge_policy(&[blocking("politics")], &feed(), 3);
assert!(!p.wanted(&[], &["Politics today"]));
assert!(p.wanted(&[], &["Cooking today"]));
let p = merge_policy(&[blocking("politics"), sub(None, None, None)], &feed(), 3);
assert!(p.wanted(&[], &["Politics today"]), "someone else still wants it");
// Everyone has auto-download off: nothing is fetched automatically. // Everyone has auto-download off: nothing is fetched automatically.
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3); let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);

View File

@@ -29,6 +29,8 @@ pub struct WebState {
pub ctx: Arc<Ctx>, pub ctx: Arc<Ctx>,
pub cmds: mpsc::Sender<Command>, pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>, pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
} }
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
@@ -99,7 +101,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let token = cfg.web.token.clone(); let token = cfg.web.token.clone();
// 1. A header, but only from a hop we were told to believe. // 1. A header, but only from a hop we were told to believe.
let vouched = vouched_name(&cfg, &req); let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
let mut set_cookie: Option<String> = None; let mut set_cookie: Option<String> = None;
let mut user = None; let mut user = None;
@@ -201,20 +203,31 @@ struct Proxied(bool);
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and /// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and /// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
/// be whoever they liked. /// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> { /// token Cloudflare Access signed, and the name is the one in the token.
let peer = req ///
.extensions() /// Takes the request's parts rather than the request: a `&Request` held across the await makes
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() /// the future unsendable, as a body is not `Sync`.
.map(|c| c.0.ip().to_string()) async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
.unwrap_or_default();
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) { if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return None; return None;
} }
req.headers() let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
.get(&cfg.web.trusted_header) let Some((team, aud)) = cfg.web.access() else {
.and_then(|v| v.to_str().ok()) return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
.and_then(crate::auth::name_from_header) };
// The plain header still has to be there, as it is what switches this path on for a
// request; who it names is the token's to say.
header(&cfg.web.trusted_header)?;
let token = header("Cf-Access-Jwt-Assertion")?;
state.access.verify(&state.ctx.client, team, aud, token).await
}
fn peer(req: &Request) -> String {
req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default()
} }
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing /// Handlers take `User` to say they need one; the auth layer put it there, and nothing
@@ -319,29 +332,40 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone(); let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url); let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default(); let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
Json(serde_json::json!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode, "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
"blocked": blocked,
})) }))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct MePatch { struct MePatch {
theme: String, theme: Option<String>,
mode: String, mode: Option<String>,
/// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>,
} }
/// Saves the theme to the account, so it follows the person rather than the browser. /// Saves the theme to the account, so it follows the person rather than the browser, and the
/// block list for every feed.
async fn patch_me( async fn patch_me(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
Json(body): Json<MePatch>, Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// The page's script knows the themes; this only makes sure what is kept is safe to write if body.theme.is_some() || body.mode.is_some() {
// into the page's <html> tag, which is where index() puts it. let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
if !theme_ok(&body.theme, &body.mode) { // The page's script knows the themes; this only makes sure what is kept is safe to write
return Err(ApiError::bad_request("not a theme")); // into the page's <html> tag, which is where index() puts it.
if !theme_ok(&theme, &mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
}
if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
} }
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -474,7 +498,7 @@ async fn remove_user(
/// The password form, except for someone the proxy vouches for: they are signed in already, and /// The password form, except for someone the proxy vouches for: they are signed in already, and
/// the form only made it look as if they were not. /// the form only made it look as if they were not.
async fn login_page(State(state): State<WebState>, req: Request) -> Response { async fn login_page(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state.ctx.cfg(), &req).is_some() { if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html")))) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
@@ -626,6 +650,8 @@ struct FeedRow {
category: Option<String>, category: Option<String>,
feed_category: Option<String>, feed_category: Option<String>,
keywords: Vec<String>, keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>,
allow_explicit: bool, allow_explicit: bool,
auto_download: bool, auto_download: bool,
max_new_per_check: Option<usize>, max_new_per_check: Option<usize>,
@@ -702,6 +728,7 @@ async fn feeds(
.clone() .clone()
.or_else(|| up.and_then(|u| u.keywords.clone())) .or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()), .unwrap_or_else(|| feed.keywords.clone()),
blocked: state.ctx.db.blocklist(user.id, id).await?,
allow_explicit: mine allow_explicit: mine
.allow_explicit .allow_explicit
.or(up.and_then(|u| u.allow_explicit)) .or(up.and_then(|u| u.allow_explicit))
@@ -1259,6 +1286,7 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>, category: Option<Option<String>>,
keywords: Option<Vec<String>>, keywords: Option<Vec<String>>,
blocked: Option<Vec<String>>,
allow_explicit: Option<bool>, allow_explicit: Option<bool>,
auto_download: Option<bool>, auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")] #[serde(default, deserialize_with = "double_option")]
@@ -1266,6 +1294,21 @@ struct FeedPatch {
pinned: Option<bool>, pinned: Option<bool>,
} }
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
/// looked for in every item of every feed the list covers.
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
let mut out: Vec<String> = Vec::new();
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
out.push(w.to_owned());
}
}
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
}
Ok(out)
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error> fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where where
T: Deserialize<'de>, T: Deserialize<'de>,
@@ -1314,6 +1357,9 @@ async fn patch_feed(
if touched { if touched {
state.ctx.db.set_subscription(user.id, &mine).await?; state.ctx.db.set_subscription(user.id, &mine).await?;
} }
if let Some(v) = body.blocked.clone() {
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
}
} }
// The rest describes the feed itself -- where its files land, its address, when it is // The rest describes the feed itself -- where its files land, its address, when it is

96
tests/contrast.js Normal file
View File

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

Binary file not shown.

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

Binary file not shown.

View File

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

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

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

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

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

View File

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

View File

@@ -1231,6 +1231,9 @@ test.describe('touch gestures on a phone', () => {
const shown = page.locator('#detail .dt'); const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]); await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
await expect(shown).toHaveText(titles[0]);
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]); await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
@@ -1341,3 +1344,33 @@ test('"check every feed" checks only the feeds of the person asking', async ({ b
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id); for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close(); await ctx.close();
}); });
test('words in Settings hide the items that mention them', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
await page.locator('#prefs').click();
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#sblock').press('Tab');
expect((await saved).status()).toBe(204);
await page.locator('#modal .cardx').click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
});
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await page.locator('#content .acts [data-a="share"]').click();
await page.locator('.ep', { hasText: 'Second Episode' }).click();
await page.locator('.encbox [data-a="share"]').first().click();
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
]);
});

View File

@@ -12,7 +12,7 @@
--line:#2c3849; --line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */ --faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */ --accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */ --ink:#0e131b; /* text on an accent fill */
@@ -239,6 +239,151 @@
--bad:#9c3f49; --bad:#9c3f49;
--shadow:0 8px 28px rgba(46,52,64,.15); --shadow:0 8px 28px rgba(46,52,64,.15);
} }
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
and peach for the rest. */
:root[data-theme="catppuccin"] {
--bg:#1e1e2e; /* base */
--panel:#181825; /* mantle */
--panel2:#313244; /* surface0 */
--raise:#45475a; /* surface1 */
--line:#45475a;
--fg:#cdd6f4; /* text */
--dim:#bac2de; /* subtext1 */
--faint:#a6adc8; /* subtext0 */
--accent:#cba6f7; /* mauve */
--accent2:#fab387; /* peach */
--ink:#11111b; /* crust */
--good:#a6e3a1;
--warn:#f9e2af;
--bad:#f38ba8;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="catppuccin"][data-mode="light"] {
--bg:#eff1f5; /* base */
--panel:#e6e9ef; /* mantle */
--panel2:#dce0e8; /* crust */
--raise:#ccd0da; /* surface0 */
--line:#bcc0cc; /* surface1 */
--fg:#4c4f69; /* text */
--dim:#5c5f77; /* subtext1 */
--faint:#5f6276; /* subtext0 */
--accent:#8638ec; /* mauve */
--accent2:#c74e09; /* peach */
--ink:#ffffff;
--good:#3a9127;
--warn:#925d13;
--bad:#d00f39;
--shadow:0 8px 28px rgba(76,79,105,.15);
}
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
half as gruvbox itself does. Blue for the accent, orange for what is new. */
:root[data-theme="gruvbox"] {
--bg:#282828; /* bg0 */
--panel:#1d2021; /* bg0_h */
--panel2:#32302f; /* bg0_s */
--raise:#3c3836; /* bg1 */
--line:#504945; /* bg2 */
--fg:#ebdbb2; /* fg */
--dim:#d5c4a1; /* fg2 */
--faint:#a89984; /* fg4 */
--accent:#83a598; /* blue */
--accent2:#fe8019; /* orange */
--ink:#1d2021;
--good:#b8bb26;
--warn:#fabd2f;
--bad:#fb533f;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="gruvbox"][data-mode="light"] {
--bg:#fbf1c7; /* bg0 */
--panel:#f2e5bc; /* bg0_s */
--panel2:#ebdbb2; /* bg1 */
--raise:#d5c4a1; /* bg2 */
--line:#d5c4a1;
--fg:#3c3836; /* fg */
--dim:#504945; /* fg2 */
--faint:#665c54; /* fg3 */
--accent:#076678; /* faded blue */
--accent2:#af3a03; /* faded orange */
--ink:#fbf1c7;
--good:#79740e;
--warn:#8d5c10;
--bad:#9d0006;
--shadow:0 8px 28px rgba(60,56,54,.15);
}
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
:root[data-theme="solarized"] {
--bg:#002b36; /* base03 */
--panel:#073642; /* base02 */
--panel2:#0b3f4c;
--raise:#124a58;
--line:#1d5563;
--fg:#eee8d5; /* base2 */
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
--accent:#4b9fda; /* blue */
--accent2:#b58900; /* yellow */
--ink:#002b36;
--good:#859900;
--warn:#bb9315;
--bad:#e87674;
--shadow:0 8px 28px rgba(0,0,0,.45);
}
:root[data-theme="solarized"][data-mode="light"] {
--bg:#fdf6e3; /* base3 */
--panel:#eee8d5; /* base2 */
--panel2:#e6dfca;
--raise:#ddd5bd;
--line:#d3cab0;
--fg:#073642; /* base02 */
--dim:#52666d; /* base01 */
--faint:#54666d; /* base00 */
--accent:#1e6da5; /* blue */
--accent2:#cb4b16; /* orange */
--ink:#ffffff;
--good:#768700;
--warn:#846400;
--bad:#c52d2a;
--shadow:0 8px 28px rgba(0,43,54,.14);
}
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
that show. For reading, not for looks. */
:root[data-theme="contrast"] {
--bg:#000000;
--panel:#000000;
--panel2:#121212;
--raise:#333333;
--line:#9a9a9a;
--fg:#ffffff;
--dim:#ffffff;
--faint:#d6d6d6;
--accent:#8cc8ff;
--accent2:#ffd400;
--ink:#000000;
--good:#7ee787;
--warn:#ffd400;
--bad:#ff9a8f;
--shadow:0 0 0 1px #9a9a9a;
}
:root[data-theme="contrast"][data-mode="light"] {
--bg:#ffffff;
--panel:#ffffff;
--panel2:#f0f0f0;
--raise:#d6d6d6;
--line:#555555;
--fg:#000000;
--dim:#000000;
--faint:#303030;
--accent:#0033a0;
--accent2:#7a3d00;
--ink:#ffffff;
--good:#0a5c1c;
--warn:#6b4500;
--bad:#a30000;
--shadow:0 0 0 1px #555555;
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */ /* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] { :root[data-theme="classic"] {
color-scheme:light; color-scheme:light;
@@ -250,7 +395,7 @@
--fg:#000000; --fg:#000000;
--dim:#444444; --dim:#444444;
--faint:#666666; --faint:#666666;
--accent:#3875d7; /* Aqua selection blue */ --accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
--accent2:#2a5db0; --accent2:#2a5db0;
--ink:#ffffff; --ink:#ffffff;
--good:#237a23; --good:#237a23;
@@ -258,6 +403,53 @@
--bad:#c42b1c; --bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28); --shadow:0 4px 16px rgba(0,0,0,.28);
} }
/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured
wash show through. The hex values are what a panel reads as once blended, so the contrast check
still means something; the translucency and the wash are in the chrome at the end of the sheet. */
:root[data-theme="glass"] {
--bg:#0b0d14;
--panel:#161a24;
--panel2:#1f2430;
--raise:#2a3040;
--line:#343b4c;
--fg:#f5f5f7; /* Apple's label */
--dim:#aeaeb2; /* systemGray2 */
--faint:#98989f; /* systemGray, a shade up to clear AA over the wash */
--accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */
--accent2:#ff9f0a; /* systemOrange */
--ink:#000000;
--good:#30d158;
--warn:#ff9f0a;
--bad:#ff6961;
--shadow:0 10px 36px rgba(0,0,0,.45);
--glass-edge:rgba(255,255,255,.10);
--glass-hi:rgba(255,255,255,.14);
--wash1:rgba(64,120,255,.30);
--wash2:rgba(175,82,222,.24);
--wash3:rgba(48,176,199,.20);
}
:root[data-theme="glass"][data-mode="light"] {
--bg:#eef1f7;
--panel:#ffffff;
--panel2:#f2f4f8;
--raise:#e4e8f0;
--line:#d1d5de;
--fg:#1d1d1f;
--dim:#515154;
--faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */
--accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */
--accent2:#b25000; /* systemOrange taken down until white on it clears AA */
--ink:#ffffff;
--good:#248a3d;
--warn:#824200;
--bad:#b8000f;
--shadow:0 10px 36px rgba(30,50,90,.16);
--glass-edge:rgba(255,255,255,.65);
--glass-hi:rgba(255,255,255,.9);
--wash1:rgba(64,120,255,.22);
--wash2:rgba(175,82,222,.16);
--wash3:rgba(48,176,199,.18);
}
*{box-sizing:border-box} *{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */ /* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important} [hidden]{display:none!important}
@@ -308,8 +500,22 @@ a{color:var(--accent)}
} }
.iconbtn:hover{background:var(--raise);color:var(--fg)} .iconbtn:hover{background:var(--raise);color:var(--fg)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */ /* One toolbar across the window, as the original had: grouped buttons, search on the right. */
/* The display's own intrusions -- the home indicator along the bottom, the notch at one side in
landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
padding. A browser with its own chrome reports nought and nothing moves. */
:root{
--safe-b:env(safe-area-inset-bottom,0px);
--safe-l:env(safe-area-inset-left,0px);
--safe-r:env(safe-area-inset-right,0px);
}
#topbar{ #topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden; display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
the whole declaration away, leaving the bar with no padding at all. */
padding-top:7px;padding-bottom:7px;
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
background:var(--panel);border-bottom:1px solid var(--line); background:var(--panel);border-bottom:1px solid var(--line);
} }
#topbar .grow{flex:1} #topbar .grow{flex:1}
@@ -468,7 +674,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* Three panes, as the original had: feeds beside, items above, the item below. */ /* Three panes, as the original had: feeds beside, items above, the item below. */
#split{ #split{
display:grid;flex:1;min-height:0; display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */
grid-template-columns:minmax(0,1fr) 270px; grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr; grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail"; grid-template-areas:"list files" "grab grab" "detail detail";
@@ -638,7 +844,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
#player{ #player{
border-top:1px solid var(--line);background:var(--panel); border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center; display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4); padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
box-shadow:0 -6px 24px rgba(6,10,16,.4);
} }
#player.on{display:grid} #player.on{display:grid}
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video /* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
@@ -720,14 +928,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
background:var(--raise);border:1px solid var(--line);border-radius:9px; background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px; padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
} }
.toast.bad{border-color:var(--bad);color:var(--bad)} /* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
@keyframes in{from{opacity:0;transform:translateY(6px)}} @keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger,#dback{display:none} #burger,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */ /* Totals for what is showing, along the bottom, as the original's status bar. */
#status{ #status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel); padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
} }
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
body:has(#player.on) #status{padding-bottom:3px}
/* The feed's own header, kept to one line so the table starts high, as it did. */ /* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap} .fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none} .fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
@@ -751,7 +965,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)} #sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none} #sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41} #scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px} #player{
position:relative;z-index:39;gap:8px;
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
}
/* The feed list is reachable whether or not anything is playing. */ /* The feed list is reachable whether or not anything is playing. */
#burger{display:grid} #burger{display:grid}
@@ -860,3 +1078,74 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */ /* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff} :root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px} :root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
/* ---------- Glass: after Apple's Liquid Glass ---------- */
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */
:root[data-theme="glass"] body{
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
background:
radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent),
radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent),
radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent),
var(--bg);
}
:root[data-theme="glass"] #sidebar,
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #player,
:root[data-theme="glass"] #status,
:root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail,
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{
background:color-mix(in srgb,var(--panel) 70%,transparent);
-webkit-backdrop-filter:blur(24px) saturate(180%);
backdrop-filter:blur(24px) saturate(180%);
border-color:var(--glass-edge);
box-shadow:inset 0 1px 0 var(--glass-hi);
}
:root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{
/* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */
background:color-mix(in srgb,var(--bg) 65%,transparent);
-webkit-backdrop-filter:blur(20px) saturate(180%);
backdrop-filter:blur(20px) saturate(180%);
}
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)}
:root[data-theme="glass"] .card{border-radius:20px}
:root[data-theme="glass"] .toast{border-radius:14px}
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
@media (max-width:820px){
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
}
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{
background:color-mix(in srgb,var(--panel2) 60%,transparent);
border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi);
}
:root[data-theme="glass"] .btn,
:root[data-theme="glass"] .sidetools button,
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
:root[data-theme="glass"] body{background:var(--bg)}
:root[data-theme="glass"] #sidebar,
:root[data-theme="glass"] #topbar,
:root[data-theme="glass"] #player,
:root[data-theme="glass"] #status,
:root[data-theme="glass"] #files,
:root[data-theme="glass"] #detail,
:root[data-theme="glass"] .card,
:root[data-theme="glass"] .toast,
:root[data-theme="glass"] .tgroup,
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
:root[data-theme="glass"] .toolbar,
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
}

View File

@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
// The files are one script, concatenated in this order, not modules: they share one top-level // The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it. // scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = { const PAGES = {
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] }, 'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] }, 'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] }, 'login.html': { script: 'login.js', src: ['login'] },
}; };
@@ -38,7 +38,14 @@ export function buildStyle({ minify = true } = {}) {
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true }); const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error'); const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`); if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>')); const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
// The minifier drops the space between a calc() and the value after it in a shorthand --
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
// declaration away, so the element silently loses its padding. It reports no error and the
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
return out;
} }
/// The page and its script, built: { html, js, script }, where script is the file's name. /// The page and its script, built: { html, js, script }, where script is the file's name.

View File

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

View File

@@ -17,7 +17,7 @@
--line:#2c3849; --line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */ --faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */ --accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */ --ink:#0e131b; /* text on an accent fill */

View File

@@ -234,6 +234,11 @@ 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 for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field"><label>Feeds are checked every</label> <div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div> ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
@@ -242,8 +247,18 @@ async function prefsModal(){
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
$('#sblock').onchange=async e=>{
const blocked=splitWords(e.target.value);
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
if(S.me) S.me.blocked=blocked;
toast('Saved'); await loadFeeds(true); loadEntries();
}catch(err){ toast(err.message,true); }
};
} }
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
function settingsModal(f, newUrl?: string){ function settingsModal(f, newUrl?: string){
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>
@@ -257,6 +272,11 @@ function settingsModal(f, newUrl?: string){
<div class="field"><label>Keywords</label> <div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}"> <input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div> <span class="hint">Comma separated. Empty takes everything.</span></div>
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases. An item with one in its title or text
is hidden from you and not downloaded for you, as well as those your Settings hide
everywhere.</span></div>`}
<div class="field"><label>Max new downloads per scan</label> <div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for <span class="hint">Blank follows the global default (${globalMax}). The rest wait for
@@ -294,9 +314,10 @@ function settingsModal(f, newUrl?: string){
const max=$('#smax').value; const max=$('#smax').value;
try{ try{
const patch: Record<string, unknown>={ const patch: Record<string, unknown>={
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean), keywords:splitWords($('#skw').value),
max_new_per_check:max===''?null:Number(max), max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked}; auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
// The shared half is an admin's to change, and the API refuses it from anyone else. // The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){ if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim(); patch.url=$('#surl').value.trim();

View File

@@ -31,6 +31,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="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</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.circleMinus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div> </div>
@@ -120,6 +121,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="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</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.circleMinus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
</div> </div>
@@ -166,6 +168,7 @@ async function feedAction(a,f){
}catch(e){ toast(e.message,true); } }catch(e){ toast(e.message,true); }
} }
if(a==='settings') settingsModal(f); if(a==='settings') settingsModal(f);
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);
} }
/// All Subscriptions' own buttons: a feed's, across every feed you read. /// All Subscriptions' own buttons: a feed's, across every feed you read.

View File

@@ -5,7 +5,9 @@
// the document because the panes are rebuilt every time a feed renders. // the document because the panes are rebuilt every time a feed renders.
const PULL = 70; // px down, from the list's top, that counts as a pull const PULL = 70; // px down, from the list's top, that counts as a pull
const SWIPE = 60; // px across that counts as a swipe // A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null; let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
@@ -60,11 +62,34 @@ document.addEventListener('touchmove', ev => {
pullShow(touch.dy); pullShow(touch.dy);
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){ }else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
touch = null; // scrolling the text, not swiping; the first few px slide(0); touch = null; // scrolling the text, not swiping; the first few px
// decide nothing, being mostly jitter // decide nothing, being mostly jitter
}else if(Math.abs(touch.dx) > 10){
// The reader follows the finger, so it is plain before letting go whether this will move on;
// with nothing that way it only gives a little.
const i = S.entries.findIndex(x => x.guid === S.sel);
const end = touch.dx < 0 && i >= S.entries.length - 1;
slide(end ? touch.dx / 4 : touch.dx, false);
} }
}, {passive: false}); }, {passive: false});
/// Moves the reader across by dx, sliding there unless told not to (when following a finger).
function slide(dx: number, animate = true){
const d = $('#detail'); if(!d) return;
d.style.transition = animate ? 'transform .16s ease-out, opacity .16s ease-out' : 'none';
d.style.transform = dx ? `translateX(${dx}px)` : '';
d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / (d.clientWidth || 1))) : '';
}
/// The reader slides off the way the finger went, the next item comes in from the other side.
/// Timers, not transitionend, because with reduced motion the stylesheet turns transitions off
/// and transitionend never comes.
function slideOn(dir: number, go: () => void){
const w = $('#detail')?.clientWidth || 0;
slide(dir * w);
setTimeout(() => { go(); slide(-dir * w, false); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
}
document.addEventListener('touchend', () => { document.addEventListener('touchend', () => {
const g = touch; touch = null; const g = touch; touch = null;
if(!g) return; if(!g) return;
@@ -74,9 +99,11 @@ document.addEventListener('touchend', () => {
return; return;
} }
// Across, mostly sideways, and not a slow drag while selecting text. // Across, mostly sideways, and not a slow drag while selecting text.
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
const i = S.entries.findIndex(x => x.guid === S.sel); const i = S.entries.findIndex(x => x.guid === S.sel);
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; } if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
if(i > 0) stepEntry(-1); else showDetail(null); || (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
if(g.dx < 0) return slideOn(-1, () => stepEntry(1));
if(i > 0) return slideOn(1, () => stepEntry(-1));
slide(0); showDetail(null);
}); });
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; }); document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });

View File

@@ -249,7 +249,8 @@ function showDetail(e){
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}" <button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button> aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer" ${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''} title="Open the original" aria-label="Open the original">${ICON.open}</a>
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
</div> </div>
${narrow?encs:''} ${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`; <div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
@@ -273,6 +274,8 @@ function encBox(x){
: 'Delete file'; : 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`; const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`; const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
// The publisher's address, not this server's copy, which only someone signed in here can open.
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
if(x.path && !isPlayable(x)){ if(x.path && !isPlayable(x)){
// On disk, but not audio or video: view it, keep it, or remove it -- no player. // On disk, but not audio or video: view it, keep it, or remove it -- no player.
return `<div class="encbox"> return `<div class="encbox">
@@ -280,6 +283,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a> <a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
${saveBtn} ${saveBtn}
${shareBtn}
${delBtn} ${delBtn}
</div>`; </div>`;
} }
@@ -291,6 +295,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button> <button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn} ${saveBtn}
${shareBtn}
${delBtn} ${delBtn}
</div>`; </div>`;
} }
@@ -303,6 +308,7 @@ function encBox(x){
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''} ${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''} ${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
${shareBtn}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button> <button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`; </div>`;
} }
@@ -314,6 +320,7 @@ async function epAction(a: string, e, el, encId?: number){
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{ try{
if(a==='play') play(e, encId!=null ? enc : undefined); if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); } if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){ if(a==='get'){

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

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

View File

@@ -5,14 +5,19 @@
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from // page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice. // the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = { const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true}, adwaita: {name: 'Adwaita', modes: true},
catppuccin: {name: 'Catppuccin', modes: true},
classic: {name: 'Classic', modes: false}, classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true}, dracula: {name: 'Dracula', modes: true},
material: {name: 'Material', modes: true},
adwaita: {name: 'Adwaita', modes: true},
flatremix: {name: 'Flat Remix', modes: true}, flatremix: {name: 'Flat Remix', modes: true},
paper: {name: 'Paper', modes: false}, glass: {name: 'Glass', modes: true},
gruvbox: {name: 'Gruvbox', modes: true},
contrast: {name: 'High contrast', modes: true},
material: {name: 'Material', modes: true},
modern: {name: 'Modern', modes: true},
nordic: {name: 'Nordic', modes: true}, nordic: {name: 'Nordic', modes: true},
paper: {name: 'Paper', modes: false},
solarized: {name: 'Solarized', modes: true},
}; };
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'}; const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
// Before themes came in light and dark, ipx.theme in localStorage held one of these. // Before themes came in light and dark, ipx.theme in localStorage held one of these.

View File

@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag. // the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`; const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={ const ICON={
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
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
@@ -92,6 +93,27 @@ async function copyText(text,btn){
} }
} }
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
/// Asks first when the address looks like it carries your own access to a paid or private
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
/// gets it your subscription (issue #48).
///
/// ponytail: a guess from the address. A private feed whose key has another name is shared
/// without asking; add its pattern here when one turns up.
async function share(title: string, url: string, btn?){
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
+'send it to could use it as you.\n\nShare it anyway?')) return;
if(navigator.share){
// Cancelling the sheet rejects too, and is not a failure worth a word.
try{ await navigator.share({title, url}); }
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
return;
}
await copyText(url,btn);
toast('Link copied');
}
function toast(msg: string, bad?: boolean){ function toast(msg: string, bad?: boolean){
const t=document.createElement('div'); const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg; t.className='toast'+(bad?' bad':''); t.textContent=msg;