Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 | |||
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 | |||
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 | |||
| bc53e0f730 | |||
| bd8f6ab855 | |||
| 611716d8b7 | |||
| a68bfb179b | |||
| 6bf1ad6b31 | |||
| d7f8f2df1d | |||
| 4927677e66 | |||
| 484aaa1849 | |||
| c99e17bd80 | |||
| e312f11bb1 |
99
CHANGELOG.md
99
CHANGELOG.md
@@ -5,11 +5,97 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
The long form, with what was wrong before and how it was found, is in
|
||||
[docs/history.md](docs/history.md).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.4] - 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||
goes solid when the system asks for less transparency or more contrast.
|
||||
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||
home screen. In landscape the bars keep clear of the notch as well.
|
||||
|
||||
## [0.8.3] - 2026-09-19
|
||||
|
||||
### Security
|
||||
|
||||
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||
says where the token is kept instead: `[web] token` in config.toml.
|
||||
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||
docs/sso.md.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||
now on have it.
|
||||
|
||||
## [0.8.2] - 2026-09-19
|
||||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||
across, everything in one go.
|
||||
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||
|
||||
### Changed
|
||||
|
||||
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||
be opened by a 0.7 release first, which brings its tables up to date.
|
||||
|
||||
## [0.7.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
@@ -486,7 +572,12 @@ The long form, with what was wrong before and how it was found, is in
|
||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.4...main
|
||||
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
|
||||
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||
[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.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
||||
|
||||
75
CLAUDE.md
75
CLAUDE.md
@@ -13,15 +13,36 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
|
||||
| | Host | In the container |
|
||||
|---|---|---|
|
||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
|
||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
||||
|
||||
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||
|
||||
```sh
|
||||
R="--login git.sdf1.net --repo rays/ipodderx-rs"
|
||||
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||
t issues close $R 40
|
||||
```
|
||||
|
||||
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||
|
||||
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||
for later stays open, and that is how it gets picked up again.
|
||||
|
||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||
|
||||
```sh
|
||||
@@ -56,8 +77,10 @@ container restarts on its own after a reboot.
|
||||
|
||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
||||
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
||||
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||
running the command and kills the session (exit 144), which has happened more than once.
|
||||
|
||||
## Before you touch the page
|
||||
|
||||
@@ -99,6 +122,8 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
@@ -125,10 +150,18 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
||||
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
||||
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
||||
and `position` columns from before accounts; two bugs came from queries still reading them
|
||||
(retention, and the entry pruner), and `migrate()` now drops them.
|
||||
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
|
||||
(retention, and the entry pruner), and they were dropped in 0.5.
|
||||
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
|
||||
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
|
||||
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
|
||||
and who gets in, the database for the rest (`assemble_config`); a change goes through
|
||||
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
|
||||
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
|
||||
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
||||
derived and never written to config.
|
||||
derived and never in the catalogue.
|
||||
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
|
||||
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
|
||||
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
|
||||
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
||||
Anyone wanting an item is enough to fetch it.
|
||||
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
||||
@@ -140,10 +173,16 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||
different task. `ipx status` checks the control socket and the database; to see the worker
|
||||
getting through its jobs, watch for `scan complete` in the log.
|
||||
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
|
||||
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
|
||||
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
|
||||
for `daemon started` in the log. Copy `state.db` aside before deploying one.
|
||||
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
|
||||
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
|
||||
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
||||
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
||||
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
|
||||
|
||||
## House style
|
||||
|
||||
@@ -154,9 +193,9 @@ addressed to the person using it.
|
||||
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
||||
say, such as what was wrong before or what it cost to find out, write it up at the top of
|
||||
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
|
||||
than once.
|
||||
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
||||
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||
|
||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
||||
@@ -167,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
||||
|
||||
## Known gaps
|
||||
|
||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
||||
(documented in [docs/sso.md](docs/sso.md)).
|
||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
||||
Substack-style subtitles.
|
||||
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||
issue left open.
|
||||
|
||||
<!-- rtk-instructions v2 -->
|
||||
# Command output
|
||||
|
||||
915
Cargo.lock
generated
915
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.7.0"
|
||||
version = "0.8.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -12,13 +12,14 @@ axum = "0.8.9"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||
opml = "1.1.6"
|
||||
percent-encoding = "2.3.2"
|
||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||
rss = "2.1.1"
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
||||
|
||||
@@ -63,7 +63,6 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
||||
| [docs/history.md](docs/history.md) | How it was built, with what was wrong and why |
|
||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||
|
||||
## Tests
|
||||
@@ -71,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
||||
```sh
|
||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||
node tests/page-smoke.js # the page script loads without throwing
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ services:
|
||||
PGID: "100"
|
||||
TZ: "America/Toronto"
|
||||
IPX_LOG: "ipx=info"
|
||||
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||
env_file:
|
||||
- ipodderx.env # relative: Arcane resolves it inside its own container
|
||||
ports:
|
||||
- "8099:8099" # web UI
|
||||
- "6881:6881/tcp" # BitTorrent peers
|
||||
|
||||
@@ -10,7 +10,8 @@ it to a running daemon.
|
||||
|---|---|---|
|
||||
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||
| `src/db.rs` | SQLite schema, migrations, every query | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
||||
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||
@@ -65,14 +66,14 @@ entry_state user_id, feed_id, guid, read, flagged, position
|
||||
```
|
||||
|
||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
||||
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
|
||||
older database.
|
||||
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||
|
||||
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
|
||||
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
|
||||
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
|
||||
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
|
||||
path as `Db::open`, so a migration cannot pass the tests while missing in production.
|
||||
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
|
||||
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
||||
database the same way, so the tests run on the schema production gets. A database from before
|
||||
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
||||
|
||||
## Control socket
|
||||
|
||||
@@ -140,6 +141,7 @@ before they reach the page.
|
||||
```sh
|
||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||
node tests/native-bridge.js # the page hands playback to a native shell
|
||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
|
||||
pkill -x ipx
|
||||
```
|
||||
|
||||
`pkill -f ipx` matches the shell running the command too, and kills your own session.
|
||||
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
|
||||
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||
|
||||
## Talking to it directly
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
# Configuration
|
||||
|
||||
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
|
||||
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
||||
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
|
||||
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
||||
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
||||
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
||||
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
||||
take effect without a restart.
|
||||
|
||||
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
|
||||
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
|
||||
ignored, with a warning in the log saying so. The sections below describe them as they were
|
||||
written in config.toml, which is still how a fresh install begins.
|
||||
|
||||
config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
|
||||
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
||||
|
||||
| What | Where | Override |
|
||||
@@ -11,8 +23,10 @@ effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
||||
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
||||
| Downloads | `[general] download_dir` | — |
|
||||
|
||||
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
|
||||
while the daemon is stopped, or with `sqlite3 state.db .backup`.
|
||||
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
||||
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
|
||||
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
|
||||
|
||||
## `[general]`
|
||||
|
||||
@@ -28,6 +42,9 @@ max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
||||
media_types = ["audio", "video"]
|
||||
```
|
||||
|
||||
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
|
||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||
|
||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||
@@ -66,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
||||
token = "" # generated and saved on first run
|
||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||
trusted_proxies = ["127.0.0.1", "::1"]
|
||||
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||
access_aud = "" # the Access application's AUD tag
|
||||
auto_create_users = true
|
||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
@@ -77,6 +96,9 @@ session_days = 30
|
||||
disables that path. See [sso.md](sso.md).
|
||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||
for it. Name the proxy, never a subnet.
|
||||
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||
@@ -88,8 +110,9 @@ itself carry a credential. Put TLS in front of it if that matters.
|
||||
|
||||
## `[feeds.<id>]`
|
||||
|
||||
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
|
||||
derives it from the feed title.
|
||||
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
|
||||
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
|
||||
|
||||
```toml
|
||||
[feeds.atp]
|
||||
@@ -107,8 +130,8 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||
and are the same for everyone. See [users.md](users.md).
|
||||
|
||||
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and
|
||||
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
|
||||
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -116,6 +139,8 @@ they are re-derived on every scan. Editing one in the UI promotes it to a real c
|
||||
|---|---|
|
||||
| `IPX_CONFIG` | Config file path |
|
||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||
|
||||
1732
docs/history.md
1732
docs/history.md
File diff suppressed because it is too large
Load Diff
36
docs/sso.md
36
docs/sso.md
@@ -39,11 +39,17 @@ enabled = true
|
||||
bind = "0.0.0.0:8099"
|
||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||
auto_create_users = true
|
||||
sign_out_url = "/cdn-cgi/access/logout"
|
||||
session_days = 30
|
||||
```
|
||||
|
||||
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
|
||||
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||
|
||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||
restart ipodderx`.
|
||||
|
||||
@@ -172,9 +178,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
||||
sides. Never list a LAN address or range: anyone there could then send
|
||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||
|
||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
||||
boundary, and is the upgrade if that ever matters.
|
||||
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||
a container on Tower cannot make one.
|
||||
|
||||
### Verifying Cloudflare's token
|
||||
|
||||
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||
request, and takes the name from its `email` claim.
|
||||
|
||||
```toml
|
||||
[web]
|
||||
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||
```
|
||||
|
||||
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||
the keys cannot be fetched; password and token sign-in still work then.
|
||||
|
||||
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||
place.
|
||||
|
||||
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||
|
||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||
in with them until they are given a password (`ipx user passwd <name>`).
|
||||
|
||||
215
src/access.rs
Normal file
215
src/access.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
|
||||
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
|
||||
//! address is the Docker gateway: any container there could send the header and be anyone.
|
||||
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
|
||||
//! takes the network out of the question.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use jsonwebtoken::jwk::JwkSet;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||
|
||||
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||
/// made-up key ids cannot turn every request into a request to Cloudflare.
|
||||
const REFETCH_EVERY: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Keys {
|
||||
cache: Mutex<Cache>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
keys: HashMap<String, DecodingKey>,
|
||||
fetched: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Claims {
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
impl Keys {
|
||||
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||
}
|
||||
|
||||
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||
match fetch(client, team).await {
|
||||
Ok(set) => self.store(set),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = Result<JwkSet>>,
|
||||
{
|
||||
let kid = decode_header(token).ok()?.kid?;
|
||||
let key = match self.key(&kid) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
if !self.may_refetch() {
|
||||
return None;
|
||||
}
|
||||
match fetch().await {
|
||||
Ok(set) => self.store(set),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.key(&kid)?
|
||||
}
|
||||
};
|
||||
// RS256 only: a token that names HS256 or none is refused here, before its signature
|
||||
// is looked at, rather than checked with the public key as if it were a secret.
|
||||
let mut v = Validation::new(Algorithm::RS256);
|
||||
v.set_audience(&[aud]);
|
||||
v.set_issuer(&[format!("https://{team}")]);
|
||||
v.validate_nbf = true;
|
||||
match decode::<Claims>(token, &key, &v) {
|
||||
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||
self.cache.lock().unwrap().keys.get(kid).cloned()
|
||||
}
|
||||
|
||||
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||
fn may_refetch(&self) -> bool {
|
||||
let mut c = self.cache.lock().unwrap();
|
||||
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||
return false;
|
||||
}
|
||||
c.fetched = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||
fn store(&self, set: JwkSet) {
|
||||
let keys = set
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||
.collect();
|
||||
let mut c = self.cache.lock().unwrap();
|
||||
c.keys = keys;
|
||||
c.fetched = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||
client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("fetching {url}"))?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await
|
||||
.context("reading the signing keys")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
|
||||
|
||||
const TEAM: &str = "team.cloudflareaccess.com";
|
||||
const AUD: &str = "aud-tag";
|
||||
|
||||
fn jwks() -> JwkSet {
|
||||
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
||||
}
|
||||
|
||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||
let mut h = Header::new(alg);
|
||||
h.kid = Some("k1".into());
|
||||
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||
encode(&h, &claims, &k).unwrap()
|
||||
}
|
||||
|
||||
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
let now = get_current_timestamp() as i64;
|
||||
serde_json::json!({
|
||||
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||
})
|
||||
}
|
||||
|
||||
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
||||
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
||||
|
||||
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||
let keys = Keys::default();
|
||||
keys.store(jwks());
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
|
||||
|
||||
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
|
||||
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
|
||||
|
||||
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
|
||||
assert_eq!(check(&keys, &expired).await, None, "expired");
|
||||
|
||||
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
|
||||
|
||||
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||
ok.split('.').nth(1).unwrap());
|
||||
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_key_refetches_once_a_minute_at_most() {
|
||||
let keys = Keys::default();
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||
"a key not cached yet is fetched");
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
|
||||
// A made-up key id straight after: not fetched again.
|
||||
let mut h = Header::new(Algorithm::RS256);
|
||||
h.kid = Some("nobody".into());
|
||||
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
|
||||
let keys = Keys::default();
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
137
src/config.rs
137
src/config.rs
@@ -78,6 +78,14 @@ pub struct Web {
|
||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||
/// host any container can send one from the gateway's address.
|
||||
pub access_team: String,
|
||||
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||
/// leaves the signature unchecked.
|
||||
pub access_aud: String,
|
||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||
pub auto_create_users: bool,
|
||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||
@@ -96,6 +104,8 @@ impl Default for Web {
|
||||
token: String::new(),
|
||||
trusted_header: String::new(),
|
||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||
access_team: String::new(),
|
||||
access_aud: String::new(),
|
||||
auto_create_users: true,
|
||||
sign_out_url: String::new(),
|
||||
session_days: 30,
|
||||
@@ -107,6 +117,12 @@ impl Web {
|
||||
pub fn binds_publicly(&self) -> bool {
|
||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||
}
|
||||
|
||||
/// Both halves of the Access check, or None while either is unset.
|
||||
pub fn access(&self) -> Option<(&str, &str)> {
|
||||
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -261,21 +277,86 @@ impl Config {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
|
||||
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
||||
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
||||
/// or decides who gets in, and stays in config.toml.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Stored {
|
||||
pub schedule: String,
|
||||
pub max_total_gb: f64,
|
||||
pub max_age_days: u64,
|
||||
pub max_new_per_check: usize,
|
||||
pub media_types: Vec<String>,
|
||||
}
|
||||
|
||||
/// `[general]` keys that live in the database once it holds the configuration.
|
||||
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
|
||||
|
||||
impl Stored {
|
||||
pub fn of(cfg: &Config) -> Self {
|
||||
let g = &cfg.general;
|
||||
Self {
|
||||
schedule: g.schedule.clone(),
|
||||
max_total_gb: g.max_total_gb,
|
||||
max_age_days: g.max_age_days,
|
||||
max_new_per_check: g.max_new_per_check,
|
||||
media_types: g.media_types.clone(),
|
||||
}
|
||||
let text = toml::to_string_pretty(self)?;
|
||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
// Passwords may live in here.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(self, cfg: &mut Config) {
|
||||
let g = &mut cfg.general;
|
||||
g.schedule = self.schedule;
|
||||
g.max_total_gb = self.max_total_gb;
|
||||
g.max_age_days = self.max_age_days;
|
||||
g.max_new_per_check = self.max_new_per_check;
|
||||
g.media_types = self.media_types;
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// config.toml as it is kept once the database holds the feeds and server settings: the same
|
||||
/// file without `[feeds]` or the `[general]` keys in `Stored`.
|
||||
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
|
||||
let mut v = toml::Value::try_from(self)?;
|
||||
if let Some(t) = v.as_table_mut() {
|
||||
t.remove("feeds");
|
||||
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
|
||||
for k in STORED_KEYS {
|
||||
g.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
write_private(path, &toml::to_string_pretty(&v)?)
|
||||
}
|
||||
|
||||
/// Whether config.toml still lists feeds or server settings, which the database now holds:
|
||||
/// an edit there would otherwise go unnoticed.
|
||||
pub fn file_holds_stored(path: &Path) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return false };
|
||||
let Ok(v) = text.parse::<toml::Table>() else { return false };
|
||||
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|
||||
|| v.get("general")
|
||||
.and_then(|g| g.as_table())
|
||||
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
|
||||
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||
@@ -370,6 +451,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||
let cfg: Config = toml::from_str(
|
||||
r#"
|
||||
[general]
|
||||
download_dir = "/downloads"
|
||||
schedule = "every 2h"
|
||||
max_new_per_check = 7
|
||||
media_types = ["audio"]
|
||||
[web]
|
||||
bind = "0.0.0.0:8099"
|
||||
token = "t"
|
||||
[feeds.show]
|
||||
url = "http://x/show.xml"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
|
||||
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
|
||||
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
|
||||
cfg.save_bootstrap(&path).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!Config::file_holds_stored(&path), "{text}");
|
||||
let back: Config = toml::from_str(&text).unwrap();
|
||||
assert!(back.feeds.is_empty());
|
||||
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
|
||||
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_config_and_applies_defaults() {
|
||||
let cfg: Config = toml::from_str(
|
||||
|
||||
285
src/entity.rs
Normal file
285
src/entity.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! The database's tables as SeaORM entities: the one description of the schema, from which
|
||||
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
|
||||
//! `db::create_missing`). Times are Unix seconds.
|
||||
|
||||
pub mod feeds {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "feeds")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub url: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub title: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
/// The channel's first <itunes:category>, for the Directory.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub category: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub etag: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub last_modified: Option<String>,
|
||||
pub last_checked: Option<i64>,
|
||||
pub ttl_mins: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub last_error: Option<String>,
|
||||
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
||||
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
||||
/// later) from a feed that has been down for a day.
|
||||
pub error_since: Option<i64>,
|
||||
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub orphaned: bool,
|
||||
/// The OPML subscription this feed came from.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub group_id: Option<String>,
|
||||
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
||||
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
||||
/// are re-derived instead. Customising one promotes it to config.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub managed: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod entries {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "entries")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub title: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub link: Option<String>,
|
||||
pub published: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
pub first_seen: i64,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
pub season: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod enclosures {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "enclosures")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub guid: String,
|
||||
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
|
||||
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
||||
#[sea_orm(unique, column_type = "Text")]
|
||||
pub url: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub mime: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub path: Option<String>,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub state: String,
|
||||
#[sea_orm(default_value = 0)]
|
||||
pub bytes_done: i64,
|
||||
pub downloaded_at: Option<i64>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod users {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
|
||||
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
||||
/// check, and leaving it empty is not the same as leaving it unset.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub pass_hash: Option<String>,
|
||||
#[sea_orm(default_value = false)]
|
||||
pub is_admin: bool,
|
||||
/// For whoever maintains the server. NULL where it is not known.
|
||||
pub created: Option<i64>,
|
||||
pub last_login: Option<i64>,
|
||||
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// A table of one person's rows, gone when they are.
|
||||
macro_rules! owned_by_user {
|
||||
() => {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
};
|
||||
}
|
||||
|
||||
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
||||
/// part that is not. NULL in a column means: follow the feed's own setting.
|
||||
pub mod subscriptions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "subscriptions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
/// JSON array of strings; NULL follows the feed.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub keywords: Option<String>,
|
||||
pub auto_download: Option<bool>,
|
||||
pub allow_explicit: Option<bool>,
|
||||
pub max_new_per_check: Option<i64>,
|
||||
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// Read, kept and how far in. One row per person per item, created on first touch; an item
|
||||
/// nobody has touched has no row at all, which is what unread means.
|
||||
pub mod entry_state {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "entry_state")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub user_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
#[sea_orm(default_value = false)]
|
||||
pub read: bool,
|
||||
#[sea_orm(default_value = false)]
|
||||
pub flagged: bool,
|
||||
#[sea_orm(default_value = 0)]
|
||||
pub position: i64,
|
||||
/// The length this person's player measured, beside the position it is measured against.
|
||||
pub duration: Option<i64>,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
pub mod sessions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "sessions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub token: String,
|
||||
pub user_id: i64,
|
||||
pub seen: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||
pub mod catalogue {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "catalogue")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub id: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub spec: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
||||
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
||||
/// configuration has moved in (issue #18).
|
||||
pub mod settings {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub name: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
77
src/feed.rs
77
src/feed.rs
@@ -698,10 +698,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||
non_empty(content)
|
||||
.filter(|c| !starts_mid_tag(c))
|
||||
.or_else(|| non_empty(description))
|
||||
.or_else(|| non_empty(content))
|
||||
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||
Some(c) => Some(match subtitle(&c, description) {
|
||||
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||
None => c,
|
||||
}),
|
||||
None => non_empty(description).or_else(|| non_empty(content)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||
/// description found in the body is not shown twice.
|
||||
///
|
||||
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||
/// `entries` column.
|
||||
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||
let d = title_text(description)?;
|
||||
if d.contains('<') || d.chars().count() > 300 {
|
||||
return None;
|
||||
}
|
||||
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||
// description ends in "…" or "[...]".
|
||||
let words = |s: &str| {
|
||||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||
};
|
||||
let want = words(&d);
|
||||
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||
}
|
||||
|
||||
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||
@@ -841,12 +882,36 @@ mod tests {
|
||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
||||
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||
Some("<p>The post.</p>"),
|
||||
"a description with markup in it is notes, not a subtitle",
|
||||
);
|
||||
assert_eq!(
|
||||
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||
);
|
||||
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||
for d in ["This week we talk about tape, drums and a very long list.",
|
||||
"This week we talk about tape, drums…",
|
||||
"This week we talk about\ntape [...]"] {
|
||||
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||
}
|
||||
let long = "word ".repeat(80);
|
||||
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||
"a long description is notes, not a subtitle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_level_explicit_overrides_entries() {
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
|
||||
18
src/ipc.rs
18
src/ipc.rs
@@ -88,6 +88,11 @@ pub enum Command {
|
||||
feed: Option<String>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(default)]
|
||||
@@ -176,7 +181,9 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
||||
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
||||
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
||||
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
|
||||
/// A future, since reading the counts is a database query.
|
||||
pub type StatusFn =
|
||||
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
|
||||
|
||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||
pub async fn serve(
|
||||
@@ -249,7 +256,7 @@ async fn handle(
|
||||
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
||||
Ok(Command::Status) => {
|
||||
tracing::info!(target: "ipx::io", "-> {line}");
|
||||
let ev = status();
|
||||
let ev = status().await;
|
||||
if let Ok(json) = serde_json::to_string(&ev) {
|
||||
tracing::info!(target: "ipx::io", "<- {json}");
|
||||
}
|
||||
@@ -299,10 +306,10 @@ mod tests {
|
||||
#[test]
|
||||
fn commands_parse_from_the_wire_form() {
|
||||
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();
|
||||
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();
|
||||
assert!(matches!(got, Command::Reap { dry_run: true }));
|
||||
@@ -366,7 +373,8 @@ mod tests {
|
||||
// Another client, watching a scan: it must not be handed someone else's answer, which
|
||||
// would end its session.
|
||||
let mut watcher = events.subscribe();
|
||||
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
|
||||
let status: StatusFn =
|
||||
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
|
||||
let (client, server) = UnixStream::pair().unwrap();
|
||||
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
||||
|
||||
|
||||
458
src/main.rs
458
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -45,28 +45,28 @@ pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
let mut report = Report::default();
|
||||
|
||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||
for (id, path) in db.missing_files()? {
|
||||
for (id, path) in db.missing_files().await? {
|
||||
if !dry_run {
|
||||
db.mark_reaped(id)?;
|
||||
db.mark_reaped(id).await?;
|
||||
}
|
||||
tracing::debug!(path, "file gone, row reaped");
|
||||
report.reconciled += 1;
|
||||
}
|
||||
|
||||
let candidates = db.reap_candidates()?;
|
||||
let candidates = db.reap_candidates().await?;
|
||||
|
||||
if cfg.general.max_age_days > 0 {
|
||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||
report.aged_out = aged(&candidates, cutoff);
|
||||
for c in &report.aged_out {
|
||||
report.bytes_freed += remove(db, c, dry_run)?;
|
||||
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||
}
|
||||
if !dry_run {
|
||||
report.entries_pruned = db.prune_entries(cutoff)?;
|
||||
report.entries_pruned = db.prune_entries(cutoff).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +81,14 @@ pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||
report.over_quota = pick(&remaining, total, limit);
|
||||
for c in &report.over_quota {
|
||||
report.bytes_freed += remove(db, c, dry_run)?;
|
||||
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
if dry_run {
|
||||
return Ok(c.bytes.max(0) as u64);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||
return Ok(0);
|
||||
}
|
||||
db.mark_reaped(c.id)?;
|
||||
db.mark_reaped(c.id).await?;
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
@@ -149,12 +149,12 @@ mod tests {
|
||||
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||
#[tokio::test]
|
||||
async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||
// One file serves both subscribers, so it takes both of them to release it.
|
||||
let db = Db::memory().unwrap();
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||
('f', 'keep', 0),
|
||||
@@ -163,20 +163,20 @@ mod tests {
|
||||
('f', 'read', 0);
|
||||
-- Starred by one of the two, so it stays whatever the other thinks.
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||
(1, 'f', 'keep', 1, 1),
|
||||
(2, 'f', 'keep', 1, 0),
|
||||
(1, 'f', 'half', 1, 0),
|
||||
(1, 'f', 'read', 1, 0),
|
||||
(2, 'f', 'read', 1, 0);
|
||||
(1, 'f', 'keep', true, true),
|
||||
(2, 'f', 'keep', true, false),
|
||||
(1, 'f', 'half', true, false),
|
||||
(1, 'f', 'read', true, false),
|
||||
(2, 'f', 'read', true, false);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||
)
|
||||
).await
|
||||
.unwrap();
|
||||
|
||||
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
|
||||
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![4, 2, 3],
|
||||
@@ -185,12 +185,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||
('f', 'has-file', 100),
|
||||
('f', 'no-file', 100),
|
||||
@@ -198,9 +198,9 @@ mod tests {
|
||||
('f', 'recent', 900);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||
)
|
||||
).await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.prune_entries(500).unwrap(), 1, "only the old, fileless, unflagged one");
|
||||
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||
}
|
||||
}
|
||||
|
||||
252
src/web.rs
252
src/web.rs
@@ -16,7 +16,6 @@ use serde::Deserialize;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeFile;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
@@ -28,9 +27,10 @@ const COOKIE: &str = "ipx_token";
|
||||
#[derive(Clone)]
|
||||
pub struct WebState {
|
||||
pub ctx: Arc<Ctx>,
|
||||
pub config_path: PathBuf,
|
||||
pub cmds: mpsc::Sender<Command>,
|
||||
pub events: broadcast::Sender<Event>,
|
||||
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||
pub access: Arc<crate::access::Keys>,
|
||||
}
|
||||
|
||||
pub fn router(state: WebState) -> Router {
|
||||
@@ -101,22 +101,22 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
let token = cfg.web.token.clone();
|
||||
|
||||
// 1. A header, but only from a hop we were told to believe.
|
||||
let vouched = vouched_name(&cfg, &req);
|
||||
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||
|
||||
let mut set_cookie: Option<String> = None;
|
||||
let mut user = None;
|
||||
|
||||
if let Some(name) = vouched {
|
||||
user = match state.ctx.db.user_by_name(&name) {
|
||||
user = match state.ctx.db.user_by_name(&name).await {
|
||||
Ok(Some(u)) => Some(u),
|
||||
Ok(None) if cfg.web.auto_create_users => {
|
||||
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
||||
state
|
||||
.ctx
|
||||
.db
|
||||
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
|
||||
.ok()
|
||||
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
|
||||
// The first account made is the admin.
|
||||
let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
|
||||
match state.ctx.db.create_user(&name, None, first).await {
|
||||
Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
||||
@@ -130,7 +130,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
|
||||
// must not turn anyone away, so its error goes unanswered.
|
||||
if let Some(u) = &user {
|
||||
let _ = state.ctx.db.signed_in(u.id);
|
||||
let _ = state.ctx.db.signed_in(u.id).await;
|
||||
}
|
||||
}
|
||||
let by_proxy = user.is_some();
|
||||
@@ -141,7 +141,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
user = state
|
||||
.ctx
|
||||
.db
|
||||
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
|
||||
.session_user(&sid, cfg.web.session_days.max(1) * 86_400).await
|
||||
.unwrap_or(None);
|
||||
}
|
||||
}
|
||||
@@ -155,11 +155,11 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
||||
if user.is_none() && !token.is_empty() {
|
||||
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
||||
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
||||
user = admin_user(&state);
|
||||
user = admin_user(&state).await;
|
||||
if from_query.is_some() {
|
||||
// The token link is a sign-in; the cookie it leaves behind is not one each time.
|
||||
if let Some(u) = &user {
|
||||
let _ = state.ctx.db.signed_in(u.id);
|
||||
let _ = state.ctx.db.signed_in(u.id).await;
|
||||
}
|
||||
set_cookie = Some(format!(
|
||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
||||
@@ -203,20 +203,31 @@ struct Proxied(bool);
|
||||
|
||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||
/// be whoever they liked.
|
||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||
///
|
||||
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||
/// the future unsendable, as a body is not `Sync`.
|
||||
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||
return None;
|
||||
}
|
||||
req.headers()
|
||||
.get(&cfg.web.trusted_header)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::auth::name_from_header)
|
||||
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||
let Some((team, aud)) = cfg.web.access() else {
|
||||
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||
};
|
||||
// The plain header still has to be there, as it is what switches this path on for a
|
||||
// request; who it names is the token's to say.
|
||||
header(&cfg.web.trusted_header)?;
|
||||
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||
}
|
||||
|
||||
fn peer(req: &Request) -> String {
|
||||
req.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||
@@ -247,8 +258,8 @@ fn cookie(req: &Request, name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The account the shared token stands for: the first admin, or the first user at all.
|
||||
fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
||||
let users = state.ctx.db.users().ok()?;
|
||||
async fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
||||
let users = state.ctx.db.users().await.ok()?;
|
||||
users
|
||||
.iter()
|
||||
.find(|u| u.is_admin)
|
||||
@@ -267,7 +278,7 @@ async fn login(
|
||||
Json(body): Json<Credentials>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = body.name.trim().to_ascii_lowercase();
|
||||
let user = state.ctx.db.user_by_name(&name)?;
|
||||
let user = state.ctx.db.user_by_name(&name).await?;
|
||||
// The same answer either way: whether a name exists is not something to leak.
|
||||
let ok = user
|
||||
.as_ref()
|
||||
@@ -280,8 +291,8 @@ async fn login(
|
||||
|
||||
let user = user.expect("verified above");
|
||||
let token = crate::auth::new_session_token();
|
||||
state.ctx.db.create_session(user.id, &token)?;
|
||||
state.ctx.db.signed_in(user.id)?;
|
||||
state.ctx.db.create_session(user.id, &token).await?;
|
||||
state.ctx.db.signed_in(user.id).await?;
|
||||
tracing::info!(user = %user.name, "signed in");
|
||||
|
||||
let days = state.ctx.cfg().web.session_days.max(1);
|
||||
@@ -298,7 +309,7 @@ async fn login(
|
||||
|
||||
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||
let _ = state.ctx.db.delete_session(&sid);
|
||||
let _ = state.ctx.db.delete_session(&sid).await;
|
||||
}
|
||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||
for c in [
|
||||
@@ -320,7 +331,7 @@ async fn me(
|
||||
) -> Json<serde_json::Value> {
|
||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||
let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default();
|
||||
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
Json(serde_json::json!({
|
||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||
}))
|
||||
@@ -343,7 +354,7 @@ async fn patch_me(
|
||||
if !theme_ok(&body.theme, &body.mode) {
|
||||
return Err(ApiError::bad_request("not a theme"));
|
||||
}
|
||||
state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?;
|
||||
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -378,7 +389,7 @@ async fn list_users(
|
||||
let users: Vec<_> = state
|
||||
.ctx
|
||||
.db
|
||||
.users()?
|
||||
.users().await?
|
||||
.iter()
|
||||
.map(|u| {
|
||||
serde_json::json!({
|
||||
@@ -409,7 +420,7 @@ async fn add_user(
|
||||
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
||||
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
||||
})?;
|
||||
if state.ctx.db.user_by_name(&name)?.is_some() {
|
||||
if state.ctx.db.user_by_name(&name).await?.is_some() {
|
||||
return Err(ApiError::bad_request(format!("{name} already exists")));
|
||||
}
|
||||
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
||||
@@ -418,7 +429,7 @@ async fn add_user(
|
||||
} else {
|
||||
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
||||
};
|
||||
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
|
||||
state.ctx.db.create_user(&name, hash.as_deref(), body.admin).await?;
|
||||
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
@@ -435,7 +446,7 @@ async fn patch_user(
|
||||
Json(body): Json<UserPatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
require_admin(&user)?;
|
||||
let users = state.ctx.db.users()?;
|
||||
let users = state.ctx.db.users().await?;
|
||||
let target = users
|
||||
.iter()
|
||||
.find(|u| u.id == id)
|
||||
@@ -446,7 +457,7 @@ async fn patch_user(
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
state.ctx.db.set_admin(id, body.admin)?;
|
||||
state.ctx.db.set_admin(id, body.admin).await?;
|
||||
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -457,7 +468,7 @@ async fn remove_user(
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
require_admin(&user)?;
|
||||
let users = state.ctx.db.users()?;
|
||||
let users = state.ctx.db.users().await?;
|
||||
let target = users
|
||||
.iter()
|
||||
.find(|u| u.id == id)
|
||||
@@ -468,7 +479,7 @@ async fn remove_user(
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
state.ctx.db.delete_user(id)?;
|
||||
state.ctx.db.delete_user(id).await?;
|
||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -476,7 +487,7 @@ async fn remove_user(
|
||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||
/// the form only made it look as if they were not.
|
||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
||||
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||
@@ -510,7 +521,7 @@ async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Res
|
||||
if !user.is_admin {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
|
||||
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
||||
}
|
||||
@@ -584,7 +595,7 @@ const ADMIN_LINK: &str = "<a id=admin ";
|
||||
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
||||
/// script instead showed it for a moment on every load, until /api/me answered.
|
||||
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
||||
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
|
||||
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
||||
}
|
||||
|
||||
@@ -672,16 +683,16 @@ async fn feeds(
|
||||
let cfg = state.ctx.cfg();
|
||||
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
||||
// What comes back is only the part of it this person subscribes to.
|
||||
let subs = crate::subscriptions(&state.ctx)?;
|
||||
let subs = crate::subscriptions(&state.ctx).await?;
|
||||
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
||||
.ctx
|
||||
.db
|
||||
.subscriptions_for(user.id)?
|
||||
.subscriptions_for(user.id).await?
|
||||
.into_iter()
|
||||
.map(|s| (s.feed_id.clone(), s))
|
||||
.collect();
|
||||
let counts = state.ctx.db.subscriber_counts()?;
|
||||
let pinned = state.ctx.db.pinned_feeds(user.id)?;
|
||||
let counts = state.ctx.db.subscriber_counts().await?;
|
||||
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
||||
let mut out = Vec::with_capacity(mine.len());
|
||||
for sub in &subs {
|
||||
let (id, feed) = (&sub.id, &sub.cfg);
|
||||
@@ -689,8 +700,8 @@ async fn feeds(
|
||||
// the same fallback the scanner uses (`Db::subscribers`).
|
||||
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
||||
let Some(mine) = mine.get(id) else { continue };
|
||||
let s = state.ctx.db.feed_summary(id)?;
|
||||
let st = state.ctx.db.http_state(id)?;
|
||||
let s = state.ctx.db.feed_summary(id).await?;
|
||||
let st = state.ctx.db.http_state(id).await?;
|
||||
out.push(FeedRow {
|
||||
id: id.clone(),
|
||||
url: feed.url.clone(),
|
||||
@@ -740,7 +751,7 @@ async fn feeds(
|
||||
last_error: s.last_error,
|
||||
entries: s.entries,
|
||||
downloaded: s.downloaded,
|
||||
unread: state.ctx.db.unread_count(user.id, id)?,
|
||||
unread: state.ctx.db.unread_count(user.id, id).await?,
|
||||
subscribers: counts.get(id).copied().unwrap_or(0),
|
||||
pinned: pinned.contains(id),
|
||||
});
|
||||
@@ -800,13 +811,13 @@ struct PopularRow {
|
||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||
/// feeds inside it and never itself: both lists are for finding a show.
|
||||
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
let db = &state.ctx.db;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
||||
let counts = db.subscriber_counts()?;
|
||||
let media = db.media_feeds()?;
|
||||
let catalogue = crate::subscriptions(&state.ctx)?;
|
||||
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let counts = db.subscriber_counts().await?;
|
||||
let media = db.media_feeds().await?;
|
||||
let catalogue = crate::subscriptions(&state.ctx).await?;
|
||||
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
||||
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||
let is_folder: std::collections::HashSet<&str> =
|
||||
@@ -823,7 +834,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let sum = db.feed_summary(&s.id)?;
|
||||
let sum = db.feed_summary(&s.id).await?;
|
||||
let subscribed = mine.contains(&s.id);
|
||||
out.push(PopularRow {
|
||||
id: s.id.clone(),
|
||||
@@ -844,7 +855,7 @@ async fn get_popular(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id)?;
|
||||
let mut rows = popular(&state, user.id).await?;
|
||||
rows.truncate(10);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -854,7 +865,7 @@ async fn get_directory(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id)?;
|
||||
let mut rows = popular(&state, user.id).await?;
|
||||
rows.sort_by_key(sort_name);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -870,10 +881,10 @@ async fn subscribe_popular(
|
||||
user: crate::db::User,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
||||
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
|
||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||
}
|
||||
state.ctx.db.subscribe(user.id, &id)?;
|
||||
state.ctx.db.subscribe(user.id, &id).await?;
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
@@ -1124,7 +1135,7 @@ async fn entries(
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
entry_page(&state, user.id, Some(&id), &page)
|
||||
entry_page(&state, user.id, Some(&id), &page).await
|
||||
}
|
||||
|
||||
/// Every subscribed feed's items together, newest first: All Subscriptions.
|
||||
@@ -1133,11 +1144,11 @@ async fn all_entries(
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
entry_page(&state, user.id, None, &page)
|
||||
entry_page(&state, user.id, None, &page).await
|
||||
}
|
||||
|
||||
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
||||
fn entry_page(
|
||||
async fn entry_page(
|
||||
state: &WebState,
|
||||
user_id: i64,
|
||||
feed: Option<&str>,
|
||||
@@ -1146,19 +1157,16 @@ fn entry_page(
|
||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
||||
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
let db = &state.ctx.db;
|
||||
let order = crate::db::order_sql(
|
||||
page.sort.as_deref().unwrap_or("published"),
|
||||
page.dir.as_deref().unwrap_or("desc"),
|
||||
);
|
||||
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
|
||||
let mut rows =
|
||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
||||
let mut sanitizer = feed_sanitizer();
|
||||
for row in &mut rows {
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
||||
}
|
||||
}
|
||||
let total = db.count_in(user_id, feed, filter, search)?;
|
||||
let total = db.count_in(user_id, feed, filter, search).await?;
|
||||
Ok(Json(EntryPage { total, entries: rows }))
|
||||
}
|
||||
|
||||
@@ -1200,10 +1208,10 @@ struct NewFeed {
|
||||
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
|
||||
/// goes on your subscription, and before the first scan, which would otherwise skip every
|
||||
/// explicit item.
|
||||
fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
|
||||
async fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
|
||||
if allow {
|
||||
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
|
||||
state.ctx.db.set_subscription(user_id, &sub)?;
|
||||
state.ctx.db.set_subscription(user_id, &sub).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1217,14 +1225,14 @@ async fn add_feed(
|
||||
let url = crate::feed::expand_input(&body.url);
|
||||
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
||||
// second copy on disk, just another name against the same feed.
|
||||
if let Some(existing) = crate::subscriptions(&state.ctx)?
|
||||
if let Some(existing) = crate::subscriptions(&state.ctx).await?
|
||||
.into_iter()
|
||||
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
||||
{
|
||||
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
|
||||
state.ctx.db.subscribe(user.id, &existing.id)?;
|
||||
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
|
||||
state.ctx.db.subscribe(user.id, &existing.id).await?;
|
||||
if !already {
|
||||
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit)?;
|
||||
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
|
||||
}
|
||||
scan_soon(&state, Some(existing.id.clone())).await;
|
||||
return Ok(Json(
|
||||
@@ -1232,10 +1240,9 @@ async fn add_feed(
|
||||
));
|
||||
}
|
||||
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
state.ctx.db.subscribe(user.id, &id)?;
|
||||
explicit_on_add(&state, user.id, &id, body.allow_explicit)?;
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
state.ctx.db.subscribe(user.id, &id).await?;
|
||||
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
||||
scan_soon(&state, Some(id.clone())).await;
|
||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||
}
|
||||
@@ -1245,7 +1252,7 @@ async fn add_feed(
|
||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1288,17 +1295,17 @@ async fn patch_feed(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
|
||||
if let Some(on) = body.pinned
|
||||
&& !state.ctx.db.set_pinned(user.id, &id, on)?
|
||||
&& !state.ctx.db.set_pinned(user.id, &id, on).await?
|
||||
{
|
||||
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
||||
}
|
||||
// What one person wants -- which items, whether to fetch them, how many at a time --
|
||||
// is theirs. It goes on their subscription and nobody else sees the change.
|
||||
if state.ctx.db.subscription(user.id, &id)?.is_some() {
|
||||
if state.ctx.db.subscription(user.id, &id).await?.is_some() {
|
||||
let mut mine = state
|
||||
.ctx
|
||||
.db
|
||||
.subscription(user.id, &id)?
|
||||
.subscription(user.id, &id).await?
|
||||
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
|
||||
let mut touched = false;
|
||||
if let Some(v) = body.keywords.clone() {
|
||||
@@ -1318,7 +1325,7 @@ async fn patch_feed(
|
||||
touched = true;
|
||||
}
|
||||
if touched {
|
||||
state.ctx.db.set_subscription(user.id, &mine)?;
|
||||
state.ctx.db.set_subscription(user.id, &mine).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1339,13 +1346,13 @@ async fn patch_feed(
|
||||
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
||||
// entry: promote it, so the config holds your decisions and nothing else.
|
||||
if !cfg.feeds.contains_key(&id) {
|
||||
let subs = crate::subscriptions(&state.ctx)?;
|
||||
let subs = crate::subscriptions(&state.ctx).await?;
|
||||
let found = subs
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
||||
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
||||
state.ctx.db.unmanage(&id)?;
|
||||
state.ctx.db.unmanage(&id).await?;
|
||||
}
|
||||
|
||||
let checked = match &body.url {
|
||||
@@ -1381,12 +1388,11 @@ async fn patch_feed(
|
||||
if let Some(v) = body.category {
|
||||
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||
}
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
if url_changed {
|
||||
// Refreshing a rotated auth token is the common case; entries and download history
|
||||
// are keyed by feed id, so they survive the change.
|
||||
state.ctx.db.clear_validators(&id)?;
|
||||
state.ctx.db.clear_validators(&id).await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1398,14 +1404,14 @@ async fn remove_feed(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
||||
// else's alone.
|
||||
state.ctx.db.unsubscribe(user.id, &id)?;
|
||||
for child in crate::subscriptions(&state.ctx)?
|
||||
state.ctx.db.unsubscribe(user.id, &id).await?;
|
||||
for child in crate::subscriptions(&state.ctx).await?
|
||||
.iter()
|
||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
state.ctx.db.unsubscribe(user.id, &child.id)?;
|
||||
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
||||
}
|
||||
if state.ctx.db.subscriber_counts()?.contains_key(&id) {
|
||||
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
@@ -1415,12 +1421,11 @@ async fn remove_feed(
|
||||
if cfg.feeds.remove(&id).is_none() {
|
||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||
// read unless you unsubscribe from the OPML itself.
|
||||
state.ctx.db.drop_managed(&id)?;
|
||||
state.ctx.db.drop_managed(&id).await?;
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
crate::retire_group(&state.ctx, &id)?;
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
crate::retire_group(&state.ctx, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1438,10 +1443,10 @@ async fn set_flags(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
use crate::db::EntryFlag;
|
||||
if let Some(v) = body.read {
|
||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v)?;
|
||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
|
||||
}
|
||||
if let Some(v) = body.flagged {
|
||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v)?;
|
||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1456,12 +1461,12 @@ async fn download_now(
|
||||
let enc = state
|
||||
.ctx
|
||||
.db
|
||||
.enclosure(id)?
|
||||
.enclosure(id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||
if enc.path.is_some() {
|
||||
return Ok(StatusCode::NO_CONTENT); // Already here.
|
||||
}
|
||||
state.ctx.db.requeue(id)?;
|
||||
state.ctx.db.requeue(id).await?;
|
||||
state
|
||||
.cmds
|
||||
.send(Command::Download { enclosure: id })
|
||||
@@ -1485,13 +1490,13 @@ async fn delete_file(
|
||||
let enc = state
|
||||
.ctx
|
||||
.db
|
||||
.enclosure(id)?
|
||||
.enclosure(id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||
|
||||
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
||||
// it, once, and let them decide.
|
||||
if !q.force {
|
||||
let (starred, unread) = state.ctx.db.others_wanting(id, user.id)?;
|
||||
let (starred, unread) = state.ctx.db.others_wanting(id, user.id).await?;
|
||||
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
|
||||
let complaint = match (starred, unread) {
|
||||
(0, 0) => None,
|
||||
@@ -1517,7 +1522,7 @@ async fn delete_file(
|
||||
return Err(e.into());
|
||||
}
|
||||
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
|
||||
state.ctx.db.mark_reaped(id)?;
|
||||
state.ctx.db.mark_reaped(id).await?;
|
||||
state.events.send(Event::Reaped {
|
||||
path: enc.path.unwrap_or_default(),
|
||||
bytes: enc.length.unwrap_or(0).max(0) as u64,
|
||||
@@ -1535,11 +1540,21 @@ struct FetchBody {
|
||||
|
||||
async fn fetch_now(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<FetchBody>,
|
||||
) -> 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
|
||||
.cmds
|
||||
.send(Command::Fetch { feed: body.feed, force: body.force })
|
||||
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||
Ok(StatusCode::ACCEPTED)
|
||||
@@ -1571,7 +1586,7 @@ async fn media(
|
||||
Path(id): Path<i64>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
|
||||
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
|
||||
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
||||
};
|
||||
let Some(path) = enc.path else {
|
||||
@@ -1596,7 +1611,7 @@ async fn set_position(
|
||||
user: crate::db::User,
|
||||
Json(body): Json<Position>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration)?;
|
||||
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1608,12 +1623,12 @@ async fn read_all(
|
||||
// A subscription's own row has no entries, so marking it read means everything under it.
|
||||
let mut ids = vec![id.clone()];
|
||||
ids.extend(
|
||||
crate::subscriptions(&state.ctx)?
|
||||
crate::subscriptions(&state.ctx).await?
|
||||
.into_iter()
|
||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||
.map(|s| s.id),
|
||||
);
|
||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
||||
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
|
||||
Ok(Json(serde_json::json!({ "marked": n })))
|
||||
}
|
||||
|
||||
@@ -1624,8 +1639,8 @@ async fn read_all_mine(
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let ids: Vec<String> =
|
||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
||||
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
|
||||
Ok(Json(serde_json::json!({ "marked": n })))
|
||||
}
|
||||
|
||||
@@ -1646,9 +1661,9 @@ async fn download_latest(
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<HowMany>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
|
||||
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
|
||||
for enc in &ids {
|
||||
state.ctx.db.requeue(*enc)?;
|
||||
state.ctx.db.requeue(*enc).await?;
|
||||
state
|
||||
.cmds
|
||||
.send(Command::Download { enclosure: *enc })
|
||||
@@ -1666,7 +1681,7 @@ async fn export_opml(
|
||||
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
|
||||
// not yours to download. This used to export config.toml to whoever asked.
|
||||
let mine: std::collections::HashSet<String> =
|
||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
||||
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let mut doc = opml::OPML {
|
||||
head: Some(opml::Head {
|
||||
title: Some("ipx subscriptions".into()),
|
||||
@@ -1674,7 +1689,7 @@ async fn export_opml(
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
for s in crate::subscriptions(&state.ctx)? {
|
||||
for s in crate::subscriptions(&state.ctx).await? {
|
||||
// A feed from an OPML subscription comes back with the OPML itself.
|
||||
if s.managed || !mine.contains(&s.id) {
|
||||
continue;
|
||||
@@ -1682,7 +1697,7 @@ async fn export_opml(
|
||||
let title = state
|
||||
.ctx
|
||||
.db
|
||||
.feed_summary(&s.id)
|
||||
.feed_summary(&s.id).await
|
||||
.ok()
|
||||
.and_then(|sum| sum.title)
|
||||
.unwrap_or_else(|| s.id.clone());
|
||||
@@ -1716,7 +1731,7 @@ async fn import_opml(
|
||||
// file arrives as text, is read here, and is gone when the request ends.
|
||||
let doc = opml::OPML::from_str(&body.xml)
|
||||
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
||||
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id)?;
|
||||
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
|
||||
if added > 0 {
|
||||
scan_soon(&state, None).await;
|
||||
}
|
||||
@@ -1790,8 +1805,7 @@ async fn patch_settings(
|
||||
if let Some(v) = body.max_age_days {
|
||||
cfg.general.max_age_days = v;
|
||||
}
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
state.ctx.store_cfg(cfg).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
96
tests/contrast.js
Normal file
96
tests/contrast.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
|
||||
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||
//
|
||||
// node tests/contrast.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
||||
const blocks = {};
|
||||
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||
const vars = {};
|
||||
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||
}
|
||||
|
||||
const lum = h => {
|
||||
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
||||
};
|
||||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||
|
||||
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||
const RULES = [
|
||||
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
|
||||
['ink', ['accent'], 4.5], // a primary button's label
|
||||
['ink', ['accent2'], 4.5], // the unread count on its badge
|
||||
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
|
||||
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
|
||||
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
|
||||
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||
];
|
||||
|
||||
const base = blocks[':root'];
|
||||
const themes = {};
|
||||
for (const sel of Object.keys(blocks)) {
|
||||
const t = sel.match(/data-theme="(\w+)"/);
|
||||
if (!t) continue;
|
||||
const light = /data-mode="light"/.test(sel);
|
||||
// A theme's dark half is :root under its own block; its light half adds the light block.
|
||||
const name = `${t[1]}${light ? ' light' : ''}`;
|
||||
themes[name] = light
|
||||
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
|
||||
: { ...base, ...blocks[sel] };
|
||||
}
|
||||
themes['modern'] = base;
|
||||
|
||||
let bad = 0;
|
||||
for (const [name, p] of Object.entries(themes)) {
|
||||
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||
const r = ratio(p[fg], p[g]);
|
||||
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||
}
|
||||
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
|
||||
}
|
||||
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
|
||||
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
|
||||
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
||||
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
||||
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
||||
const rgba = (sel, n) => {
|
||||
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
||||
return [[+m[1], +m[2], +m[3]], +m[4]];
|
||||
};
|
||||
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
||||
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
||||
const p = themes[name];
|
||||
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
||||
const worst = {};
|
||||
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||
let g = rgb(p.bg);
|
||||
for (const [w, [c, a]] of tint) {
|
||||
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||
g = over(c, a * Math.max(0, 1 - d), g);
|
||||
}
|
||||
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it.
|
||||
for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]])
|
||||
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
||||
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
||||
}
|
||||
}
|
||||
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
||||
for (const [k, r] of Object.entries(worst))
|
||||
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
|
||||
}
|
||||
if (bad) process.exit(1);
|
||||
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);
|
||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-forger.der
Normal file
Binary file not shown.
BIN
tests/data/access-test.der
Normal file
BIN
tests/data/access-test.der
Normal file
Binary file not shown.
12
tests/data/access-test.jwks.json
Normal file
12
tests/data/access-test.jwks.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kid": "k1",
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"e": "AQAB",
|
||||
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
|
||||
}
|
||||
]
|
||||
}
|
||||
131
tests/dom-stub.js
Normal file
131
tests/dom-stub.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
||||
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
||||
// to find what it reaches for, and no more.
|
||||
//
|
||||
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
||||
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
||||
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
||||
// nothing about whether it worked.
|
||||
const vm = require('vm');
|
||||
|
||||
function makeContext(html, script, { media = false } = {}) {
|
||||
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||
const missing = [];
|
||||
|
||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||
|
||||
const body = el('body');
|
||||
const audio = media ? makeMediaElement() : null;
|
||||
if (media) {
|
||||
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
||||
// one has to be a real set rather than something that always says no.
|
||||
const classes = new Set();
|
||||
body.classList = {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelector(sel) {
|
||||
if (sel === '#audio' && audio) return audio;
|
||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||
return el(sel);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => el('created'),
|
||||
addEventListener(){}, body,
|
||||
documentElement: { dataset: {} },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
document, console,
|
||||
window: { isSecureContext: false, addEventListener(){} },
|
||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||
fetch: (url) => Promise.resolve({
|
||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||
json: () => Promise.resolve(
|
||||
String(url).includes('/api/settings')
|
||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||
: String(url).includes('/api/users')
|
||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||
: /\/api\/(popular|directory)/.test(String(url))
|
||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||
}),
|
||||
EventSource: function () { this.close = () => {}; },
|
||||
MediaMetadata: function () {},
|
||||
Blob: function () {},
|
||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
confirm: () => false, prompt: () => null, alert(){},
|
||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||
};
|
||||
if (media) {
|
||||
ctx.HTMLMediaElement = MediaElement;
|
||||
ctx.Event = Event;
|
||||
ctx.isFinite = isFinite;
|
||||
ctx.CSS = { escape: s => String(s) };
|
||||
}
|
||||
ctx.globalThis = ctx;
|
||||
ctx.window.location = { href: '', hash: '' };
|
||||
ctx.location = ctx.window.location;
|
||||
|
||||
return { ctx, missing, audio, body, ids };
|
||||
}
|
||||
|
||||
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
||||
|
||||
function Event(type) { this.type = type; }
|
||||
|
||||
function MediaElement() {
|
||||
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
||||
this._ready = 0; this._vol = 1; this._rate = 1;
|
||||
this._listeners = {};
|
||||
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
||||
// Every call that reached the real element, so a test can say the host took over rather than
|
||||
// the element quietly playing as well.
|
||||
this.calls = [];
|
||||
}
|
||||
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
||||
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
||||
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
||||
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
||||
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
||||
};
|
||||
MediaElement.prototype.dispatchEvent = function(ev){
|
||||
for (const l of (this._listeners[ev.type] || []).slice()) {
|
||||
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
||||
l.fn(ev);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
||||
MediaElement.prototype.setAttribute = function(){};
|
||||
MediaElement.prototype.getAttribute = function(){ return null; };
|
||||
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
||||
configurable: true,
|
||||
get(){ return this[field]; },
|
||||
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
||||
});
|
||||
accessor('src', '_src', true);
|
||||
accessor('currentTime', '_t', true);
|
||||
accessor('volume', '_vol');
|
||||
accessor('playbackRate', '_rate');
|
||||
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
||||
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
||||
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
||||
|
||||
function makeMediaElement(){ return new MediaElement(); }
|
||||
|
||||
module.exports = { makeContext, vm };
|
||||
117
tests/native-bridge.js
Normal file
117
tests/native-bridge.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
||||
// hand it to the host, while everything in player.ts carries on talking to the element.
|
||||
//
|
||||
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
||||
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
||||
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||
// road, with nothing to look at.
|
||||
//
|
||||
// node tests/native-bridge.js
|
||||
const { makeContext, vm } = require('./dom-stub.js');
|
||||
const { buildPage } = require('../web/build.mjs');
|
||||
|
||||
const { html, js: script } = buildPage('index.html');
|
||||
|
||||
let failed = 0;
|
||||
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||
|
||||
/* ---- a browser: nothing installs ---- */
|
||||
{
|
||||
const { ctx } = makeContext(html, script, { media: true });
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
|
||||
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
||||
const audio = ctx.document.querySelector('#audio');
|
||||
audio.src = '/media/1';
|
||||
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
||||
}
|
||||
|
||||
/* ---- inside the shell ---- */
|
||||
const posted = [];
|
||||
const { ctx, audio, body } = makeContext(html, script, { media: true });
|
||||
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||
|
||||
const last = t => [...posted].reverse().find(m => m.t === t);
|
||||
const since = () => posted.splice(0, posted.length);
|
||||
|
||||
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||
ok(last('ready'), 'the host was never told the bridge is in');
|
||||
since();
|
||||
|
||||
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
||||
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
||||
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
||||
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
||||
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
||||
|
||||
body.classList.toggle('has-video', false);
|
||||
audio.calls.length = 0;
|
||||
audio.src = '/media/42';
|
||||
|
||||
const load = last('load');
|
||||
ok(load, 'setting the src told the host nothing');
|
||||
if (load) {
|
||||
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
||||
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
||||
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
||||
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
||||
}
|
||||
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
||||
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
||||
|
||||
// player.ts sets currentTime=0 straight after the src.
|
||||
since();
|
||||
audio.currentTime = 0;
|
||||
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
|
||||
|
||||
since();
|
||||
audio.play();
|
||||
ok(last('play'), 'play did not reach the host');
|
||||
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
||||
|
||||
// The host answers, and the page must move as it would have on its own.
|
||||
let played = 0, timed = 0;
|
||||
audio.addEventListener('play', () => played++);
|
||||
audio.addEventListener('timeupdate', () => timed++);
|
||||
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
||||
ok(played === 1, 'the page never saw the host start playing');
|
||||
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
||||
|
||||
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
||||
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
||||
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
||||
|
||||
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
||||
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
||||
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
||||
|
||||
// The 15-second key: a read and a write through the shim.
|
||||
since();
|
||||
audio.currentTime -= 15;
|
||||
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
|
||||
|
||||
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||
since();
|
||||
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
|
||||
|
||||
// Closing the player has to stop the host, not just blank the element.
|
||||
since();
|
||||
audio.removeAttribute('src');
|
||||
ok(last('stop'), 'closing the player left the host playing');
|
||||
|
||||
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
||||
// buys nothing.
|
||||
since();
|
||||
audio.calls.length = 0;
|
||||
body.classList.toggle('has-video', true);
|
||||
audio.src = '/media/99';
|
||||
ok(!last('load'), 'a video was handed to the host');
|
||||
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||
|
||||
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
||||
process.exit(0);
|
||||
@@ -7,7 +7,7 @@
|
||||
// and every server-side test passed too, because the server was fine.
|
||||
//
|
||||
// node tests/page-smoke.js
|
||||
const vm = require('vm');
|
||||
const { makeContext, vm } = require('./dom-stub.js');
|
||||
const PAGE = process.argv[2] || 'index.html';
|
||||
|
||||
const { buildPage } = require('../web/build.mjs');
|
||||
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
|
||||
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||
}
|
||||
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||
|
||||
const missing = [];
|
||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||
|
||||
const document = {
|
||||
querySelector(sel) {
|
||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||
return el(sel);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => el('created'),
|
||||
addEventListener(){}, body: el('body'),
|
||||
documentElement: { dataset: {} },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
document, console,
|
||||
window: { isSecureContext: false, addEventListener(){} },
|
||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||
fetch: (url) => Promise.resolve({
|
||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||
json: () => Promise.resolve(
|
||||
String(url).includes('/api/settings')
|
||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||
: String(url).includes('/api/users')
|
||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||
: /\/api\/(popular|directory)/.test(String(url))
|
||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||
}),
|
||||
EventSource: function () { this.close = () => {}; },
|
||||
MediaMetadata: function () {},
|
||||
Blob: function () {},
|
||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
confirm: () => false, prompt: () => null, alert(){},
|
||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||
};
|
||||
ctx.globalThis = ctx;
|
||||
ctx.window.location = { href: '', hash: '' };
|
||||
ctx.location = ctx.window.location;
|
||||
const { ctx, missing } = makeContext(html, script);
|
||||
|
||||
try {
|
||||
vm.createContext(ctx);
|
||||
|
||||
@@ -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 expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||
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);
|
||||
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.
|
||||
@@ -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.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();
|
||||
});
|
||||
|
||||
342
web/app.css
342
web/app.css
@@ -12,7 +12,7 @@
|
||||
--line:#2c3849;
|
||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||
--ink:#0e131b; /* text on an accent fill */
|
||||
@@ -32,12 +32,12 @@
|
||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||
--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 */
|
||||
--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;
|
||||
--good:#2f7d4f;
|
||||
--warn:#b06f10;
|
||||
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
}
|
||||
@@ -124,7 +124,7 @@
|
||||
--dim:#c4c4c8;
|
||||
--faint:#a0a0a7;
|
||||
--accent:#81d0ff;
|
||||
--accent2:#3584e4;
|
||||
--accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
|
||||
--ink:#1d1d20;
|
||||
--good:#78e9ab;
|
||||
--warn:#ffc252;
|
||||
@@ -141,7 +141,7 @@
|
||||
--dim:#57575d;
|
||||
--faint:#66666c;
|
||||
--accent:#0461be;
|
||||
--accent2:#3584e4;
|
||||
--accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
|
||||
--ink:#ffffff;
|
||||
--good:#00753a;
|
||||
--warn:#905400;
|
||||
@@ -177,7 +177,7 @@
|
||||
--dim:#5c616c; /* fg */
|
||||
--faint:#686d78;
|
||||
--accent:#0060f0; /* link */
|
||||
--accent2:#fd7d00;
|
||||
--accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
|
||||
--ink:#ffffff;
|
||||
--good:#23794f;
|
||||
--warn:#a85400;
|
||||
@@ -196,7 +196,7 @@
|
||||
--dim:#3d3a33;
|
||||
--faint:#5a564c;
|
||||
--accent:#1a5fae;
|
||||
--accent2:#b58900;
|
||||
--accent2:#957100; /* taken down until white on it clears AA */
|
||||
--ink:#ffffff;
|
||||
--good:#216609;
|
||||
--warn:#795a00;
|
||||
@@ -210,7 +210,7 @@
|
||||
--panel:#3b4252; /* nord1 */
|
||||
--panel2:#434c5e; /* nord2 */
|
||||
--raise:#4c566a; /* nord3 */
|
||||
--line:#434c5e;
|
||||
--line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
|
||||
--fg:#eceff4; /* nord6 */
|
||||
--dim:#d8dee9; /* nord4 */
|
||||
--faint:#b4bccb;
|
||||
@@ -232,13 +232,158 @@
|
||||
--dim:#3b4252; /* nord1 */
|
||||
--faint:#4c566a; /* nord3 */
|
||||
--accent:#3a5e8a;
|
||||
--accent2:#b0674e;
|
||||
--accent2:#ab644c;
|
||||
--ink:#ffffff;
|
||||
--good:#446632;
|
||||
--warn:#7e590e;
|
||||
--bad:#9c3f49;
|
||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||
}
|
||||
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
|
||||
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||
and peach for the rest. */
|
||||
:root[data-theme="catppuccin"] {
|
||||
--bg:#1e1e2e; /* base */
|
||||
--panel:#181825; /* mantle */
|
||||
--panel2:#313244; /* surface0 */
|
||||
--raise:#45475a; /* surface1 */
|
||||
--line:#45475a;
|
||||
--fg:#cdd6f4; /* text */
|
||||
--dim:#bac2de; /* subtext1 */
|
||||
--faint:#a6adc8; /* subtext0 */
|
||||
--accent:#cba6f7; /* mauve */
|
||||
--accent2:#fab387; /* peach */
|
||||
--ink:#11111b; /* crust */
|
||||
--good:#a6e3a1;
|
||||
--warn:#f9e2af;
|
||||
--bad:#f38ba8;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||
--bg:#eff1f5; /* base */
|
||||
--panel:#e6e9ef; /* mantle */
|
||||
--panel2:#dce0e8; /* crust */
|
||||
--raise:#ccd0da; /* surface0 */
|
||||
--line:#bcc0cc; /* surface1 */
|
||||
--fg:#4c4f69; /* text */
|
||||
--dim:#5c5f77; /* subtext1 */
|
||||
--faint:#5f6276; /* subtext0 */
|
||||
--accent:#8638ec; /* mauve */
|
||||
--accent2:#c74e09; /* peach */
|
||||
--ink:#ffffff;
|
||||
--good:#3a9127;
|
||||
--warn:#925d13;
|
||||
--bad:#d00f39;
|
||||
--shadow:0 8px 28px rgba(76,79,105,.15);
|
||||
}
|
||||
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
|
||||
half as gruvbox itself does. Blue for the accent, orange for what is new. */
|
||||
:root[data-theme="gruvbox"] {
|
||||
--bg:#282828; /* bg0 */
|
||||
--panel:#1d2021; /* bg0_h */
|
||||
--panel2:#32302f; /* bg0_s */
|
||||
--raise:#3c3836; /* bg1 */
|
||||
--line:#504945; /* bg2 */
|
||||
--fg:#ebdbb2; /* fg */
|
||||
--dim:#d5c4a1; /* fg2 */
|
||||
--faint:#a89984; /* fg4 */
|
||||
--accent:#83a598; /* blue */
|
||||
--accent2:#fe8019; /* orange */
|
||||
--ink:#1d2021;
|
||||
--good:#b8bb26;
|
||||
--warn:#fabd2f;
|
||||
--bad:#fb533f;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||
--bg:#fbf1c7; /* bg0 */
|
||||
--panel:#f2e5bc; /* bg0_s */
|
||||
--panel2:#ebdbb2; /* bg1 */
|
||||
--raise:#d5c4a1; /* bg2 */
|
||||
--line:#d5c4a1;
|
||||
--fg:#3c3836; /* fg */
|
||||
--dim:#504945; /* fg2 */
|
||||
--faint:#665c54; /* fg3 */
|
||||
--accent:#076678; /* faded blue */
|
||||
--accent2:#af3a03; /* faded orange */
|
||||
--ink:#fbf1c7;
|
||||
--good:#79740e;
|
||||
--warn:#8d5c10;
|
||||
--bad:#9d0006;
|
||||
--shadow:0 8px 28px rgba(60,56,54,.15);
|
||||
}
|
||||
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
|
||||
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
|
||||
:root[data-theme="solarized"] {
|
||||
--bg:#002b36; /* base03 */
|
||||
--panel:#073642; /* base02 */
|
||||
--panel2:#0b3f4c;
|
||||
--raise:#124a58;
|
||||
--line:#1d5563;
|
||||
--fg:#eee8d5; /* base2 */
|
||||
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
|
||||
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
|
||||
--accent:#4b9fda; /* blue */
|
||||
--accent2:#b58900; /* yellow */
|
||||
--ink:#002b36;
|
||||
--good:#859900;
|
||||
--warn:#bb9315;
|
||||
--bad:#e87674;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="solarized"][data-mode="light"] {
|
||||
--bg:#fdf6e3; /* base3 */
|
||||
--panel:#eee8d5; /* base2 */
|
||||
--panel2:#e6dfca;
|
||||
--raise:#ddd5bd;
|
||||
--line:#d3cab0;
|
||||
--fg:#073642; /* base02 */
|
||||
--dim:#52666d; /* base01 */
|
||||
--faint:#54666d; /* base00 */
|
||||
--accent:#1e6da5; /* blue */
|
||||
--accent2:#cb4b16; /* orange */
|
||||
--ink:#ffffff;
|
||||
--good:#768700;
|
||||
--warn:#846400;
|
||||
--bad:#c52d2a;
|
||||
--shadow:0 8px 28px rgba(0,43,54,.14);
|
||||
}
|
||||
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
|
||||
that show. For reading, not for looks. */
|
||||
:root[data-theme="contrast"] {
|
||||
--bg:#000000;
|
||||
--panel:#000000;
|
||||
--panel2:#121212;
|
||||
--raise:#333333;
|
||||
--line:#9a9a9a;
|
||||
--fg:#ffffff;
|
||||
--dim:#ffffff;
|
||||
--faint:#d6d6d6;
|
||||
--accent:#8cc8ff;
|
||||
--accent2:#ffd400;
|
||||
--ink:#000000;
|
||||
--good:#7ee787;
|
||||
--warn:#ffd400;
|
||||
--bad:#ff9a8f;
|
||||
--shadow:0 0 0 1px #9a9a9a;
|
||||
}
|
||||
:root[data-theme="contrast"][data-mode="light"] {
|
||||
--bg:#ffffff;
|
||||
--panel:#ffffff;
|
||||
--panel2:#f0f0f0;
|
||||
--raise:#d6d6d6;
|
||||
--line:#555555;
|
||||
--fg:#000000;
|
||||
--dim:#000000;
|
||||
--faint:#303030;
|
||||
--accent:#0033a0;
|
||||
--accent2:#7a3d00;
|
||||
--ink:#ffffff;
|
||||
--good:#0a5c1c;
|
||||
--warn:#6b4500;
|
||||
--bad:#a30000;
|
||||
--shadow:0 0 0 1px #555555;
|
||||
}
|
||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||
:root[data-theme="classic"] {
|
||||
color-scheme:light;
|
||||
@@ -250,14 +395,61 @@
|
||||
--fg:#000000;
|
||||
--dim:#444444;
|
||||
--faint:#666666;
|
||||
--accent:#3875d7; /* Aqua selection blue */
|
||||
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
|
||||
--accent2:#2a5db0;
|
||||
--ink:#ffffff;
|
||||
--good:#237a23;
|
||||
--warn:#a15f00;
|
||||
--warn:#9b5b00;
|
||||
--bad:#c42b1c;
|
||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||
}
|
||||
/* Glass, after Apple's Liquid Glass: Apple's system colours, on panels that let a soft coloured
|
||||
wash show through. The hex values are what a panel reads as once blended, so the contrast check
|
||||
still means something; the translucency and the wash are in the chrome at the end of the sheet. */
|
||||
:root[data-theme="glass"] {
|
||||
--bg:#0b0d14;
|
||||
--panel:#161a24;
|
||||
--panel2:#1f2430;
|
||||
--raise:#2a3040;
|
||||
--line:#343b4c;
|
||||
--fg:#f5f5f7; /* Apple's label */
|
||||
--dim:#aeaeb2; /* systemGray2 */
|
||||
--faint:#98989f; /* systemGray, a shade up to clear AA over the wash */
|
||||
--accent:#409cff; /* systemBlue's accessible dark variant; #0a84ff falls short as link text */
|
||||
--accent2:#ff9f0a; /* systemOrange */
|
||||
--ink:#000000;
|
||||
--good:#30d158;
|
||||
--warn:#ff9f0a;
|
||||
--bad:#ff6961;
|
||||
--shadow:0 10px 36px rgba(0,0,0,.45);
|
||||
--glass-edge:rgba(255,255,255,.10);
|
||||
--glass-hi:rgba(255,255,255,.14);
|
||||
--wash1:rgba(64,120,255,.30);
|
||||
--wash2:rgba(175,82,222,.24);
|
||||
--wash3:rgba(48,176,199,.20);
|
||||
}
|
||||
:root[data-theme="glass"][data-mode="light"] {
|
||||
--bg:#eef1f7;
|
||||
--panel:#ffffff;
|
||||
--panel2:#f2f4f8;
|
||||
--raise:#e4e8f0;
|
||||
--line:#d1d5de;
|
||||
--fg:#1d1d1f;
|
||||
--dim:#515154;
|
||||
--faint:#5a5a5f; /* systemGray, taken down to clear AA over the wash */
|
||||
--accent:#0055aa; /* a shade under Apple's #0066cc, which falls short over the wash */
|
||||
--accent2:#b25000; /* systemOrange taken down until white on it clears AA */
|
||||
--ink:#ffffff;
|
||||
--good:#248a3d;
|
||||
--warn:#824200;
|
||||
--bad:#b8000f;
|
||||
--shadow:0 10px 36px rgba(30,50,90,.16);
|
||||
--glass-edge:rgba(255,255,255,.65);
|
||||
--glass-hi:rgba(255,255,255,.9);
|
||||
--wash1:rgba(64,120,255,.22);
|
||||
--wash2:rgba(175,82,222,.16);
|
||||
--wash3:rgba(48,176,199,.18);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||
[hidden]{display:none!important}
|
||||
@@ -308,8 +500,22 @@ a{color:var(--accent)}
|
||||
}
|
||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
||||
/* The display's own intrusions -- the home indicator along the bottom, the notch at one side in
|
||||
landscape. viewport-fit=cover hands the page the whole screen, which is what a standalone
|
||||
shell and an iOS home-screen app both give it, so the bars along the edges pay for them in
|
||||
padding. A browser with its own chrome reports nought and nothing moves. */
|
||||
:root{
|
||||
--safe-b:env(safe-area-inset-bottom,0px);
|
||||
--safe-l:env(safe-area-inset-left,0px);
|
||||
--safe-r:env(safe-area-inset-right,0px);
|
||||
}
|
||||
#topbar{
|
||||
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
|
||||
display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
|
||||
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
|
||||
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
|
||||
the whole declaration away, leaving the bar with no padding at all. */
|
||||
padding-top:7px;padding-bottom:7px;
|
||||
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||
background:var(--panel);border-bottom:1px solid var(--line);
|
||||
}
|
||||
#topbar .grow{flex:1}
|
||||
@@ -368,6 +574,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
}
|
||||
.chev:hover{color:var(--fg)}
|
||||
.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 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)}
|
||||
@@ -421,9 +634,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 small{display:block;color:var(--faint);font-size:11.5px}
|
||||
.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{
|
||||
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{
|
||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||
@@ -444,6 +659,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;
|
||||
}
|
||||
.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. */
|
||||
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||
|
||||
@@ -627,7 +844,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
#player{
|
||||
border-top:1px solid var(--line);background:var(--panel);
|
||||
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
||||
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||
padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
|
||||
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
|
||||
box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||
}
|
||||
#player.on{display:grid}
|
||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||
@@ -690,6 +909,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 .grow{flex:1;min-width:120px}
|
||||
.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 label{font-size:12px;color:var(--dim)}
|
||||
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||
@@ -706,14 +928,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||
}
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
||||
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||
#burger,#dback{display:none}
|
||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||
#status{
|
||||
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
||||
padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
|
||||
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
|
||||
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
||||
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
|
||||
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
|
||||
body:has(#player.on) #status{padding-bottom:3px}
|
||||
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
||||
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
||||
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
||||
@@ -737,7 +965,11 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||
#sidebar.open{transform:none}
|
||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
|
||||
#player{
|
||||
position:relative;z-index:39;gap:8px;
|
||||
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
|
||||
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
|
||||
}
|
||||
|
||||
/* The feed list is reachable whether or not anything is playing. */
|
||||
#burger{display:grid}
|
||||
@@ -838,10 +1070,82 @@ 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 .fl,
|
||||
: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. */
|
||||
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
||||
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
||||
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
||||
:root[data-theme="classic"] .childrow{background:#fff}
|
||||
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
||||
|
||||
/* ---------- Glass: after Apple's Liquid Glass ---------- */
|
||||
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
|
||||
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
|
||||
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43). */
|
||||
:root[data-theme="glass"] body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,Inter,"Segoe UI",Roboto,sans-serif;
|
||||
background:
|
||||
radial-gradient(60% 55% at 8% 0%,var(--wash1),transparent),
|
||||
radial-gradient(50% 50% at 92% 18%,var(--wash2),transparent),
|
||||
radial-gradient(60% 60% at 60% 100%,var(--wash3),transparent),
|
||||
var(--bg);
|
||||
}
|
||||
:root[data-theme="glass"] #sidebar,
|
||||
:root[data-theme="glass"] #topbar,
|
||||
:root[data-theme="glass"] #player,
|
||||
:root[data-theme="glass"] #status,
|
||||
:root[data-theme="glass"] #files,
|
||||
:root[data-theme="glass"] #detail,
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast{
|
||||
background:color-mix(in srgb,var(--panel) 70%,transparent);
|
||||
-webkit-backdrop-filter:blur(24px) saturate(180%);
|
||||
backdrop-filter:blur(24px) saturate(180%);
|
||||
border-color:var(--glass-edge);
|
||||
box-shadow:inset 0 1px 0 var(--glass-hi);
|
||||
}
|
||||
:root[data-theme="glass"] .toolbar,
|
||||
:root[data-theme="glass"] .ephead{
|
||||
/* Sticky, so the list scrolls under them: the one place the frosting has something to blur. */
|
||||
background:color-mix(in srgb,var(--bg) 65%,transparent);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(180%);
|
||||
backdrop-filter:blur(20px) saturate(180%);
|
||||
}
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast{box-shadow:inset 0 1px 0 var(--glass-hi),var(--shadow)}
|
||||
:root[data-theme="glass"] .card{border-radius:20px}
|
||||
:root[data-theme="glass"] .toast{border-radius:14px}
|
||||
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||
@media (max-width:820px){
|
||||
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
||||
}
|
||||
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
||||
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
||||
:root[data-theme="glass"] .tgroup,
|
||||
:root[data-theme="glass"] .tabs{
|
||||
background:color-mix(in srgb,var(--panel2) 60%,transparent);
|
||||
border-color:var(--glass-edge);border-radius:12px;box-shadow:inset 0 1px 0 var(--glass-hi);
|
||||
}
|
||||
:root[data-theme="glass"] .btn,
|
||||
:root[data-theme="glass"] .sidetools button,
|
||||
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
||||
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
||||
:root[data-theme="glass"] body{background:var(--bg)}
|
||||
:root[data-theme="glass"] #sidebar,
|
||||
:root[data-theme="glass"] #topbar,
|
||||
:root[data-theme="glass"] #player,
|
||||
:root[data-theme="glass"] #status,
|
||||
:root[data-theme="glass"] #files,
|
||||
:root[data-theme="glass"] #detail,
|
||||
:root[data-theme="glass"] .card,
|
||||
:root[data-theme="glass"] .toast,
|
||||
:root[data-theme="glass"] .tgroup,
|
||||
:root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)}
|
||||
:root[data-theme="glass"] .toolbar,
|
||||
:root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||
const PAGES = {
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||
'login.html': { script: 'login.js', src: ['login'] },
|
||||
};
|
||||
@@ -38,7 +38,14 @@ export function buildStyle({ minify = true } = {}) {
|
||||
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||
// The minifier drops the space between a calc() and the value after it in a shorthand --
|
||||
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
|
||||
// declaration away, so the element silently loses its padding. It reports no error and the
|
||||
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
|
||||
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
|
||||
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>iPodderX</title>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||
|
||||
@@ -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>
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
@@ -12,7 +17,7 @@
|
||||
--line:#2c3849;
|
||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||
--ink:#0e131b; /* text on an accent fill */
|
||||
@@ -22,7 +27,8 @@
|
||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
||||
--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;
|
||||
--panel:#ffffff; /* #FFFFFF device body */
|
||||
--panel2:#e9edf3;
|
||||
@@ -30,15 +36,15 @@
|
||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||
--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 */
|
||||
--accent2:#b06f10;
|
||||
--accent2:#985e0a;
|
||||
--ink:#ffffff;
|
||||
--good:#2f7d4f;
|
||||
--warn:#b06f10;
|
||||
--warn:#985e0a;
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
}
|
||||
}}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
|
||||
@@ -218,7 +218,7 @@ function due(ts){
|
||||
async function prefsModal(){
|
||||
const g = await api('/api/settings');
|
||||
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>
|
||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||
`<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.
|
||||
${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>
|
||||
<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>`);
|
||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||
$('#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;
|
||||
$('#prefs').onclick=prefsModal;
|
||||
// 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 refreshFeeds=soon(()=>loadFeeds(true));
|
||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||
let fresh={};
|
||||
const tellNew=soon(()=>{
|
||||
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);
|
||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||
sse.onmessage=m=>{
|
||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||
if(ev.ev==='progress'){
|
||||
@@ -23,22 +19,28 @@ function connect(){
|
||||
}
|
||||
else if(ev.ev==='download_done'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
||||
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||
// 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'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
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'){
|
||||
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();
|
||||
}
|
||||
// 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.
|
||||
else if(ev.ev==='feed_error') refreshFeeds();
|
||||
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
||||
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
||||
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
||||
};
|
||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ function renderGroup(f,kids){
|
||||
}
|
||||
|
||||
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==='rm') removeFeed(f);
|
||||
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.'},
|
||||
':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(){
|
||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||
const list=$('#feedlist'); const top=list.scrollTop;
|
||||
@@ -104,6 +120,7 @@ function renderFeeds(){
|
||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
list.appendChild(el);
|
||||
}
|
||||
paintScanning();
|
||||
done();
|
||||
}
|
||||
function selectFeed(id){
|
||||
|
||||
@@ -34,7 +34,6 @@ function pullShow(dy: number){
|
||||
function refreshFeed(){
|
||||
const f = S.feeds.find(x => x.id === S.feed);
|
||||
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.
|
||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||
.catch(e => toast(e.message, true));
|
||||
|
||||
177
web/src/native.ts
Normal file
177
web/src/native.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/* ---------------- native shell bridge ---------------- */
|
||||
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
|
||||
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
|
||||
// that cannot render a WebView at all, and the only audio they will control is the host's.
|
||||
//
|
||||
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
|
||||
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
|
||||
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
|
||||
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
|
||||
// exactly as they do in a browser, with nothing in player.ts changed.
|
||||
//
|
||||
// In a browser none of this installs and the page is untouched.
|
||||
|
||||
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
|
||||
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
|
||||
/// which is what switches the whole file off.
|
||||
const ipxHost: ((m: any) => void) | null = (() => {
|
||||
const w = window as any;
|
||||
const ios = w.webkit?.messageHandlers?.ipx;
|
||||
if (ios) return (m: any) => ios.postMessage(m);
|
||||
const android = w.ipxAndroid;
|
||||
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (ipxHost) installNativePlayback();
|
||||
|
||||
function installNativePlayback(){
|
||||
const post = ipxHost!;
|
||||
const M = HTMLMediaElement.prototype;
|
||||
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
|
||||
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
|
||||
// Bound before anything is redefined, because the src setter below has to drop the element's
|
||||
// file without that counting as closing the player: removeAttribute is overridden further down
|
||||
// to mean exactly that, and going through it there switched the shim straight back off.
|
||||
const realRemoveAttribute = audio.removeAttribute.bind(audio);
|
||||
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
|
||||
const paused = own('paused'), readyState = own('readyState');
|
||||
const volume = own('volume'), playbackRate = own('playbackRate');
|
||||
|
||||
// What the host last told us. `on` is the whole switch: false means this element is playing for
|
||||
// itself, which is still the case for video -- the host plays audio, and a native video layer
|
||||
// under a WebView buys nothing when CarPlay is audio-only either way.
|
||||
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
|
||||
|
||||
const fire = (name: string) => audio.dispatchEvent(new Event(name));
|
||||
|
||||
const define = (k: string, d: PropertyDescriptor) =>
|
||||
Object.defineProperty(audio, k, {configurable:true, ...d});
|
||||
|
||||
define('play', {value(){
|
||||
if(!N.on) return realPlay.call(audio);
|
||||
post({t:'play'});
|
||||
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
|
||||
// message from the host instead, so there is nothing to reject.
|
||||
return Promise.resolve();
|
||||
}});
|
||||
define('pause', {value(){
|
||||
if(!N.on) return realPause.call(audio);
|
||||
post({t:'pause'});
|
||||
}});
|
||||
|
||||
define('src', {
|
||||
get(){ return N.on ? '' : src.get!.call(audio); },
|
||||
set(v){
|
||||
// has-video is set immediately before the src in play(), so it is already right here.
|
||||
if(document.body.classList.contains('has-video')){
|
||||
stop();
|
||||
src.set!.call(audio, v);
|
||||
return;
|
||||
}
|
||||
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
|
||||
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
|
||||
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
|
||||
realPause.call(audio);
|
||||
realRemoveAttribute('src');
|
||||
realLoad.call(audio);
|
||||
const e = player.entry, f = player.feed;
|
||||
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
|
||||
title:(e && e.title) || '', feedTitle:feedName(f),
|
||||
artwork:(e && e.image) || feedArt(f) || null,
|
||||
// Where the host starts is not this: the seek to where you left off is player.ts's, on
|
||||
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
|
||||
// display before the file has loaded.
|
||||
position:(e && e.position) || 0, duration:(e && e.duration) || null,
|
||||
rate:audio.playbackRate, volume:audio.volume});
|
||||
},
|
||||
});
|
||||
|
||||
define('currentTime', {
|
||||
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
|
||||
set(v){
|
||||
if(!N.on){ currentTime.set!.call(audio, v); return; }
|
||||
N.cur = v;
|
||||
post({t:'seek', to:v});
|
||||
// The clock and the scrubber move now rather than at the host's next tick, which is what
|
||||
// makes the 15 and 30 second keys feel like they did.
|
||||
fire('timeupdate');
|
||||
},
|
||||
});
|
||||
|
||||
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
|
||||
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
|
||||
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
|
||||
|
||||
define('volume', {
|
||||
get(){ return volume.get!.call(audio); },
|
||||
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
|
||||
});
|
||||
define('playbackRate', {
|
||||
get(){ return playbackRate.get!.call(audio); },
|
||||
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
|
||||
});
|
||||
|
||||
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
|
||||
// playing on with nothing on screen to stop it.
|
||||
define('removeAttribute', {value(name: string){
|
||||
if(name === 'src') stop();
|
||||
return realRemoveAttribute(name);
|
||||
}});
|
||||
|
||||
function stop(){
|
||||
if(!N.on) return;
|
||||
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
|
||||
post({t:'stop'});
|
||||
}
|
||||
|
||||
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
|
||||
// left paused in another tab once saved its older place over where you had got to -- and a
|
||||
// backgrounded WebView is exactly that tab: frozen, holding a time from minutes ago, while the
|
||||
// host plays on. So the beacon becomes a request for the host to save its own time, and the host
|
||||
// is also the one saving while nothing here is running at all.
|
||||
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(url: string, data?: any){
|
||||
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
|
||||
return beacon ? beacon(url, data) : false;
|
||||
} as any;
|
||||
|
||||
// What the host calls back into. On `window` deliberately: the host reaches it by name through
|
||||
// evaluateJavaScript, and a top-level const would work but not obviously.
|
||||
(window as any).ipxNative = {
|
||||
version: 1,
|
||||
on(m: any){
|
||||
if(!N.on) return;
|
||||
switch(m.t){
|
||||
case 'time':
|
||||
N.cur = m.cur;
|
||||
if(m.dur != null) N.dur = m.dur;
|
||||
fire('timeupdate');
|
||||
break;
|
||||
case 'meta':
|
||||
N.dur = m.dur; N.ready = 1;
|
||||
fire('loadedmetadata');
|
||||
break;
|
||||
case 'state':
|
||||
if(m.playing === !N.paused) return;
|
||||
N.paused = !m.playing;
|
||||
fire(m.playing ? 'play' : 'pause');
|
||||
break;
|
||||
case 'ended':
|
||||
N.paused = true;
|
||||
fire('pause');
|
||||
fire('ended');
|
||||
break;
|
||||
case 'error':
|
||||
N.paused = true;
|
||||
fire('pause');
|
||||
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// The host waits for this to know the bridge is in and which build it got: an app newer than the
|
||||
// deployed page would otherwise sit there sending messages nothing answers.
|
||||
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
|
||||
}
|
||||
@@ -191,8 +191,7 @@ function keysModal(){
|
||||
['Anywhere'],
|
||||
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||
];
|
||||
openModal(`<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>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
||||
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>`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||
// the system, so no palette is written twice.
|
||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||
modern: {name: 'Modern', modes: true},
|
||||
classic: {name: 'Classic, the 2004 Mac app', modes: false},
|
||||
dracula: {name: 'Dracula', modes: true},
|
||||
material: {name: 'Material', 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},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
glass: {name: 'Glass', modes: true},
|
||||
gruvbox: {name: 'Gruvbox', modes: true},
|
||||
contrast: {name: 'High contrast', modes: true},
|
||||
material: {name: 'Material', modes: true},
|
||||
modern: {name: 'Modern', modes: true},
|
||||
nordic: {name: 'Nordic', modes: true},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
solarized: {name: 'Solarized', modes: true},
|
||||
};
|
||||
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user