Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 | |||
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 |
56
CHANGELOG.md
56
CHANGELOG.md
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
|
||||||
|
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||||
|
"Downloaded" toast is only for a file on your screen.
|
||||||
|
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||||
|
- The Classic theme is listed in Settings as just "Classic".
|
||||||
|
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
|
||||||
|
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
|
||||||
|
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
|
||||||
|
drawing at desktop width.
|
||||||
|
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
|
||||||
|
- In Nordic's dark half, button and toolbar borders show.
|
||||||
|
- A zero unread count on the selected feed no longer disappears into the selection.
|
||||||
|
|
||||||
## [0.8.0] - 2026-09-18
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -503,6 +556,9 @@ 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/`.
|
- `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.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.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||||
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
||||||
|
|||||||
@@ -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
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
npx tsc -p . # type-checks web/src
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,10 +183,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* Nothing at the moment. Add one here when a limitation is known and left in place.
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
|
||||||
Substack-style subtitles.
|
|
||||||
|
|
||||||
<!-- rtk-instructions v2 -->
|
<!-- rtk-instructions v2 -->
|
||||||
# Command output
|
# Command output
|
||||||
|
|||||||
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.8.0"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1830,6 +1830,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2007,6 +2008,22 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -3786,6 +3803,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -5183,6 +5209,20 @@ name = "zeroize"
|
|||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.8.0"
|
version = "0.8.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,6 +12,7 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
|||||||
token = "" # generated and saved on first run
|
token = "" # generated and saved on first run
|
||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
|
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||||
|
access_aud = "" # the Access application's AUD tag
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
@@ -94,6 +96,9 @@ session_days = 30
|
|||||||
disables that path. See [sso.md](sso.md).
|
disables that path. See [sso.md](sso.md).
|
||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
|
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||||
|
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||||
|
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
|
|||||||
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
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
|
|
||||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||||
boundary, and is the upgrade if that ever matters.
|
a container on Tower cannot make one.
|
||||||
|
|
||||||
|
### Verifying Cloudflare's token
|
||||||
|
|
||||||
|
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||||
|
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||||
|
request, and takes the name from its `email` claim.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||||
|
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||||
|
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||||
|
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||||
|
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||||
|
the keys cannot be fetched; password and token sign-in still work then.
|
||||||
|
|
||||||
|
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||||
|
place.
|
||||||
|
|
||||||
|
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||||
|
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||||
|
|
||||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||||
in with them until they are given a password (`ipx user passwd <name>`).
|
in with them until they are given a password (`ipx user passwd <name>`).
|
||||||
|
|||||||
215
src/access.rs
Normal file
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
|
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||||
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||||
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||||
|
/// host any container can send one from the gateway's address.
|
||||||
|
pub access_team: String,
|
||||||
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||||
|
/// leaves the signature unchecked.
|
||||||
|
pub access_aud: String,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
@@ -96,6 +104,8 @@ impl Default for Web {
|
|||||||
token: String::new(),
|
token: String::new(),
|
||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
|
access_team: String::new(),
|
||||||
|
access_aud: String::new(),
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
@@ -107,6 +117,12 @@ impl Web {
|
|||||||
pub fn binds_publicly(&self) -> bool {
|
pub fn binds_publicly(&self) -> bool {
|
||||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both halves of the Access check, or None while either is unset.
|
||||||
|
pub fn access(&self) -> Option<(&str, &str)> {
|
||||||
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||||
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
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
|
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||||
non_empty(content)
|
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||||
.filter(|c| !starts_mid_tag(c))
|
Some(c) => Some(match subtitle(&c, description) {
|
||||||
.or_else(|| non_empty(description))
|
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||||
.or_else(|| non_empty(content))
|
None => c,
|
||||||
|
}),
|
||||||
|
None => non_empty(description).or_else(|| non_empty(content)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||||
|
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||||
|
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||||
|
/// description found in the body is not shown twice.
|
||||||
|
///
|
||||||
|
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||||
|
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||||
|
/// `entries` column.
|
||||||
|
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||||
|
let d = title_text(description)?;
|
||||||
|
if d.contains('<') || d.chars().count() > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||||
|
// description ends in "…" or "[...]".
|
||||||
|
let words = |s: &str| {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||||
|
};
|
||||||
|
let want = words(&d);
|
||||||
|
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||||
|
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||||
@@ -841,12 +882,36 @@ mod tests {
|
|||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||||
|
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a description with markup in it is notes, not a subtitle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why Q&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]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ pub enum Command {
|
|||||||
feed: Option<String>,
|
feed: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
|
||||||
|
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
|
||||||
|
/// Empty is every feed, as the schedule and the CLI mean it.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
feeds: Vec<String>,
|
||||||
},
|
},
|
||||||
Reap {
|
Reap {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -301,10 +306,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn commands_parse_from_the_wire_form() {
|
fn commands_parse_from_the_wire_form() {
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
|
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
|
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Reap { dry_run: true }));
|
assert!(matches!(got, Command::Reap { dry_run: true }));
|
||||||
|
|||||||
34
src/main.rs
34
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod access;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -197,7 +198,7 @@ async fn main() -> Result<()> {
|
|||||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||||
let wire_cmd = match &cli.command {
|
let wire_cmd = match &cli.command {
|
||||||
Command::Fetch { feed, force } => {
|
Command::Fetch { feed, force } => {
|
||||||
Some(Cmd::Fetch { feed: feed.clone(), force: *force })
|
Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
|
||||||
}
|
}
|
||||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||||
Command::Status => Some(Cmd::Status),
|
Command::Status => Some(Cmd::Status),
|
||||||
@@ -354,10 +355,10 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
|
|
||||||
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Fetch { feed, force } => {
|
Cmd::Fetch { feed, force, feeds } => {
|
||||||
// Make room before pulling more down, as the original did per download.
|
// Make room before pulling more down, as the original did per download.
|
||||||
reap(ctx, false, false).await?;
|
reap(ctx, false, false).await?;
|
||||||
fetch(ctx, feed.as_deref(), force).await
|
fetch(ctx, feed.as_deref(), force, &feeds).await
|
||||||
}
|
}
|
||||||
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
|
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
|
||||||
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
||||||
@@ -510,7 +511,7 @@ async fn daemon(
|
|||||||
}
|
}
|
||||||
_ = ticker.tick() => {
|
_ = ticker.tick() => {
|
||||||
// Per-feed schedule and TTL decide what actually gets polled.
|
// Per-feed schedule and TTL decide what actually gets polled.
|
||||||
let job = run(&ctx, Cmd::Fetch { feed: None, force: false });
|
let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
|
||||||
if !until_stopped(&ctx, &rx_stop, job).await {
|
if !until_stopped(&ctx, &rx_stop, job).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -550,11 +551,16 @@ async fn start_web(
|
|||||||
// The token is config.toml's, not the database's: it decides who gets in.
|
// The token is config.toml's, not the database's: it decides who gets in.
|
||||||
fresh.save_bootstrap(config_path)?;
|
fresh.save_bootstrap(config_path)?;
|
||||||
ctx.set_cfg(fresh.clone());
|
ctx.set_cfg(fresh.clone());
|
||||||
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
|
// The token signs in as the admin, and whatever reads this process's output (docker logs,
|
||||||
|
// for one) is wider than who reads config.toml. So say where it is, never what it is.
|
||||||
|
println!(
|
||||||
|
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"web ui at http://{bind}/?token={}",
|
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
|
||||||
ctx.cfg().web.token
|
config_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,10 +568,16 @@ async fn start_web(
|
|||||||
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let access = Arc::new(access::Keys::default());
|
||||||
|
if let Some((team, _)) = ctx.cfg().web.access() {
|
||||||
|
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
|
||||||
|
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
|
||||||
|
}
|
||||||
let state = web::WebState {
|
let state = web::WebState {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
cmds: cmds.clone(),
|
cmds: cmds.clone(),
|
||||||
events: events.clone(),
|
events: events.clone(),
|
||||||
|
access,
|
||||||
};
|
};
|
||||||
Ok(Some(tokio::spawn(async move {
|
Ok(Some(tokio::spawn(async move {
|
||||||
if let Err(e) = web::serve(state, &bind).await {
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
@@ -892,7 +904,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
/// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
|
||||||
|
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let subs = subscriptions(ctx).await?;
|
let subs = subscriptions(ctx).await?;
|
||||||
if let Some(id) = only
|
if let Some(id) = only
|
||||||
@@ -903,7 +916,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
|||||||
|
|
||||||
let mut scanned = 0;
|
let mut scanned = 0;
|
||||||
let mut fresh: Vec<String> = vec![];
|
let mut fresh: Vec<String> = vec![];
|
||||||
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
let in_scope = |s: &Sub| {
|
||||||
|
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
|
||||||
|
};
|
||||||
|
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
|
||||||
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
||||||
let state = ctx.db.http_state(id).await?;
|
let state = ctx.db.http_state(id).await?;
|
||||||
|
|
||||||
|
|||||||
53
src/web.rs
53
src/web.rs
@@ -29,6 +29,8 @@ pub struct WebState {
|
|||||||
pub ctx: Arc<Ctx>,
|
pub ctx: Arc<Ctx>,
|
||||||
pub cmds: mpsc::Sender<Command>,
|
pub cmds: mpsc::Sender<Command>,
|
||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
|
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||||
|
pub access: Arc<crate::access::Keys>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
@@ -99,7 +101,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
// 1. A header, but only from a hop we were told to believe.
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
let vouched = vouched_name(&cfg, &req);
|
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -201,20 +203,31 @@ struct Proxied(bool);
|
|||||||
|
|
||||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||||
/// be whoever they liked.
|
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||||
let peer = req
|
///
|
||||||
.extensions()
|
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
/// the future unsendable, as a body is not `Sync`.
|
||||||
.map(|c| c.0.ip().to_string())
|
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
.unwrap_or_default();
|
|
||||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
req.headers()
|
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
.get(&cfg.web.trusted_header)
|
let Some((team, aud)) = cfg.web.access() else {
|
||||||
.and_then(|v| v.to_str().ok())
|
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||||
.and_then(crate::auth::name_from_header)
|
};
|
||||||
|
// The plain header still has to be there, as it is what switches this path on for a
|
||||||
|
// request; who it names is the token's to say.
|
||||||
|
header(&cfg.web.trusted_header)?;
|
||||||
|
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||||
|
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(req: &Request) -> String {
|
||||||
|
req.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
@@ -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 password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
/// the form only made it look as if they were not.
|
/// the form only made it look as if they were not.
|
||||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
@@ -1239,7 +1252,7 @@ async fn add_feed(
|
|||||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||||
let force = feed.is_some();
|
let force = feed.is_some();
|
||||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
|
||||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1527,11 +1540,21 @@ struct FetchBody {
|
|||||||
|
|
||||||
async fn fetch_now(
|
async fn fetch_now(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
Json(body): Json<FetchBody>,
|
Json(body): Json<FetchBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
|
||||||
|
let feeds = if body.feed.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
|
||||||
|
};
|
||||||
|
if body.feed.is_none() && feeds.is_empty() {
|
||||||
|
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Fetch { feed: body.feed, force: body.force })
|
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await expect(page.locator('#admin')).toHaveCount(0);
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
||||||
// Asking for it anyway goes back to the app, and its script is refused.
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
@@ -1299,3 +1299,45 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|||||||
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge')).toBeVisible();
|
||||||
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
|
await page.locator('#scanAll').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
||||||
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
||||||
|
// Listen as the page does, then ask.
|
||||||
|
await piper.evaluate(() => {
|
||||||
|
window.started = []; window.done = false;
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
||||||
|
if (ev.ev === 'scan_done') window.done = true; };
|
||||||
|
});
|
||||||
|
await piper.waitForTimeout(500);
|
||||||
|
await piper.locator('#scanAll').click();
|
||||||
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
||||||
|
const started = await piper.evaluate(() => window.started);
|
||||||
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
||||||
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|||||||
191
web/app.css
191
web/app.css
@@ -12,7 +12,7 @@
|
|||||||
--line:#2c3849;
|
--line:#2c3849;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
@@ -32,12 +32,12 @@
|
|||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
--dim:#c4c4c8;
|
--dim:#c4c4c8;
|
||||||
--faint:#a0a0a7;
|
--faint:#a0a0a7;
|
||||||
--accent:#81d0ff;
|
--accent:#81d0ff;
|
||||||
--accent2:#3584e4;
|
--accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
|
||||||
--ink:#1d1d20;
|
--ink:#1d1d20;
|
||||||
--good:#78e9ab;
|
--good:#78e9ab;
|
||||||
--warn:#ffc252;
|
--warn:#ffc252;
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
--dim:#57575d;
|
--dim:#57575d;
|
||||||
--faint:#66666c;
|
--faint:#66666c;
|
||||||
--accent:#0461be;
|
--accent:#0461be;
|
||||||
--accent2:#3584e4;
|
--accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#00753a;
|
--good:#00753a;
|
||||||
--warn:#905400;
|
--warn:#905400;
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
--dim:#5c616c; /* fg */
|
--dim:#5c616c; /* fg */
|
||||||
--faint:#686d78;
|
--faint:#686d78;
|
||||||
--accent:#0060f0; /* link */
|
--accent:#0060f0; /* link */
|
||||||
--accent2:#fd7d00;
|
--accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#23794f;
|
--good:#23794f;
|
||||||
--warn:#a85400;
|
--warn:#a85400;
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
--dim:#3d3a33;
|
--dim:#3d3a33;
|
||||||
--faint:#5a564c;
|
--faint:#5a564c;
|
||||||
--accent:#1a5fae;
|
--accent:#1a5fae;
|
||||||
--accent2:#b58900;
|
--accent2:#957100; /* taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#216609;
|
--good:#216609;
|
||||||
--warn:#795a00;
|
--warn:#795a00;
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
--panel:#3b4252; /* nord1 */
|
--panel:#3b4252; /* nord1 */
|
||||||
--panel2:#434c5e; /* nord2 */
|
--panel2:#434c5e; /* nord2 */
|
||||||
--raise:#4c566a; /* nord3 */
|
--raise:#4c566a; /* nord3 */
|
||||||
--line:#434c5e;
|
--line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
|
||||||
--fg:#eceff4; /* nord6 */
|
--fg:#eceff4; /* nord6 */
|
||||||
--dim:#d8dee9; /* nord4 */
|
--dim:#d8dee9; /* nord4 */
|
||||||
--faint:#b4bccb;
|
--faint:#b4bccb;
|
||||||
@@ -232,13 +232,158 @@
|
|||||||
--dim:#3b4252; /* nord1 */
|
--dim:#3b4252; /* nord1 */
|
||||||
--faint:#4c566a; /* nord3 */
|
--faint:#4c566a; /* nord3 */
|
||||||
--accent:#3a5e8a;
|
--accent:#3a5e8a;
|
||||||
--accent2:#b0674e;
|
--accent2:#ab644c;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#446632;
|
--good:#446632;
|
||||||
--warn:#7e590e;
|
--warn:#7e590e;
|
||||||
--bad:#9c3f49;
|
--bad:#9c3f49;
|
||||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||||
}
|
}
|
||||||
|
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
|
||||||
|
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||||
|
and peach for the rest. */
|
||||||
|
:root[data-theme="catppuccin"] {
|
||||||
|
--bg:#1e1e2e; /* base */
|
||||||
|
--panel:#181825; /* mantle */
|
||||||
|
--panel2:#313244; /* surface0 */
|
||||||
|
--raise:#45475a; /* surface1 */
|
||||||
|
--line:#45475a;
|
||||||
|
--fg:#cdd6f4; /* text */
|
||||||
|
--dim:#bac2de; /* subtext1 */
|
||||||
|
--faint:#a6adc8; /* subtext0 */
|
||||||
|
--accent:#cba6f7; /* mauve */
|
||||||
|
--accent2:#fab387; /* peach */
|
||||||
|
--ink:#11111b; /* crust */
|
||||||
|
--good:#a6e3a1;
|
||||||
|
--warn:#f9e2af;
|
||||||
|
--bad:#f38ba8;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||||
|
--bg:#eff1f5; /* base */
|
||||||
|
--panel:#e6e9ef; /* mantle */
|
||||||
|
--panel2:#dce0e8; /* crust */
|
||||||
|
--raise:#ccd0da; /* surface0 */
|
||||||
|
--line:#bcc0cc; /* surface1 */
|
||||||
|
--fg:#4c4f69; /* text */
|
||||||
|
--dim:#5c5f77; /* subtext1 */
|
||||||
|
--faint:#5f6276; /* subtext0 */
|
||||||
|
--accent:#8638ec; /* mauve */
|
||||||
|
--accent2:#c74e09; /* peach */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#3a9127;
|
||||||
|
--warn:#925d13;
|
||||||
|
--bad:#d00f39;
|
||||||
|
--shadow:0 8px 28px rgba(76,79,105,.15);
|
||||||
|
}
|
||||||
|
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
|
||||||
|
half as gruvbox itself does. Blue for the accent, orange for what is new. */
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--bg:#282828; /* bg0 */
|
||||||
|
--panel:#1d2021; /* bg0_h */
|
||||||
|
--panel2:#32302f; /* bg0_s */
|
||||||
|
--raise:#3c3836; /* bg1 */
|
||||||
|
--line:#504945; /* bg2 */
|
||||||
|
--fg:#ebdbb2; /* fg */
|
||||||
|
--dim:#d5c4a1; /* fg2 */
|
||||||
|
--faint:#a89984; /* fg4 */
|
||||||
|
--accent:#83a598; /* blue */
|
||||||
|
--accent2:#fe8019; /* orange */
|
||||||
|
--ink:#1d2021;
|
||||||
|
--good:#b8bb26;
|
||||||
|
--warn:#fabd2f;
|
||||||
|
--bad:#fb533f;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||||
|
--bg:#fbf1c7; /* bg0 */
|
||||||
|
--panel:#f2e5bc; /* bg0_s */
|
||||||
|
--panel2:#ebdbb2; /* bg1 */
|
||||||
|
--raise:#d5c4a1; /* bg2 */
|
||||||
|
--line:#d5c4a1;
|
||||||
|
--fg:#3c3836; /* fg */
|
||||||
|
--dim:#504945; /* fg2 */
|
||||||
|
--faint:#665c54; /* fg3 */
|
||||||
|
--accent:#076678; /* faded blue */
|
||||||
|
--accent2:#af3a03; /* faded orange */
|
||||||
|
--ink:#fbf1c7;
|
||||||
|
--good:#79740e;
|
||||||
|
--warn:#8d5c10;
|
||||||
|
--bad:#9d0006;
|
||||||
|
--shadow:0 8px 28px rgba(60,56,54,.15);
|
||||||
|
}
|
||||||
|
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
|
||||||
|
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
|
||||||
|
:root[data-theme="solarized"] {
|
||||||
|
--bg:#002b36; /* base03 */
|
||||||
|
--panel:#073642; /* base02 */
|
||||||
|
--panel2:#0b3f4c;
|
||||||
|
--raise:#124a58;
|
||||||
|
--line:#1d5563;
|
||||||
|
--fg:#eee8d5; /* base2 */
|
||||||
|
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
|
||||||
|
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
|
||||||
|
--accent:#4b9fda; /* blue */
|
||||||
|
--accent2:#b58900; /* yellow */
|
||||||
|
--ink:#002b36;
|
||||||
|
--good:#859900;
|
||||||
|
--warn:#bb9315;
|
||||||
|
--bad:#e87674;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="solarized"][data-mode="light"] {
|
||||||
|
--bg:#fdf6e3; /* base3 */
|
||||||
|
--panel:#eee8d5; /* base2 */
|
||||||
|
--panel2:#e6dfca;
|
||||||
|
--raise:#ddd5bd;
|
||||||
|
--line:#d3cab0;
|
||||||
|
--fg:#073642; /* base02 */
|
||||||
|
--dim:#52666d; /* base01 */
|
||||||
|
--faint:#54666d; /* base00 */
|
||||||
|
--accent:#1e6da5; /* blue */
|
||||||
|
--accent2:#cb4b16; /* orange */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#768700;
|
||||||
|
--warn:#846400;
|
||||||
|
--bad:#c52d2a;
|
||||||
|
--shadow:0 8px 28px rgba(0,43,54,.14);
|
||||||
|
}
|
||||||
|
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
|
||||||
|
that show. For reading, not for looks. */
|
||||||
|
:root[data-theme="contrast"] {
|
||||||
|
--bg:#000000;
|
||||||
|
--panel:#000000;
|
||||||
|
--panel2:#121212;
|
||||||
|
--raise:#333333;
|
||||||
|
--line:#9a9a9a;
|
||||||
|
--fg:#ffffff;
|
||||||
|
--dim:#ffffff;
|
||||||
|
--faint:#d6d6d6;
|
||||||
|
--accent:#8cc8ff;
|
||||||
|
--accent2:#ffd400;
|
||||||
|
--ink:#000000;
|
||||||
|
--good:#7ee787;
|
||||||
|
--warn:#ffd400;
|
||||||
|
--bad:#ff9a8f;
|
||||||
|
--shadow:0 0 0 1px #9a9a9a;
|
||||||
|
}
|
||||||
|
:root[data-theme="contrast"][data-mode="light"] {
|
||||||
|
--bg:#ffffff;
|
||||||
|
--panel:#ffffff;
|
||||||
|
--panel2:#f0f0f0;
|
||||||
|
--raise:#d6d6d6;
|
||||||
|
--line:#555555;
|
||||||
|
--fg:#000000;
|
||||||
|
--dim:#000000;
|
||||||
|
--faint:#303030;
|
||||||
|
--accent:#0033a0;
|
||||||
|
--accent2:#7a3d00;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#0a5c1c;
|
||||||
|
--warn:#6b4500;
|
||||||
|
--bad:#a30000;
|
||||||
|
--shadow:0 0 0 1px #555555;
|
||||||
|
}
|
||||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||||
:root[data-theme="classic"] {
|
:root[data-theme="classic"] {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
@@ -250,11 +395,11 @@
|
|||||||
--fg:#000000;
|
--fg:#000000;
|
||||||
--dim:#444444;
|
--dim:#444444;
|
||||||
--faint:#666666;
|
--faint:#666666;
|
||||||
--accent:#3875d7; /* Aqua selection blue */
|
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
|
||||||
--accent2:#2a5db0;
|
--accent2:#2a5db0;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#237a23;
|
--good:#237a23;
|
||||||
--warn:#a15f00;
|
--warn:#9b5b00;
|
||||||
--bad:#c42b1c;
|
--bad:#c42b1c;
|
||||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||||
}
|
}
|
||||||
@@ -368,6 +513,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.chev:hover{color:var(--fg)}
|
.chev:hover{color:var(--fg)}
|
||||||
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||||
|
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
|
||||||
|
.feed.scanning .badge{display:none}
|
||||||
|
.feed.scanning::after{
|
||||||
|
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
||||||
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
||||||
a child's included, which is why it moves left by the child's indent. */
|
a child's included, which is why it moves left by the child's indent. */
|
||||||
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
||||||
@@ -421,9 +573,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||||||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||||
.tile:hover .txt b{color:var(--accent)}
|
.tile:hover .txt b{color:var(--accent)}
|
||||||
|
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
||||||
|
themes, where on the row's own ground they clear it. */
|
||||||
.tag{
|
.tag{
|
||||||
font-size:11px;font-weight:600;
|
font-size:11px;font-weight:600;
|
||||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
|
||||||
}
|
}
|
||||||
.art{
|
.art{
|
||||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
@@ -444,6 +598,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||||||
}
|
}
|
||||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
/* A selected row is --raise too, and the zero count's pill vanished into it. */
|
||||||
|
.sel .badge.zero{background:var(--bg)}
|
||||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
@@ -690,6 +846,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
||||||
.logbar .grow{flex:1;min-width:120px}
|
.logbar .grow{flex:1;min-width:120px}
|
||||||
.card h3{margin:0 0 14px;font-size:17px}
|
.card h3{margin:0 0 14px;font-size:17px}
|
||||||
|
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
|
||||||
|
at the foot of a long card. */
|
||||||
|
.cardx{float:right;margin:-5px -6px 0 8px}
|
||||||
.field{display:grid;gap:4px;margin-bottom:12px}
|
.field{display:grid;gap:4px;margin-bottom:12px}
|
||||||
.field label{font-size:12px;color:var(--dim)}
|
.field label{font-size:12px;color:var(--dim)}
|
||||||
.field .hint{font-size:11.5px;color:var(--faint)}
|
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||||
@@ -706,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;
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||||
}
|
}
|
||||||
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||||
|
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||||
#burger,#dback{display:none}
|
#burger,#dback{display:none}
|
||||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||||
@@ -838,7 +998,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
:root[data-theme="classic"] .ep.sel .st,
|
:root[data-theme="classic"] .ep.sel .st,
|
||||||
:root[data-theme="classic"] .ep.sel .fl,
|
:root[data-theme="classic"] .ep.sel .fl,
|
||||||
:root[data-theme="classic"] .ep.sel .file,
|
:root[data-theme="classic"] .ep.sel .file,
|
||||||
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
|
:root[data-theme="classic"] .ep.sel .kind,
|
||||||
|
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
|
||||||
/* Green and red stay green and red on the blue, just lighter so they read. */
|
/* Green and red stay green and red on the blue, just lighter so they read. */
|
||||||
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
||||||
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>Sign in — iPodderX</title>
|
<title>Sign in — iPodderX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
@@ -12,7 +17,7 @@
|
|||||||
--line:#2c3849;
|
--line:#2c3849;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
@@ -22,7 +27,8 @@
|
|||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(6,10,16,.55);
|
||||||
--r:10px;
|
--r:10px;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] {
|
/* Signed out, there is no account to take a theme from, so this follows the system. */
|
||||||
|
@media (prefers-color-scheme:light){:root {
|
||||||
--bg:#f2f4f7;
|
--bg:#f2f4f7;
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
--panel:#ffffff; /* #FFFFFF device body */
|
||||||
--panel2:#e9edf3;
|
--panel2:#e9edf3;
|
||||||
@@ -30,15 +36,15 @@
|
|||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#6b6b6b;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#b06f10;
|
--accent2:#985e0a;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a;
|
||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
}
|
}}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ function due(ts){
|
|||||||
async function prefsModal(){
|
async function prefsModal(){
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const admin = !!(S.me&&S.me.admin);
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<h3>Settings</h3>
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
<div class="field"><label>Theme</label>
|
<div class="field"><label>Theme</label>
|
||||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
@@ -238,8 +238,7 @@ async function prefsModal(){
|
|||||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#gopml').onclick=opmlModal;
|
||||||
@@ -371,7 +370,8 @@ function opmlModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ function connect(){
|
|||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshFeeds=soon(()=>loadFeeds(true));
|
const refreshFeeds=soon(()=>loadFeeds(true));
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
let fresh={};
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const tellNew=soon(()=>{
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
|
|
||||||
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
|
|
||||||
fresh={};
|
|
||||||
},900);
|
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
@@ -23,22 +19,28 @@ function connect(){
|
|||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries(); refreshFeeds();
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
}
|
}
|
||||||
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
|
setScanning(ev.feed,false);
|
||||||
|
if(!mine(ev.feed)) return;
|
||||||
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
}
|
}
|
||||||
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
// red ! marks the feed instead, and its page says why.
|
// red ! marks the feed instead, and its page says why.
|
||||||
else if(ev.ev==='feed_error') refreshFeeds();
|
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
||||||
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function renderGroup(f,kids){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
async function feedAction(a,f){
|
||||||
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ const VIEWS={
|
|||||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
@@ -104,6 +120,7 @@ function renderFeeds(){
|
|||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
|
paintScanning();
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id){
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ function pullShow(dy: number){
|
|||||||
function refreshFeed(){
|
function refreshFeed(){
|
||||||
const f = S.feeds.find(x => x.id === S.feed);
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
if(!f && S.feed !== ':all') return;
|
if(!f && S.feed !== ':all') return;
|
||||||
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
|
|
||||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
.catch(e => toast(e.message, true));
|
.catch(e => toast(e.message, true));
|
||||||
|
|||||||
@@ -191,8 +191,7 @@ function keysModal(){
|
|||||||
['Anywhere'],
|
['Anywhere'],
|
||||||
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
];
|
];
|
||||||
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||||||
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,18 @@
|
|||||||
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
// the system, so no palette is written twice.
|
// the system, so no palette is written twice.
|
||||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
modern: {name: 'Modern', modes: true},
|
|
||||||
classic: {name: 'Classic, the 2004 Mac app', modes: false},
|
|
||||||
dracula: {name: 'Dracula', modes: true},
|
|
||||||
material: {name: 'Material', modes: true},
|
|
||||||
adwaita: {name: 'Adwaita', modes: true},
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
catppuccin: {name: 'Catppuccin', modes: true},
|
||||||
|
classic: {name: 'Classic', modes: false},
|
||||||
|
dracula: {name: 'Dracula', modes: true},
|
||||||
flatremix: {name: 'Flat Remix', 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},
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
solarized: {name: 'Solarized', modes: true},
|
||||||
};
|
};
|
||||||
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const ICON={
|
|||||||
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
|
|
||||||
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
@@ -40,7 +39,6 @@ const ICON={
|
|||||||
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
|
|
||||||
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
|
|||||||
Reference in New Issue
Block a user