Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.4] - 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||
goes solid when the system asks for less transparency or more contrast.
|
||||
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||
home screen. In landscape the bars keep clear of the notch as well.
|
||||
|
||||
## [0.8.3] - 2026-09-19
|
||||
|
||||
### Security
|
||||
|
||||
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||
says where the token is kept instead: `[web] token` in config.toml.
|
||||
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||
docs/sso.md.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||
now on have it.
|
||||
|
||||
## [0.8.2] - 2026-09-19
|
||||
|
||||
### Added
|
||||
@@ -539,7 +572,9 @@ 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.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main
|
||||
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
|
||||
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||
|
||||
35
CLAUDE.md
35
CLAUDE.md
@@ -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` |
|
||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
|
||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
||||
|
||||
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||
|
||||
```sh
|
||||
R="--login git.sdf1.net --repo rays/ipodderx-rs"
|
||||
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||
t issues close $R 40
|
||||
```
|
||||
|
||||
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||
|
||||
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||
for later stays open, and that is how it gets picked up again.
|
||||
|
||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||
|
||||
```sh
|
||||
@@ -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
|
||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
||||
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
||||
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||
running the command and kills the session (exit 144), which has happened more than once.
|
||||
|
||||
## Before you touch the page
|
||||
|
||||
@@ -100,6 +122,7 @@ 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
|
||||
npx tsc -p . # type-checks web/src
|
||||
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
|
||||
```
|
||||
@@ -183,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
||||
|
||||
## Known gaps
|
||||
|
||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
||||
(documented in [docs/sso.md](docs/sso.md)).
|
||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
||||
Substack-style subtitles.
|
||||
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||
issue left open.
|
||||
|
||||
<!-- rtk-instructions v2 -->
|
||||
# Command output
|
||||
|
||||
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -1830,6 +1830,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"jsonwebtoken",
|
||||
"librqbit",
|
||||
"opml",
|
||||
"percent-encoding",
|
||||
@@ -2007,6 +2008,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -3786,6 +3803,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -5183,6 +5209,20 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "zerotrie"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -12,6 +12,7 @@ axum = "0.8.9"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||
opml = "1.1.6"
|
||||
percent-encoding = "2.3.2"
|
||||
|
||||
@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
||||
```sh
|
||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||
node tests/page-smoke.js # the page script loads without throwing
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ before they reach the page.
|
||||
```sh
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
|
||||
pkill -x ipx
|
||||
```
|
||||
|
||||
`pkill -f ipx` matches the shell running the command too, and kills your own session.
|
||||
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
|
||||
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||
|
||||
## Talking to it directly
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
||||
token = "" # generated and saved on first run
|
||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||
trusted_proxies = ["127.0.0.1", "::1"]
|
||||
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||
access_aud = "" # the Access application's AUD tag
|
||||
auto_create_users = true
|
||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
@@ -94,6 +96,9 @@ session_days = 30
|
||||
disables that path. See [sso.md](sso.md).
|
||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||
for it. Name the proxy, never a subnet.
|
||||
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||
|
||||
36
docs/sso.md
36
docs/sso.md
@@ -39,11 +39,17 @@ enabled = true
|
||||
bind = "0.0.0.0:8099"
|
||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||
auto_create_users = true
|
||||
sign_out_url = "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
```
|
||||
|
||||
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
|
||||
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||
|
||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||
restart ipodderx`.
|
||||
|
||||
@@ -172,9 +178,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
||||
sides. Never list a LAN address or range: anyone there could then send
|
||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||
|
||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
||||
boundary, and is the upgrade if that ever matters.
|
||||
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||
a container on Tower cannot make one.
|
||||
|
||||
### Verifying Cloudflare's token
|
||||
|
||||
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||
request, and takes the name from its `email` claim.
|
||||
|
||||
```toml
|
||||
[web]
|
||||
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||
```
|
||||
|
||||
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||
the keys cannot be fetched; password and token sign-in still work then.
|
||||
|
||||
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||
place.
|
||||
|
||||
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||
|
||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||
in with them until they are given a password (`ipx user passwd <name>`).
|
||||
|
||||
215
src/access.rs
Normal file
215
src/access.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,14 @@ pub struct Web {
|
||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||
/// host any container can send one from the gateway's address.
|
||||
pub access_team: String,
|
||||
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||
/// leaves the signature unchecked.
|
||||
pub access_aud: String,
|
||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||
pub auto_create_users: bool,
|
||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||
@@ -96,6 +104,8 @@ impl Default for Web {
|
||||
token: String::new(),
|
||||
trusted_header: String::new(),
|
||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||
access_team: String::new(),
|
||||
access_aud: String::new(),
|
||||
auto_create_users: true,
|
||||
sign_out_url: String::new(),
|
||||
session_days: 30,
|
||||
@@ -107,6 +117,12 @@ impl Web {
|
||||
pub fn binds_publicly(&self) -> bool {
|
||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||
}
|
||||
|
||||
/// Both halves of the Access check, or None while either is unset.
|
||||
pub fn access(&self) -> Option<(&str, &str)> {
|
||||
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
77
src/feed.rs
77
src/feed.rs
@@ -698,10 +698,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||
non_empty(content)
|
||||
.filter(|c| !starts_mid_tag(c))
|
||||
.or_else(|| non_empty(description))
|
||||
.or_else(|| non_empty(content))
|
||||
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||
Some(c) => Some(match subtitle(&c, description) {
|
||||
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||
None => c,
|
||||
}),
|
||||
None => non_empty(description).or_else(|| non_empty(content)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||
/// description found in the body is not shown twice.
|
||||
///
|
||||
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||
/// `entries` column.
|
||||
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||
let d = title_text(description)?;
|
||||
if d.contains('<') || d.chars().count() > 300 {
|
||||
return None;
|
||||
}
|
||||
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||
// description ends in "…" or "[...]".
|
||||
let words = |s: &str| {
|
||||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||
};
|
||||
let want = words(&d);
|
||||
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||
}
|
||||
|
||||
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||
@@ -841,12 +882,36 @@ mod tests {
|
||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
||||
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||
Some("<p>The post.</p>"),
|
||||
"a description with markup in it is notes, not a subtitle",
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||
);
|
||||
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||
for d in ["This week we talk about tape, drums and a very long list.",
|
||||
"This week we talk about tape, drums…",
|
||||
"This week we talk about\ntape [...]"] {
|
||||
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||
}
|
||||
let long = "word ".repeat(80);
|
||||
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||
"a long description is notes, not a subtitle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_level_explicit_overrides_entries() {
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@@ -1,3 +1,4 @@
|
||||
mod access;
|
||||
mod auth;
|
||||
mod config;
|
||||
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.
|
||||
fresh.save_bootstrap(config_path)?;
|
||||
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 {
|
||||
println!(
|
||||
"web ui at http://{bind}/?token={}",
|
||||
ctx.cfg().web.token
|
||||
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
|
||||
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");
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx: ctx.clone(),
|
||||
cmds: cmds.clone(),
|
||||
events: events.clone(),
|
||||
access,
|
||||
};
|
||||
Ok(Some(tokio::spawn(async move {
|
||||
if let Err(e) = web::serve(state, &bind).await {
|
||||
|
||||
39
src/web.rs
39
src/web.rs
@@ -29,6 +29,8 @@ pub struct WebState {
|
||||
pub ctx: Arc<Ctx>,
|
||||
pub cmds: mpsc::Sender<Command>,
|
||||
pub events: broadcast::Sender<Event>,
|
||||
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||
pub access: Arc<crate::access::Keys>,
|
||||
}
|
||||
|
||||
pub fn router(state: WebState) -> Router {
|
||||
@@ -99,7 +101,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
let token = cfg.web.token.clone();
|
||||
|
||||
// 1. A header, but only from a hop we were told to believe.
|
||||
let vouched = vouched_name(&cfg, &req);
|
||||
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||
|
||||
let mut set_cookie: Option<String> = None;
|
||||
let mut user = None;
|
||||
@@ -201,20 +203,31 @@ struct Proxied(bool);
|
||||
|
||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||
/// be whoever they liked.
|
||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||
///
|
||||
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||
/// the future unsendable, as a body is not `Sync`.
|
||||
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||
return None;
|
||||
}
|
||||
req.headers()
|
||||
.get(&cfg.web.trusted_header)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::auth::name_from_header)
|
||||
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||
let Some((team, aud)) = cfg.web.access() else {
|
||||
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||
};
|
||||
// The plain header still has to be there, as it is what switches this path on for a
|
||||
// request; who it names is the token's to say.
|
||||
header(&cfg.web.trusted_header)?;
|
||||
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||
}
|
||||
|
||||
fn peer(req: &Request) -> String {
|
||||
req.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||
@@ -474,7 +487,7 @@ async fn remove_user(
|
||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||
/// the form only made it look as if they were not.
|
||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
||||
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||
|
||||
@@ -58,5 +58,39 @@ for (const [name, p] of Object.entries(themes)) {
|
||||
// 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`);
|
||||
|
||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-forger.der
Normal file
Binary file not shown.
BIN
tests/data/access-test.der
Normal file
BIN
tests/data/access-test.der
Normal file
Binary file not shown.
12
tests/data/access-test.jwks.json
Normal file
12
tests/data/access-test.jwks.json
Normal 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
131
tests/dom-stub.js
Normal 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
117
tests/native-bridge.js
Normal 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);
|
||||
@@ -7,7 +7,7 @@
|
||||
// and every server-side test passed too, because the server was fine.
|
||||
//
|
||||
// node tests/page-smoke.js
|
||||
const vm = require('vm');
|
||||
const { makeContext, vm } = require('./dom-stub.js');
|
||||
const PAGE = process.argv[2] || 'index.html';
|
||||
|
||||
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)) {
|
||||
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 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;
|
||||
const { ctx, missing } = makeContext(html, script);
|
||||
|
||||
try {
|
||||
vm.createContext(ctx);
|
||||
|
||||
151
web/app.css
151
web/app.css
@@ -403,6 +403,53 @@
|
||||
--bad:#c42b1c;
|
||||
--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}
|
||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||
[hidden]{display:none!important}
|
||||
@@ -453,8 +500,22 @@ a{color:var(--accent)}
|
||||
}
|
||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||
/* 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{
|
||||
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);
|
||||
}
|
||||
#topbar .grow{flex:1}
|
||||
@@ -783,7 +844,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
#player{
|
||||
border-top:1px solid var(--line);background:var(--panel);
|
||||
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}
|
||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||
@@ -871,9 +934,14 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
#burger,#dback{display:none}
|
||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||
#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;
|
||||
}
|
||||
/* 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. */
|
||||
.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}
|
||||
@@ -897,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.open{transform:none}
|
||||
#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. */
|
||||
#burger{display:grid}
|
||||
@@ -1006,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. */
|
||||
: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}
|
||||
|
||||
/* ---------- 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)}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||
const PAGES = {
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||
'login.html': { script: 'login.js', src: ['login'] },
|
||||
};
|
||||
@@ -38,7 +38,14 @@ export function buildStyle({ minify = 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');
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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">
|
||||
<title>iPodderX</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||
|
||||
177
web/src/native.ts
Normal file
177
web/src/native.ts
Normal 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});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||
classic: {name: 'Classic', modes: false},
|
||||
dracula: {name: 'Dracula', modes: true},
|
||||
flatremix: {name: 'Flat Remix', modes: true},
|
||||
glass: {name: 'Glass', modes: true},
|
||||
gruvbox: {name: 'Gruvbox', modes: true},
|
||||
contrast: {name: 'High contrast', modes: true},
|
||||
material: {name: 'Material', modes: true},
|
||||
|
||||
Reference in New Issue
Block a user