Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.3] - 2026-09-19
|
||||
|
||||
### Security
|
||||
|
||||
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||
says where the token is kept instead: `[web] token` in config.toml.
|
||||
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||
docs/sso.md.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||
now on have it.
|
||||
|
||||
## [0.8.2] - 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||
contrast, black and white with every colour at 7:1 or more.
|
||||
|
||||
### Changed
|
||||
|
||||
- The themes in Settings are listed in alphabetical order.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||
read comfortably. A failed-action message is easier to read in every theme.
|
||||
|
||||
## [0.8.1] - 2026-09-19
|
||||
|
||||
### Changed
|
||||
@@ -524,6 +556,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `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
|
||||
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||
|
||||
@@ -100,6 +100,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/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
@@ -182,10 +183,7 @@ 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.
|
||||
* Nothing at the moment. Add one here when a limitation is known and left in place.
|
||||
|
||||
<!-- 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.1"
|
||||
version = "0.8.3"
|
||||
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.1"
|
||||
version = "0.8.3"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
30
docs/sso.md
30
docs/sso.md
@@ -172,9 +172,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"))))
|
||||
|
||||
62
tests/contrast.js
Normal file
62
tests/contrast.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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`); }
|
||||
}
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
152
web/app.css
152
web/app.css
@@ -12,7 +12,7 @@
|
||||
--line:#2c3849;
|
||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||
--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 */
|
||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||
--ink:#0e131b; /* text on an accent fill */
|
||||
@@ -239,6 +239,151 @@
|
||||
--bad:#9c3f49;
|
||||
--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. */
|
||||
:root[data-theme="classic"] {
|
||||
color-scheme:light;
|
||||
@@ -250,7 +395,7 @@
|
||||
--fg:#000000;
|
||||
--dim:#444444;
|
||||
--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;
|
||||
--ink:#ffffff;
|
||||
--good:#237a23;
|
||||
@@ -720,7 +865,8 @@ 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;
|
||||
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)}}
|
||||
#burger,#dback{display:none}
|
||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
--line:#2c3849;
|
||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||
--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 */
|
||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||
--ink:#0e131b; /* text on an accent fill */
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
// 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.
|
||||
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},
|
||||
dracula: {name: 'Dracula', modes: true},
|
||||
material: {name: 'Material', modes: true},
|
||||
adwaita: {name: 'Adwaita', modes: true},
|
||||
flatremix: {name: 'Flat Remix', modes: true},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
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},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
solarized: {name: 'Solarized', modes: true},
|
||||
};
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user