Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f56436033 | |||
| ae900b82ca | |||
| 40c92ad08d | |||
| 7796566dfc | |||
| 162665b010 | |||
| f2fde8cc75 | |||
| a573a07ed5 | |||
| 380a552913 | |||
| bdda9b3d2e | |||
| 868dd673f3 | |||
| 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 |
167
CHANGELOG.md
167
CHANGELOG.md
@@ -7,14 +7,135 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
|
||||||
|
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
|
||||||
|
version. The 2004 icon is still at /icon.png.
|
||||||
|
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
|
||||||
|
list of its own; an item with one of those words or phrases in its title or text is hidden
|
||||||
|
from you and is not downloaded on your account. Other people's lists never hide anything from
|
||||||
|
you.
|
||||||
|
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
|
||||||
|
elsewhere they copy the link. A link that looks like it carries your own access to a paid
|
||||||
|
feed asks before it goes anywhere.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- The database is reached through SeaORM, on the way to Postgres (issue #18); it is still the
|
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
|
||||||
same SQLite file, and nothing you see changes. A database from before 0.7 has to be opened by a
|
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
|
||||||
0.7 release first, which brings its tables up to date.
|
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
|
||||||
- A pinned item sits at the top of its list, above everything else in whatever order you sort
|
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
|
||||||
by, and moves there the moment you pin it. Sorting by the pin column itself still goes both
|
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
|
||||||
ways, and Currently Listening keeps its own order.
|
keep their outlines.
|
||||||
|
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
|
||||||
|
one along the far edges, and a soft sheen from the top-left corner.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
|
||||||
|
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||||
|
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||||
|
them.
|
||||||
|
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
||||||
|
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
|
||||||
|
across as it changes.
|
||||||
|
- Swiping between items on a phone no longer shows the item list through the reader. The next
|
||||||
|
or previous item sits beside the one you are reading and comes across with it, the end of the
|
||||||
|
list says so, and swiping right from the first item draws the reader off the list, dimmed
|
||||||
|
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
|
||||||
|
Safari taking it as Back.
|
||||||
|
|
||||||
|
## [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
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
@@ -492,17 +613,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
|
||||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
||||||
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
||||||
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
||||||
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
|
||||||
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
|
||||||
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
|
||||||
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
|
||||||
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
|
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
|
||||||
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
|
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
|
||||||
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
|
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
|
||||||
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
|
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
|
||||||
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
|
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
|
||||||
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0
|
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
|
||||||
|
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
|
||||||
|
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
|
||||||
|
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
|
||||||
|
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
|
||||||
|
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0
|
||||||
|
|||||||
73
CLAUDE.md
73
CLAUDE.md
@@ -1,4 +1,4 @@
|
|||||||
# Working on ipodderx-rs
|
# Working on ipx
|
||||||
|
|
||||||
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
||||||
thing is built; this file is about working on it without repeating mistakes that have already been
|
thing is built; this file is about working on it without repeating mistakes that have already been
|
||||||
@@ -6,29 +6,50 @@ made here.
|
|||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
|
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||||
|
|
||||||
| | Host | In the container |
|
| | Host | In the container |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
| 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 `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipx.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 | |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| Old database | `/mnt/user/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||||
|
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||||
|
|
||||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||||
|
|
||||||
|
**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/ipx"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||||
|
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||||
|
t issues close $R 40
|
||||||
|
```
|
||||||
|
|
||||||
|
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||||
|
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||||
|
|
||||||
|
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||||
|
for later stays open, and that is how it gets picked up again.
|
||||||
|
|
||||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
|
||||||
docker logs --tail 20 iPodderX
|
docker logs --tail 20 iPX
|
||||||
```
|
```
|
||||||
|
|
||||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||||
@@ -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
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
||||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||||
|
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||||
|
running the command and kills the session (exit 144), which has happened more than once.
|
||||||
|
|
||||||
## Before you touch the page
|
## Before you touch the page
|
||||||
|
|
||||||
@@ -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
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
npx tsc -p . # type-checks web/src
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -126,9 +151,17 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
* **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
|
and `position` columns from before accounts; two bugs came from queries still reading them
|
||||||
(retention, and the entry pruner), and they were dropped in 0.5.
|
(retention, and the entry pruner), and they were dropped in 0.5.
|
||||||
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
|
* **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
|
`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`).
|
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
||||||
Anyone wanting an item is enough to fetch it.
|
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
|
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
||||||
@@ -173,10 +206,8 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
issue left open.
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
|
||||||
Substack-style subtitles.
|
|
||||||
|
|
||||||
<!-- rtk-instructions v2 -->
|
<!-- rtk-instructions v2 -->
|
||||||
# Command output
|
# Command output
|
||||||
|
|||||||
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.7.0"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1830,6 +1830,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2007,6 +2008,22 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -3786,6 +3803,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -5183,6 +5209,20 @@ name = "zeroize"
|
|||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.7.0"
|
version = "0.9.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,6 +12,7 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ipodderx-rs
|
# ipx
|
||||||
|
|
||||||
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
||||||
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
||||||
@@ -30,7 +30,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
With Docker:
|
With Docker:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t ipodderx .
|
docker build -t ipx .
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
```sh
|
```sh
|
||||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
node tests/page-smoke.js # the page script loads without throwing
|
node tests/page-smoke.js # the page script loads without throwing
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
services:
|
services:
|
||||||
ipodderx:
|
ipx:
|
||||||
image: 192.168.1.130:5000/ipodderx:latest
|
image: 192.168.1.130:5000/ipx:latest
|
||||||
container_name: iPodderX
|
container_name: iPX
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PUID: "99"
|
PUID: "99"
|
||||||
PGID: "100"
|
PGID: "100"
|
||||||
TZ: "America/Toronto"
|
TZ: "America/Toronto"
|
||||||
IPX_LOG: "ipx=info"
|
IPX_LOG: "ipx=info"
|
||||||
|
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||||
|
env_file:
|
||||||
|
- ipx.env # relative: Arcane resolves it inside its own container
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099" # web UI
|
- "8099:8099" # web UI
|
||||||
- "6881:6881/tcp" # BitTorrent peers
|
- "6881:6881/tcp" # BitTorrent peers
|
||||||
- "6881:6881/udp" # DHT
|
- "6881:6881/udp" # DHT
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
|
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
|
||||||
- /mnt/user/ipodderx/:/data # state.db
|
- /mnt/user/ipx/:/data # state.db
|
||||||
- /mnt/user/ipodderx/downloads:/downloads
|
- /mnt/user/ipx/downloads:/downloads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "ipx", "status"]
|
test: ["CMD", "ipx", "status"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ before they reach the page.
|
|||||||
```sh
|
```sh
|
||||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
|
|||||||
pkill -x ipx
|
pkill -x ipx
|
||||||
```
|
```
|
||||||
|
|
||||||
`pkill -f ipx` matches the shell running the command too, and kills your own session.
|
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
|
||||||
|
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||||
|
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||||
|
|
||||||
## Talking to it directly
|
## Talking to it directly
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
|
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
|
||||||
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
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`.
|
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
||||||
|
|
||||||
| What | Where | Override |
|
| 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` |
|
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
||||||
| Downloads | `[general] download_dir` | — |
|
| Downloads | `[general] download_dir` | — |
|
||||||
|
|
||||||
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
|
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
||||||
while the daemon is stopped, or with `sqlite3 state.db .backup`.
|
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]`
|
## `[general]`
|
||||||
|
|
||||||
@@ -28,6 +42,9 @@ max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
|||||||
media_types = ["audio", "video"]
|
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
|
* **`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.
|
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`.
|
* **`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
|
token = "" # generated and saved on first run
|
||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
|
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||||
|
access_aud = "" # the Access application's AUD tag
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
@@ -77,6 +96,9 @@ session_days = 30
|
|||||||
disables that path. See [sso.md](sso.md).
|
disables that path. See [sso.md](sso.md).
|
||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
|
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||||
|
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||||
|
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
@@ -88,8 +110,9 @@ itself carry a credential. Put TLS in front of it if that matters.
|
|||||||
|
|
||||||
## `[feeds.<id>]`
|
## `[feeds.<id>]`
|
||||||
|
|
||||||
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
|
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||||
derives it from the feed title.
|
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
|
```toml
|
||||||
[feeds.atp]
|
[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
|
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).
|
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
|
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||||
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
|
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||||
|
|
||||||
## Environment
|
## 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_CONFIG` | Config file path |
|
||||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
| `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_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 |
|
| `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 |
|
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||||
|
|||||||
50
docs/sso.md
50
docs/sso.md
@@ -31,7 +31,7 @@ request it forwards through the tunnel, and ipx signs that person in.
|
|||||||
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
||||||
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
||||||
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
||||||
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
|
| ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[web]
|
[web]
|
||||||
@@ -39,13 +39,19 @@ enabled = true
|
|||||||
bind = "0.0.0.0:8099"
|
bind = "0.0.0.0:8099"
|
||||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||||
|
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||||
|
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "/cdn-cgi/access/logout"
|
sign_out_url = "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
|
||||||
|
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||||
|
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||||
|
|
||||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||||
restart ipodderx`.
|
restart ipx`.
|
||||||
|
|
||||||
### What was missing
|
### What was missing
|
||||||
|
|
||||||
@@ -87,7 +93,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
|
|||||||
send:
|
send:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker exec iPodderX ipx user rename <old name> <email address>
|
docker exec iPX ipx user rename <old name> <email address>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signing out
|
### Signing out
|
||||||
@@ -103,10 +109,10 @@ feeds.
|
|||||||
|
|
||||||
### The tile in Authentik's library
|
### The tile in Authentik's library
|
||||||
|
|
||||||
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
|
Authentik's library lists Authentik's own applications, and ipx signs in through the one
|
||||||
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
|
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
|
||||||
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
|
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
|
||||||
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
the ipx logo. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
||||||
tile. Who actually gets in is still up to the Access policy.
|
tile. Who actually gets in is still up to the Access policy.
|
||||||
|
|
||||||
### Check it
|
### Check it
|
||||||
@@ -172,9 +178,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
|||||||
sides. Never list a LAN address or range: anyone there could then send
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
|
|
||||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||||
boundary, and is the upgrade if that ever matters.
|
a container on Tower cannot make one.
|
||||||
|
|
||||||
|
### Verifying Cloudflare's token
|
||||||
|
|
||||||
|
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||||
|
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||||
|
request, and takes the name from its `email` claim.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||||
|
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||||
|
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||||
|
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||||
|
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||||
|
the keys cannot be fetched; password and token sign-in still work then.
|
||||||
|
|
||||||
|
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||||
|
place.
|
||||||
|
|
||||||
|
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||||
|
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||||
|
|
||||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||||
in with them until they are given a password (`ipx user passwd <name>`).
|
in with them until they are given a password (`ipx user passwd <name>`).
|
||||||
@@ -192,7 +222,7 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
|
|||||||
ipx user rm sam # remove the account
|
ipx user rm sam # remove the account
|
||||||
```
|
```
|
||||||
|
|
||||||
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
|
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
|
||||||
that read a password.
|
that read a password.
|
||||||
|
|
||||||
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
||||||
|
|||||||
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
|
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||||
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||||
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||||
|
/// host any container can send one from the gateway's address.
|
||||||
|
pub access_team: String,
|
||||||
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||||
|
/// leaves the signature unchecked.
|
||||||
|
pub access_aud: String,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
@@ -96,6 +104,8 @@ impl Default for Web {
|
|||||||
token: String::new(),
|
token: String::new(),
|
||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
|
access_team: String::new(),
|
||||||
|
access_aud: String::new(),
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
@@ -107,6 +117,12 @@ impl Web {
|
|||||||
pub fn binds_publicly(&self) -> bool {
|
pub fn binds_publicly(&self) -> bool {
|
||||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both halves of the Access check, or None while either is unset.
|
||||||
|
pub fn access(&self) -> Option<(&str, &str)> {
|
||||||
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||||
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -261,21 +277,86 @@ impl Config {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
}
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
/// 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`.
|
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||||
@@ -370,6 +451,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn parses_a_config_and_applies_defaults() {
|
fn parses_a_config_and_applies_defaults() {
|
||||||
let cfg: Config = toml::from_str(
|
let cfg: Config = toml::from_str(
|
||||||
|
|||||||
380
src/db.rs
380
src/db.rs
@@ -2,7 +2,7 @@
|
|||||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crate::entity::{enclosures, entries, feeds, sessions, subscriptions, users};
|
use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
|
||||||
use sea_orm::sea_query::{Expr, Func};
|
use sea_orm::sea_query::{Expr, Func};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||||
@@ -55,6 +55,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
|||||||
schema.create_table_from_entity(subscriptions::Entity),
|
schema.create_table_from_entity(subscriptions::Entity),
|
||||||
schema.create_table_from_entity(entry_state::Entity),
|
schema.create_table_from_entity(entry_state::Entity),
|
||||||
schema.create_table_from_entity(sessions::Entity),
|
schema.create_table_from_entity(sessions::Entity),
|
||||||
|
schema.create_table_from_entity(catalogue::Entity),
|
||||||
|
schema.create_table_from_entity(settings::Entity),
|
||||||
|
schema.create_table_from_entity(blocklists::Entity),
|
||||||
|
schema.create_table_from_entity(hidden::Entity),
|
||||||
] {
|
] {
|
||||||
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||||
}
|
}
|
||||||
@@ -100,6 +104,9 @@ pub fn location() -> String {
|
|||||||
.unwrap_or_else(|| crate::config::data_dir().join("state.db").display().to_string())
|
.unwrap_or_else(|| crate::config::data_dir().join("state.db").display().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Postgres connection option that keeps notices from being sent at all; see `connect`.
|
||||||
|
const QUIET: &str = "options=-c%20client_min_messages%3Dwarning";
|
||||||
|
|
||||||
/// A URL fit for a log or an error: the password taken out.
|
/// A URL fit for a log or an error: the password taken out.
|
||||||
fn redact(url: &str) -> String {
|
fn redact(url: &str) -> String {
|
||||||
match (url.find("://"), url.rfind('@')) {
|
match (url.find("://"), url.rfind('@')) {
|
||||||
@@ -118,15 +125,30 @@ fn is_postgres(location: &str) -> bool {
|
|||||||
/// sqlx's defaults for SQLite are what ipx wants: foreign keys on, and a five-second wait for a
|
/// sqlx's defaults for SQLite are what ipx wants: foreign keys on, and a five-second wait for a
|
||||||
/// lock, which is what rusqlite was set to.
|
/// lock, which is what rusqlite was set to.
|
||||||
async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
||||||
let url =
|
let mut opts = sea_orm::ConnectOptions::new(url_for(location));
|
||||||
if is_postgres(location) { location.to_owned() } else { format!("sqlite://{location}?mode=rwc") };
|
|
||||||
let mut opts = sea_orm::ConnectOptions::new(url);
|
|
||||||
opts.sqlx_logging(false);
|
opts.sqlx_logging(false);
|
||||||
sea_orm::Database::connect(opts)
|
sea_orm::Database::connect(opts)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("opening {}", redact(location)))
|
.with_context(|| format!("opening {}", redact(location)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The URL `connect` hands sqlx for a location: a SQLite file, created if missing, or a Postgres
|
||||||
|
/// URL with notices turned off.
|
||||||
|
fn url_for(location: &str) -> String {
|
||||||
|
if !is_postgres(location) {
|
||||||
|
format!("sqlite://{location}?mode=rwc")
|
||||||
|
} else if location.contains("options=") {
|
||||||
|
location.to_owned() // someone chose their own; theirs stands
|
||||||
|
} else {
|
||||||
|
// Warnings and up only. Postgres answers every CREATE ... IF NOT EXISTS on an existing
|
||||||
|
// table with a notice, eleven on each open; sqlx logs each, no filter here wants them,
|
||||||
|
// and tracing-subscriber's per-layer filters then swallowed the next line ipx logged
|
||||||
|
// (0.3.23: "moved the feeds ... into the database" went missing that way).
|
||||||
|
let sep = if location.contains('?') { '&' } else { '?' };
|
||||||
|
format!("{location}{sep}{QUIET}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// One person's wants for one feed. `None` in a field means the feed's own setting stands.
|
/// One person's wants for one feed. `None` in a field means the feed's own setting stands.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -136,6 +158,10 @@ pub struct Sub {
|
|||||||
pub auto_download: Option<bool>,
|
pub auto_download: Option<bool>,
|
||||||
pub allow_explicit: Option<bool>,
|
pub allow_explicit: Option<bool>,
|
||||||
pub max_new_per_check: Option<i64>,
|
pub max_new_per_check: Option<i64>,
|
||||||
|
/// Words that keep an item from being downloaded for this person: their list for every feed
|
||||||
|
/// and their list for this one together. Filled by `subscribers` only; it is kept apart from
|
||||||
|
/// the settings above (`Db::set_blocklist`).
|
||||||
|
pub blocked: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
|
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
|
||||||
@@ -180,6 +206,7 @@ impl From<subscriptions::Model> for Sub {
|
|||||||
auto_download: s.auto_download,
|
auto_download: s.auto_download,
|
||||||
allow_explicit: s.allow_explicit,
|
allow_explicit: s.allow_explicit,
|
||||||
max_new_per_check: s.max_new_per_check,
|
max_new_per_check: s.max_new_per_check,
|
||||||
|
blocked: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +307,9 @@ impl Db {
|
|||||||
admin.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await?;
|
admin.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await?;
|
||||||
// Every connection in the pool starts in it, so each test sees only its own tables.
|
// Every connection in the pool starts in it, so each test sees only its own tables.
|
||||||
let sep = if url.contains('?') { '&' } else { '?' };
|
let sep = if url.contains('?') { '&' } else { '?' };
|
||||||
let orm = connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}")).await?;
|
let orm =
|
||||||
|
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"))
|
||||||
|
.await?;
|
||||||
create_missing(&orm).await?;
|
create_missing(&orm).await?;
|
||||||
return Ok(Self { orm, tmp: None });
|
return Ok(Self { orm, tmp: None });
|
||||||
}
|
}
|
||||||
@@ -598,8 +627,11 @@ impl Db {
|
|||||||
FROM enclosures e
|
FROM enclosures e
|
||||||
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
||||||
ON subs.feed_id = e.feed_id
|
ON subs.feed_id = e.feed_id
|
||||||
LEFT JOIN (SELECT feed_id, guid, count(*) AS n FROM entry_state
|
-- Someone an item is hidden from is as done with it as someone who read it.
|
||||||
WHERE read GROUP BY feed_id, guid) readers
|
LEFT JOIN (SELECT feed_id, guid, count(*) AS n
|
||||||
|
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
|
||||||
|
UNION SELECT user_id, feed_id, guid FROM hidden) done
|
||||||
|
GROUP BY feed_id, guid) readers
|
||||||
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
|
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
|
||||||
WHERE e.path IS NOT NULL
|
WHERE e.path IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
||||||
@@ -661,13 +693,17 @@ impl Db {
|
|||||||
.await?;
|
.await?;
|
||||||
// Whatever went takes everyone's read state with it, rather than leaving rows
|
// Whatever went takes everyone's read state with it, rather than leaving rows
|
||||||
// pointing at an item that no longer exists.
|
// pointing at an item that no longer exists.
|
||||||
self.exec(
|
for table in ["entry_state", "hidden"] {
|
||||||
"DELETE FROM entry_state WHERE NOT EXISTS (
|
self.exec(
|
||||||
SELECT 1 FROM entries e
|
&format!(
|
||||||
WHERE e.feed_id = entry_state.feed_id AND e.guid = entry_state.guid)",
|
"DELETE FROM {table} WHERE NOT EXISTS (
|
||||||
vec![],
|
SELECT 1 FROM entries e
|
||||||
)
|
WHERE e.feed_id = {table}.feed_id AND e.guid = {table}.guid)"
|
||||||
.await?;
|
),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
Ok(n as usize)
|
Ok(n as usize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -679,7 +715,9 @@ impl Db {
|
|||||||
"SELECT count(*) AS n FROM entries e
|
"SELECT count(*) AS n FROM entries e
|
||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||||
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)",
|
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM hidden h
|
||||||
|
WHERE h.user_id = $2 AND h.feed_id = e.feed_id AND h.guid = e.guid)",
|
||||||
vec![feed_id.into(), user_id.into()],
|
vec![feed_id.into(), user_id.into()],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -754,8 +792,16 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
|
|||||||
format!(
|
format!(
|
||||||
"FROM entries e
|
"FROM entries e
|
||||||
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
|
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||||
WHERE {scope} AND {} AND {search}",
|
WHERE {scope} AND {} AND {search} AND {}",
|
||||||
filter.sql()
|
filter.sql(),
|
||||||
|
not_hidden(&user)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not hidden from this person by their block lists. `e` is entries.
|
||||||
|
fn not_hidden(user: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM hidden h WHERE h.user_id = {user} AND h.feed_id = e.feed_id AND h.guid = e.guid)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,10 +811,10 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
|
|||||||
///
|
///
|
||||||
/// ponytail: file type and size look at the item's first and largest file. The row shows the file
|
/// ponytail: file type and size look at the item's first and largest file. The row shows the file
|
||||||
/// it summarises, which is almost always that one; sort by that one if they ever disagree.
|
/// it summarises, which is almost always that one; sort by that one if they ever disagree.
|
||||||
/// `pinned_first` puts your pinned items above the rest, each part in the order asked for, so a
|
///
|
||||||
/// pin keeps something at the top of its list (issue #35). Not when sorting by the pin itself,
|
/// Pinned items are not lifted above the rest: sorting by the pin column, or the Pinned tab, does
|
||||||
/// where the direction chosen is the point.
|
/// that when it is wanted, and lifting them always was undone (issues #35 and #36).
|
||||||
pub fn order_sql(col: &str, dir: &str, pinned_first: bool) -> String {
|
pub fn order_sql(col: &str, dir: &str) -> String {
|
||||||
let expr = match col {
|
let expr = match col {
|
||||||
"kept" => "coalesce(s.flagged, false)",
|
"kept" => "coalesce(s.flagged, false)",
|
||||||
"title" => "lower(coalesce(e.title, ''))",
|
"title" => "lower(coalesce(e.title, ''))",
|
||||||
@@ -781,9 +827,8 @@ pub fn order_sql(col: &str, dir: &str, pinned_first: bool) -> String {
|
|||||||
// and Postgres as the largest, so "largest first" on Postgres opened with every item that has
|
// and Postgres as the largest, so "largest first" on Postgres opened with every item that has
|
||||||
// no file. NULLS FIRST going up and LAST going down keeps what SQLite did.
|
// no file. NULLS FIRST going up and LAST going down keeps what SQLite did.
|
||||||
let dir = if dir == "asc" { "ASC NULLS FIRST" } else { "DESC NULLS LAST" };
|
let dir = if dir == "asc" { "ASC NULLS FIRST" } else { "DESC NULLS LAST" };
|
||||||
let pins = if pinned_first && col != "kept" { "coalesce(s.flagged, false) DESC, " } else { "" };
|
|
||||||
// The guid breaks what ties remain: SQLite's rowid did, and Postgres has none.
|
// The guid breaks what ties remain: SQLite's rowid did, and Postgres has none.
|
||||||
format!("{pins}{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.guid DESC")
|
format!("{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.guid DESC")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which slice of a feed the UI is asking for.
|
/// Which slice of a feed the UI is asking for.
|
||||||
@@ -964,6 +1009,76 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the configuration: the catalogue and the server settings ----
|
||||||
|
|
||||||
|
/// The feeds and server settings, once the database holds them; None before, when they are
|
||||||
|
/// still config.toml's.
|
||||||
|
pub async fn stored_config(
|
||||||
|
&self,
|
||||||
|
) -> Result<Option<(crate::config::Stored, std::collections::BTreeMap<String, crate::config::Feed>)>> {
|
||||||
|
let Some(general) = settings::Entity::find_by_id("general".to_owned()).one(&self.orm).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let stored = serde_json::from_str(&general.value).context("reading the stored server settings")?;
|
||||||
|
let mut feeds = std::collections::BTreeMap::new();
|
||||||
|
for row in catalogue::Entity::find().all(&self.orm).await? {
|
||||||
|
let feed = serde_json::from_str(&row.spec).with_context(|| format!("reading feed {}", row.id))?;
|
||||||
|
feeds.insert(row.id, feed);
|
||||||
|
}
|
||||||
|
Ok(Some((stored, feeds)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the feeds and server settings from `cfg` (config.toml, as read) into a database that
|
||||||
|
/// has none. False when it already had them: another process got there first, and its copy
|
||||||
|
/// stands. One transaction, and the settings row goes in first, so two processes starting at
|
||||||
|
/// once cannot both import.
|
||||||
|
pub async fn import_config(&self, cfg: &crate::config::Config) -> Result<bool> {
|
||||||
|
use sea_orm::TransactionTrait;
|
||||||
|
let backend = self.orm.get_database_backend();
|
||||||
|
let tx = self.orm.begin().await?;
|
||||||
|
let claimed = tx
|
||||||
|
.execute_raw(Statement::from_sql_and_values(
|
||||||
|
backend,
|
||||||
|
"INSERT INTO settings (name, value) VALUES ('general', $1) ON CONFLICT DO NOTHING",
|
||||||
|
vec![serde_json::to_string(&crate::config::Stored::of(cfg))?.into()],
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if claimed == 0 {
|
||||||
|
return Ok(false); // dropped, so rolled back
|
||||||
|
}
|
||||||
|
for (id, feed) in &cfg.feeds {
|
||||||
|
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
|
||||||
|
.insert(&tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the feeds and server settings as they now stand: what config.toml's save did.
|
||||||
|
/// The whole catalogue at once, in one transaction, so a feed removed goes too.
|
||||||
|
pub async fn store_config(&self, cfg: &crate::config::Config) -> Result<()> {
|
||||||
|
use sea_orm::TransactionTrait;
|
||||||
|
let backend = self.orm.get_database_backend();
|
||||||
|
let tx = self.orm.begin().await?;
|
||||||
|
tx.execute_raw(Statement::from_sql_and_values(
|
||||||
|
backend,
|
||||||
|
"INSERT INTO settings (name, value) VALUES ('general', $1)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET value = excluded.value",
|
||||||
|
vec![serde_json::to_string(&crate::config::Stored::of(cfg))?.into()],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
catalogue::Entity::delete_many().exec(&tx).await?;
|
||||||
|
for (id, feed) in &cfg.feeds {
|
||||||
|
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
|
||||||
|
.insert(&tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---- moving to another database ----
|
// ---- moving to another database ----
|
||||||
|
|
||||||
/// Copies every row of `from` into this database, which must be empty: the move from the
|
/// Copies every row of `from` into this database, which must be empty: the move from the
|
||||||
@@ -986,6 +1101,10 @@ impl Db {
|
|||||||
("subscriptions", copy_table::<subscriptions::Entity>(&from.orm, &tx).await?),
|
("subscriptions", copy_table::<subscriptions::Entity>(&from.orm, &tx).await?),
|
||||||
("entry_state", copy_table::<entry_state::Entity>(&from.orm, &tx).await?),
|
("entry_state", copy_table::<entry_state::Entity>(&from.orm, &tx).await?),
|
||||||
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
|
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
|
||||||
|
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
|
||||||
|
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
|
||||||
|
("blocklists", copy_table::<blocklists::Entity>(&from.orm, &tx).await?),
|
||||||
|
("hidden", copy_table::<hidden::Entity>(&from.orm, &tx).await?),
|
||||||
];
|
];
|
||||||
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
|
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
|
||||||
// The copied ids came with the rows; the counters that hand out new ones start past
|
// The copied ids came with the rows; the counters that hand out new ones start past
|
||||||
@@ -1073,9 +1192,12 @@ impl Db {
|
|||||||
"SELECT coalesce(c.keywords, p.keywords) AS keywords,
|
"SELECT coalesce(c.keywords, p.keywords) AS keywords,
|
||||||
coalesce(c.auto_download, p.auto_download) AS auto_download,
|
coalesce(c.auto_download, p.auto_download) AS auto_download,
|
||||||
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
|
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
|
||||||
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check
|
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check,
|
||||||
|
g.words AS global_blocked, b.words AS blocked
|
||||||
FROM subscriptions c
|
FROM subscriptions c
|
||||||
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
|
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
|
||||||
|
LEFT JOIN blocklists g ON g.user_id = c.user_id AND g.feed_id = ''
|
||||||
|
LEFT JOIN blocklists b ON b.user_id = c.user_id AND b.feed_id = c.feed_id
|
||||||
WHERE c.feed_id = $1",
|
WHERE c.feed_id = $1",
|
||||||
vec![feed_id.into(), group.map(str::to_owned).into()],
|
vec![feed_id.into(), group.map(str::to_owned).into()],
|
||||||
)
|
)
|
||||||
@@ -1088,6 +1210,10 @@ impl Db {
|
|||||||
auto_download: r.try_get("", "auto_download")?,
|
auto_download: r.try_get("", "auto_download")?,
|
||||||
allow_explicit: r.try_get("", "allow_explicit")?,
|
allow_explicit: r.try_get("", "allow_explicit")?,
|
||||||
max_new_per_check: r.try_get("", "max_new_per_check")?,
|
max_new_per_check: r.try_get("", "max_new_per_check")?,
|
||||||
|
blocked: [r.try_get("", "global_blocked")?, r.try_get("", "blocked")?]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|w| keywords(w).unwrap_or_default())
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1124,11 +1250,13 @@ impl Db {
|
|||||||
let r = self
|
let r = self
|
||||||
.rows(
|
.rows(
|
||||||
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
|
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
|
||||||
sum(CASE WHEN st.read THEN 0 ELSE 1 END) AS unread
|
sum(CASE WHEN st.read OR h.guid IS NOT NULL THEN 0 ELSE 1 END) AS unread
|
||||||
FROM enclosures e
|
FROM enclosures e
|
||||||
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
|
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
|
||||||
LEFT JOIN entry_state st
|
LEFT JOIN entry_state st
|
||||||
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
|
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
|
||||||
|
LEFT JOIN hidden h
|
||||||
|
ON h.user_id = s.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid
|
||||||
WHERE e.id = $1",
|
WHERE e.id = $1",
|
||||||
vec![enclosure_id.into(), user_id.into()],
|
vec![enclosure_id.into(), user_id.into()],
|
||||||
)
|
)
|
||||||
@@ -1141,11 +1269,99 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||||
self.exec(
|
let new = self
|
||||||
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
.exec(
|
||||||
vec![user_id.into(), feed_id.into()],
|
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
)
|
vec![user_id.into(), feed_id.into()],
|
||||||
.await?;
|
)
|
||||||
|
.await?;
|
||||||
|
if new > 0 {
|
||||||
|
// Their list for every feed applies to this one from the start.
|
||||||
|
self.rehide(feed_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone's block list: for one feed, or with `feed_id` empty, for every feed.
|
||||||
|
pub async fn blocklist(&self, user_id: i64, feed_id: &str) -> Result<Vec<String>> {
|
||||||
|
Ok(blocklists::Entity::find_by_id((user_id, feed_id.to_owned()))
|
||||||
|
.one(&self.orm)
|
||||||
|
.await?
|
||||||
|
.and_then(|b| keywords(Some(b.words)))
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a block list and works out again what it hides: in that feed, or with
|
||||||
|
/// `feed_id` empty, in every feed the person reads.
|
||||||
|
pub async fn set_blocklist(&self, user_id: i64, feed_id: &str, words: &[String]) -> Result<()> {
|
||||||
|
if words.is_empty() {
|
||||||
|
blocklists::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
|
||||||
|
} else {
|
||||||
|
self.exec(
|
||||||
|
"INSERT INTO blocklists (user_id, feed_id, words) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, feed_id) DO UPDATE SET words = excluded.words",
|
||||||
|
vec![user_id.into(), feed_id.into(), serde_json::to_string(words)?.into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if feed_id.is_empty() {
|
||||||
|
for sub in self.subscriptions_for(user_id).await? {
|
||||||
|
self.rehide(&sub.feed_id).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.rehide(feed_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Works out again which of a feed's items each subscriber's block lists hide from them.
|
||||||
|
///
|
||||||
|
/// ponytail: every item of the feed for every subscriber, each time the feed is scanned with
|
||||||
|
/// something new or a list changes. Fine at hundreds of items a feed; look only at new items
|
||||||
|
/// on a scan if a feed with thousands makes scans slow.
|
||||||
|
pub async fn rehide(&self, feed_id: &str) -> Result<()> {
|
||||||
|
let lists: Vec<(i64, Vec<String>)> = self
|
||||||
|
.rows(
|
||||||
|
"SELECT s.user_id, g.words AS global, b.words AS feed
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN blocklists g ON g.user_id = s.user_id AND g.feed_id = ''
|
||||||
|
LEFT JOIN blocklists b ON b.user_id = s.user_id AND b.feed_id = s.feed_id
|
||||||
|
WHERE s.feed_id = $1",
|
||||||
|
vec![feed_id.into()],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let words = [r.try_get("", "global")?, r.try_get("", "feed")?]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|w: Option<String>| keywords(w).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
Ok((r.try_get("", "user_id")?, words))
|
||||||
|
})
|
||||||
|
.collect::<Result<_>>()?;
|
||||||
|
hidden::Entity::delete_many().filter(hidden::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
|
||||||
|
let lists: Vec<_> = lists.into_iter().filter(|(_, w)| !w.is_empty()).collect();
|
||||||
|
if lists.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let items = entries::Entity::find().filter(entries::Column::FeedId.eq(feed_id)).all(&self.orm).await?;
|
||||||
|
let rows: Vec<hidden::ActiveModel> = items
|
||||||
|
.iter()
|
||||||
|
.flat_map(|e| {
|
||||||
|
let hay = [e.title.as_deref().unwrap_or(""), e.description.as_deref().unwrap_or("")];
|
||||||
|
lists
|
||||||
|
.iter()
|
||||||
|
.filter(move |(_, words)| crate::download::blocked(words, &hay))
|
||||||
|
.map(|(user, _)| hidden::ActiveModel {
|
||||||
|
user_id: Set(*user),
|
||||||
|
feed_id: Set(feed_id.to_owned()),
|
||||||
|
guid: Set(e.guid.clone()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !rows.is_empty() {
|
||||||
|
hidden::Entity::insert_many(rows).exec_without_returning(&self.orm).await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,7 +1945,7 @@ mod tests {
|
|||||||
).await
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let order = async |col: &str, dir: &str| -> Vec<String> {
|
let order = async |col: &str, dir: &str| -> Vec<String> {
|
||||||
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir, false)).await
|
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir)).await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| e.guid)
|
.map(|e| e.guid)
|
||||||
@@ -1751,20 +1967,11 @@ mod tests {
|
|||||||
assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
|
assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
|
||||||
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap();
|
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap();
|
||||||
assert_eq!(order("kept", "desc").await[0], "a");
|
assert_eq!(order("kept", "desc").await[0], "a");
|
||||||
// Pinned first: the pinned banana tops every sort, the rest in the order asked for.
|
// A pin does not lift an item above the others in any other sort (#36).
|
||||||
let pinned = async |col: &str, dir: &str| -> Vec<String> {
|
assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
|
||||||
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir, true)).await
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| e.guid)
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
assert_eq!(pinned("published", "desc").await, ["a", "c", "b"]);
|
|
||||||
assert_eq!(pinned("title", "desc").await, ["a", "c", "b"]);
|
|
||||||
assert_eq!(pinned("kept", "asc").await[2], "a", "sorting by the pin itself keeps its direction");
|
|
||||||
// An unknown column or direction is newest first; the name itself never reaches the SQL.
|
// An unknown column or direction is newest first; the name itself never reaches the SQL.
|
||||||
assert_eq!(order("title; DROP TABLE entries", "sideways").await, ["c", "b", "a"]);
|
assert_eq!(order("title; DROP TABLE entries", "sideways").await, ["c", "b", "a"]);
|
||||||
assert!(!order_sql("x'; --", "asc", false).contains("x'"));
|
assert!(!order_sql("x'; --", "asc").contains("x'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1813,7 +2020,7 @@ mod tests {
|
|||||||
// Starring and position are just as private.
|
// Starring and position are just as private.
|
||||||
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).await.unwrap();
|
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).await.unwrap();
|
||||||
db.set_position(2, "f", "b", 42, Some(600)).await.unwrap();
|
db.set_position(2, "f", "b", 42, Some(600)).await.unwrap();
|
||||||
let order = order_sql("published", "desc", false);
|
let order = order_sql("published", "desc");
|
||||||
let page = async |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).await.unwrap();
|
let page = async |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).await.unwrap();
|
||||||
let (ray, sam) = (page(1).await, page(2).await);
|
let (ray, sam) = (page(1).await, page(2).await);
|
||||||
let ray_b = ray.iter().find(|e| e.guid == "b").unwrap();
|
let ray_b = ray.iter().find(|e| e.guid == "b").unwrap();
|
||||||
@@ -1927,7 +2134,7 @@ mod tests {
|
|||||||
|
|
||||||
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
|
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
|
||||||
// Both paths must run without erroring, and agree with each other.
|
// Both paths must run without erroring, and agree with each other.
|
||||||
let order = order_sql("published", "desc", false);
|
let order = order_sql("published", "desc");
|
||||||
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).await.unwrap();
|
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).await.unwrap();
|
||||||
let n = db.count_in(7, Some("f"), f, None).await.unwrap();
|
let n = db.count_in(7, Some("f"), f, None).await.unwrap();
|
||||||
assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page");
|
assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page");
|
||||||
@@ -1946,7 +2153,7 @@ mod tests {
|
|||||||
"search is case-insensitive and covers the description");
|
"search is case-insensitive and covers the description");
|
||||||
|
|
||||||
// Currently Listening: started, not finished, and not just an accidental tap.
|
// Currently Listening: started, not finished, and not just an accidental tap.
|
||||||
let order = order_sql("published", "desc", false);
|
let order = order_sql("published", "desc");
|
||||||
let listening = async || {
|
let listening = async || {
|
||||||
let rows = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order).await.unwrap();
|
let rows = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order).await.unwrap();
|
||||||
rows.into_iter().map(|e| e.guid).collect::<Vec<_>>()
|
rows.into_iter().map(|e| e.guid).collect::<Vec<_>>()
|
||||||
@@ -1966,6 +2173,42 @@ mod tests {
|
|||||||
assert_eq!(listening().await, ["h", "e"]);
|
assert_eq!(listening().await, ["h", "e"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn block_lists_hide_items_from_whoever_keeps_them() {
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
db.exec_for_test(
|
||||||
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (2,'f');
|
||||||
|
INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
|
||||||
|
('f','x','Election night','',1),
|
||||||
|
('f','y','Baking bread','<p>No politics here, honest</p>',2),
|
||||||
|
('f','z','Gardening','',3);",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let shown = async |user| db.count_in(user, Some("f"), Filter::All, None).await.unwrap();
|
||||||
|
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
db.set_blocklist(1, "", &words(&["election"])).await.unwrap();
|
||||||
|
db.set_blocklist(1, "f", &words(&["politics"])).await.unwrap();
|
||||||
|
assert_eq!(shown(1).await, 1, "a title and a body each hide an item, both lists apply");
|
||||||
|
assert_eq!(db.unread_count(1, "f").await.unwrap(), 1, "hidden is not unread");
|
||||||
|
assert_eq!(shown(2).await, 3, "someone else's list hides nothing from you");
|
||||||
|
|
||||||
|
db.set_blocklist(1, "", &[]).await.unwrap();
|
||||||
|
assert_eq!(shown(1).await, 2, "emptying a list brings its items back");
|
||||||
|
assert!(db.blocklist(1, "").await.unwrap().is_empty());
|
||||||
|
|
||||||
|
// Someone subscribing starts with their list applied, and the scanner sees it.
|
||||||
|
db.set_blocklist(2, "", &words(&["gardening"])).await.unwrap();
|
||||||
|
db.exec_for_test("INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('g','w','Gardening',4)")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db.subscribe(2, "g").await.unwrap();
|
||||||
|
assert_eq!(db.count_in(2, Some("g"), Filter::All, None).await.unwrap(), 0);
|
||||||
|
assert_eq!(db.subscribers("g", None).await.unwrap()[0].blocked, ["gardening"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pending_takes_the_latest_episodes_first() {
|
async fn pending_takes_the_latest_episodes_first() {
|
||||||
// A cap of 3 must mean the three newest, not the three recorded first.
|
// A cap of 3 must mean the three newest, not the three recorded first.
|
||||||
@@ -2087,6 +2330,51 @@ mod tests {
|
|||||||
assert!(db.pinned_feeds(me).await.unwrap().is_empty());
|
assert!(db.pinned_feeds(me).await.unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn postgres_is_asked_for_no_notices_and_a_password_never_reaches_a_log() {
|
||||||
|
assert_eq!(url_for("postgres://u:p@h/d"), format!("postgres://u:p@h/d?{QUIET}"));
|
||||||
|
assert_eq!(url_for("postgres://u:p@h/d?sslmode=disable"), format!("postgres://u:p@h/d?sslmode=disable&{QUIET}"));
|
||||||
|
assert_eq!(url_for("postgres://u:p@h/d?options=-c%20x%3Dy"), "postgres://u:p@h/d?options=-c%20x%3Dy", "theirs stands");
|
||||||
|
assert_eq!(url_for("/data/state.db"), "sqlite:///data/state.db?mode=rwc");
|
||||||
|
assert_eq!(redact("postgres://ipodderx:s3cret@h:5433/ipodderx"), "postgres://ipodderx:***@h:5433/ipodderx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_configuration_goes_in_once_and_comes_back_as_it_went() {
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
assert!(db.stored_config().await.unwrap().is_none(), "nothing until it is taken in");
|
||||||
|
let mut cfg: crate::config::Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
schedule = "every 2h"
|
||||||
|
max_total_gb = 1.5
|
||||||
|
[feeds.a]
|
||||||
|
url = "http://x/a.xml"
|
||||||
|
keywords = ["one", "two"]
|
||||||
|
[feeds.b]
|
||||||
|
url = "http://x/b.xml"
|
||||||
|
password = "secret"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(db.import_config(&cfg).await.unwrap());
|
||||||
|
assert!(!db.import_config(&cfg).await.unwrap(), "a second import finds the first and stands back");
|
||||||
|
let (stored, feeds) = db.stored_config().await.unwrap().unwrap();
|
||||||
|
assert_eq!(stored, crate::config::Stored::of(&cfg));
|
||||||
|
assert_eq!(feeds.keys().collect::<Vec<_>>(), ["a", "b"]);
|
||||||
|
assert_eq!(feeds["a"].keywords, ["one", "two"]);
|
||||||
|
assert_eq!(feeds["b"].password.as_deref(), Some("secret"));
|
||||||
|
|
||||||
|
// Storing is the whole catalogue: a feed taken out goes, one put in arrives.
|
||||||
|
cfg.feeds.remove("a");
|
||||||
|
cfg.feeds.insert("c".into(), cfg.feeds["b"].clone());
|
||||||
|
cfg.general.schedule = "every 30m".into();
|
||||||
|
db.store_config(&cfg).await.unwrap();
|
||||||
|
let (stored, feeds) = db.stored_config().await.unwrap().unwrap();
|
||||||
|
assert_eq!(stored.schedule, "every 30m");
|
||||||
|
assert_eq!(feeds.keys().collect::<Vec<_>>(), ["b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn enclosure_url_is_the_dedupe_key() {
|
async fn enclosure_url_is_the_dedupe_key() {
|
||||||
let db = Db::memory().await.unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
|
|||||||
@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
|
||||||
|
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
|
||||||
|
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
|
||||||
|
/// "Trump's" is by "trump".
|
||||||
|
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
|
||||||
|
// Letters and digits only, each run of anything else one space, padded so a phrase matches
|
||||||
|
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
|
||||||
|
let norm = |s: &str| {
|
||||||
|
let mut out = String::from(" ");
|
||||||
|
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
|
||||||
|
out.push_str(&w.to_lowercase());
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let hay = norm(&haystacks.join(" "));
|
||||||
|
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -404,4 +423,16 @@ mod tests {
|
|||||||
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
||||||
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocking_matches_whole_words_and_phrases() {
|
||||||
|
let words = vec!["AI".to_string(), "new york".to_string()];
|
||||||
|
assert!(blocked(&words, &["What AI means now"]));
|
||||||
|
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
|
||||||
|
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
|
||||||
|
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
|
||||||
|
assert!(!blocked(&words, &["New Yorkshire"]));
|
||||||
|
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
|
||||||
|
assert!(!blocked(&[], &["anything"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,46 @@ pub mod entry_state {
|
|||||||
owned_by_user!();
|
owned_by_user!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
|
||||||
|
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
|
||||||
|
/// every feed they read.
|
||||||
|
pub mod blocklists {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "blocklists")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
/// JSON array of strings.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub words: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The items someone's block lists hide from them, worked out whenever a list or the feed
|
||||||
|
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
|
||||||
|
pub mod hidden {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "hidden")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
pub mod sessions {
|
pub mod sessions {
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
@@ -242,3 +282,44 @@ pub mod sessions {
|
|||||||
|
|
||||||
owned_by_user!();
|
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
|
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||||
non_empty(content)
|
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||||
.filter(|c| !starts_mid_tag(c))
|
Some(c) => Some(match subtitle(&c, description) {
|
||||||
.or_else(|| non_empty(description))
|
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||||
.or_else(|| non_empty(content))
|
None => c,
|
||||||
|
}),
|
||||||
|
None => non_empty(description).or_else(|| non_empty(content)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||||
|
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||||
|
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||||
|
/// description found in the body is not shown twice.
|
||||||
|
///
|
||||||
|
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||||
|
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||||
|
/// `entries` column.
|
||||||
|
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||||
|
let d = title_text(description)?;
|
||||||
|
if d.contains('<') || d.chars().count() > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||||
|
// description ends in "…" or "[...]".
|
||||||
|
let words = |s: &str| {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||||
|
};
|
||||||
|
let want = words(&d);
|
||||||
|
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||||
|
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||||
@@ -841,12 +882,36 @@ mod tests {
|
|||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||||
|
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a description with markup in it is notes, not a subtitle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||||
|
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||||
|
);
|
||||||
|
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||||
|
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||||
|
for d in ["This week we talk about tape, drums and a very long list.",
|
||||||
|
"This week we talk about tape, drums…",
|
||||||
|
"This week we talk about\ntape [...]"] {
|
||||||
|
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||||
|
}
|
||||||
|
let long = "word ".repeat(80);
|
||||||
|
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||||
|
"a long description is notes, not a subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ pub enum Command {
|
|||||||
feed: Option<String>,
|
feed: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
|
||||||
|
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
|
||||||
|
/// Empty is every feed, as the schedule and the CLI mean it.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
feeds: Vec<String>,
|
||||||
},
|
},
|
||||||
Reap {
|
Reap {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -301,10 +306,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn commands_parse_from_the_wire_form() {
|
fn commands_parse_from_the_wire_form() {
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
|
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
|
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Reap { dry_run: true }));
|
assert!(matches!(got, Command::Reap { dry_run: true }));
|
||||||
|
|||||||
221
src/main.rs
221
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod access;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -144,13 +145,19 @@ impl Ctx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-reads config.toml into the live snapshot.
|
/// Re-reads config.toml into the live snapshot.
|
||||||
pub fn reload_cfg(&self, path: &std::path::Path) -> Result<()> {
|
/// Keeps a changed catalogue or server settings: in the database, and for everything running
|
||||||
let fresh = config::Config::load(path)?;
|
/// here from now on. What config.toml's save and reload did, before the database held them.
|
||||||
*self.cfg.write().unwrap() = std::sync::Arc::new(fresh);
|
pub async fn store_cfg(&self, cfg: config::Config) -> Result<()> {
|
||||||
tracing::info!("config reloaded");
|
self.db.store_config(&cfg).await?;
|
||||||
|
self.set_cfg(cfg);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_cfg(&self, cfg: config::Config) {
|
||||||
|
*self.cfg.write().unwrap() = std::sync::Arc::new(cfg);
|
||||||
|
tracing::info!("config reloaded");
|
||||||
|
}
|
||||||
|
|
||||||
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
async fn torrents(&self) -> Result<&torrent::Torrents> {
|
||||||
let cfg = self.cfg();
|
let cfg = self.cfg();
|
||||||
self.torrents
|
self.torrents
|
||||||
@@ -191,7 +198,7 @@ async fn main() -> Result<()> {
|
|||||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||||
let wire_cmd = match &cli.command {
|
let wire_cmd = match &cli.command {
|
||||||
Command::Fetch { feed, force } => {
|
Command::Fetch { feed, force } => {
|
||||||
Some(Cmd::Fetch { feed: feed.clone(), force: *force })
|
Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
|
||||||
}
|
}
|
||||||
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
|
||||||
Command::Status => Some(Cmd::Status),
|
Command::Status => Some(Cmd::Status),
|
||||||
@@ -211,6 +218,14 @@ async fn main() -> Result<()> {
|
|||||||
return ipc::proxy(&cfg.general.socket, cmd).await;
|
return ipc::proxy(&cfg.general.socket, cmd).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copy-db fills an empty database from another, configuration included; taking config.toml
|
||||||
|
// into it first would have the copy collide with it.
|
||||||
|
let cfg = if matches!(cli.command, Command::CopyDb { .. }) {
|
||||||
|
cfg
|
||||||
|
} else {
|
||||||
|
assemble_config(&db, cfg, &config_path).await?
|
||||||
|
};
|
||||||
|
|
||||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||||
let (events, _) = broadcast::channel(1024);
|
let (events, _) = broadcast::channel(1024);
|
||||||
let ctx = Arc::new(Ctx {
|
let ctx = Arc::new(Ctx {
|
||||||
@@ -227,14 +242,14 @@ async fn main() -> Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::List => list(&ctx, &config_path).await,
|
Command::List => list(&ctx).await,
|
||||||
Command::Daemon { web } => daemon(ctx, config_path, web, events).await,
|
Command::Daemon { web } => daemon(ctx, config_path, web, events).await,
|
||||||
Command::Add { url, folder, keywords } => {
|
Command::Add { url, folder, keywords } => {
|
||||||
add(&ctx, &config_path, &url, folder, keywords).await
|
add(&ctx, &url, folder, keywords).await
|
||||||
}
|
}
|
||||||
Command::Rm { feed } => rm(&ctx, &config_path, &feed).await,
|
Command::Rm { feed } => rm(&ctx, &feed).await,
|
||||||
Command::User { cmd } => user_cmd(&ctx, cmd).await,
|
Command::User { cmd } => user_cmd(&ctx, cmd).await,
|
||||||
Command::Import { file } => import(&ctx, &config_path, &file).await,
|
Command::Import { file } => import(&ctx, &file).await,
|
||||||
Command::Export { file } => export(&ctx, &file).await,
|
Command::Export { file } => export(&ctx, &file).await,
|
||||||
Command::CopyDb { from } => copy_db(&ctx, &from).await,
|
Command::CopyDb { from } => copy_db(&ctx, &from).await,
|
||||||
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||||
@@ -340,10 +355,10 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
|
|
||||||
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Fetch { feed, force } => {
|
Cmd::Fetch { feed, force, feeds } => {
|
||||||
// Make room before pulling more down, as the original did per download.
|
// Make room before pulling more down, as the original did per download.
|
||||||
reap(ctx, false, false).await?;
|
reap(ctx, false, false).await?;
|
||||||
fetch(ctx, feed.as_deref(), force).await
|
fetch(ctx, feed.as_deref(), force, &feeds).await
|
||||||
}
|
}
|
||||||
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
|
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
|
||||||
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
|
||||||
@@ -496,7 +511,7 @@ async fn daemon(
|
|||||||
}
|
}
|
||||||
_ = ticker.tick() => {
|
_ = ticker.tick() => {
|
||||||
// Per-feed schedule and TTL decide what actually gets polled.
|
// Per-feed schedule and TTL decide what actually gets polled.
|
||||||
let job = run(&ctx, Cmd::Fetch { feed: None, force: false });
|
let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
|
||||||
if !until_stopped(&ctx, &rx_stop, job).await {
|
if !until_stopped(&ctx, &rx_stop, job).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -533,13 +548,19 @@ async fn start_web(
|
|||||||
fresh.web.enabled = true;
|
fresh.web.enabled = true;
|
||||||
fresh.web.bind = bind.clone();
|
fresh.web.bind = bind.clone();
|
||||||
fresh.web.token = crate::auth::new_session_token();
|
fresh.web.token = crate::auth::new_session_token();
|
||||||
fresh.save(config_path)?;
|
// The token is config.toml's, not the database's: it decides who gets in.
|
||||||
ctx.reload_cfg(config_path)?;
|
fresh.save_bootstrap(config_path)?;
|
||||||
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
|
ctx.set_cfg(fresh.clone());
|
||||||
|
// The token signs in as the admin, and whatever reads this process's output (docker logs,
|
||||||
|
// for one) is wider than who reads config.toml. So say where it is, never what it is.
|
||||||
|
println!(
|
||||||
|
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"web ui at http://{bind}/?token={}",
|
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
|
||||||
ctx.cfg().web.token
|
config_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,11 +568,16 @@ async fn start_web(
|
|||||||
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let access = Arc::new(access::Keys::default());
|
||||||
|
if let Some((team, _)) = ctx.cfg().web.access() {
|
||||||
|
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
|
||||||
|
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
|
||||||
|
}
|
||||||
let state = web::WebState {
|
let state = web::WebState {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
config_path: config_path.to_path_buf(),
|
|
||||||
cmds: cmds.clone(),
|
cmds: cmds.clone(),
|
||||||
events: events.clone(),
|
events: events.clone(),
|
||||||
|
access,
|
||||||
};
|
};
|
||||||
Ok(Some(tokio::spawn(async move {
|
Ok(Some(tokio::spawn(async move {
|
||||||
if let Err(e) = web::serve(state, &bind).await {
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
@@ -575,7 +601,6 @@ async fn shutdown() {
|
|||||||
/// Subscribes to one feed, naming it from its own title.
|
/// Subscribes to one feed, naming it from its own title.
|
||||||
async fn add(
|
async fn add(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
config_path: &std::path::Path,
|
|
||||||
url: &str,
|
url: &str,
|
||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
@@ -587,7 +612,7 @@ async fn add(
|
|||||||
anyhow::bail!("already subscribed as {:?}", existing.id);
|
anyhow::bail!("already subscribed as {:?}", existing.id);
|
||||||
}
|
}
|
||||||
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
||||||
cfg.save(config_path)?;
|
ctx.store_cfg(cfg).await?;
|
||||||
println!("added {id}");
|
println!("added {id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -659,7 +684,7 @@ fn url_stem(url: &str) -> String {
|
|||||||
.unwrap_or_else(|| url.to_owned())
|
.unwrap_or_else(|| url.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = (*ctx.cfg()).clone();
|
||||||
if cfg.feeds.remove(feed).is_none() {
|
if cfg.feeds.remove(feed).is_none() {
|
||||||
// Derived from an OPML: drop it here, though the subscription will list it again
|
// Derived from an OPML: drop it here, though the subscription will list it again
|
||||||
@@ -668,14 +693,14 @@ async fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()>
|
|||||||
println!("removed {feed}; it came from an OPML subscription and may return on the next read");
|
println!("removed {feed}; it came from an OPML subscription and may return on the next read");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
cfg.save(config_path)?;
|
ctx.store_cfg(cfg).await?;
|
||||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||||
println!("removed {feed}; downloads and history kept");
|
println!("removed {feed}; downloads and history kept");
|
||||||
retire_group(ctx, feed).await?;
|
retire_group(ctx, feed).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
|
async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
||||||
let text = std::fs::read_to_string(file)
|
let text = std::fs::read_to_string(file)
|
||||||
.with_context(|| format!("reading {}", file.display()))?;
|
.with_context(|| format!("reading {}", file.display()))?;
|
||||||
// The CLI speaks for the operator, as the shared web token does.
|
// The CLI speaks for the operator, as the shared web token does.
|
||||||
@@ -687,7 +712,7 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
|
|||||||
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
|
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
|
||||||
let doc = opml::OPML::from_str(&text)
|
let doc = opml::OPML::from_str(&text)
|
||||||
.map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
|
.map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
|
||||||
let (added, had) = subscribe_opml(ctx, config_path, &doc, admin.id).await?;
|
let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?;
|
||||||
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -704,7 +729,6 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
|
|||||||
/// before anything is touched: a 400 from the web, a message from the CLI.
|
/// before anything is touched: a 400 from the web, a message from the CLI.
|
||||||
pub async fn subscribe_opml(
|
pub async fn subscribe_opml(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
config_path: &std::path::Path,
|
|
||||||
doc: &opml::OPML,
|
doc: &opml::OPML,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> Result<(usize, usize)> {
|
) -> Result<(usize, usize)> {
|
||||||
@@ -751,8 +775,7 @@ pub async fn subscribe_opml(
|
|||||||
ids.push(id);
|
ids.push(id);
|
||||||
}
|
}
|
||||||
if grew {
|
if grew {
|
||||||
cfg.save(config_path)?;
|
ctx.store_cfg(cfg).await?;
|
||||||
ctx.reload_cfg(config_path)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut added, mut had) = (0, 0);
|
let (mut added, mut had) = (0, 0);
|
||||||
@@ -778,6 +801,40 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The configuration ipx runs with: config.toml for where things are and who may sign in, the
|
||||||
|
/// database for the feeds and the server settings (issue #18). The first time a database holds
|
||||||
|
/// neither, it takes them from config.toml, which is then cut down to the rest, the original kept
|
||||||
|
/// beside it as config.toml.pre-database.
|
||||||
|
async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path::Path) -> Result<config::Config> {
|
||||||
|
for _ in 0..2 {
|
||||||
|
if let Some((stored, feeds)) = db.stored_config().await? {
|
||||||
|
if config::Config::file_holds_stored(path) {
|
||||||
|
tracing::warn!(
|
||||||
|
"config.toml still lists feeds or server settings; they are ignored, since the \
|
||||||
|
database holds them now. Change them in the web UI, or with ipx add and rm."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stored.apply(&mut cfg);
|
||||||
|
cfg.feeds = feeds;
|
||||||
|
return Ok(cfg);
|
||||||
|
}
|
||||||
|
if db.import_config(&cfg).await? {
|
||||||
|
if path.exists() {
|
||||||
|
let original = path.with_extension("toml.pre-database");
|
||||||
|
if !original.exists() {
|
||||||
|
std::fs::copy(path, &original)
|
||||||
|
.with_context(|| format!("keeping the original as {}", original.display()))?;
|
||||||
|
}
|
||||||
|
cfg.save_bootstrap(path)?;
|
||||||
|
}
|
||||||
|
tracing::info!(feeds = cfg.feeds.len(), "moved the feeds and server settings from config.toml into the database");
|
||||||
|
return Ok(cfg);
|
||||||
|
}
|
||||||
|
// Another ipx imported between our look and our insert; the next pass reads its copy.
|
||||||
|
}
|
||||||
|
anyhow::bail!("the database says it holds the configuration and then that it does not")
|
||||||
|
}
|
||||||
|
|
||||||
async fn copy_db(ctx: &Ctx, from: &std::path::Path) -> Result<()> {
|
async fn copy_db(ctx: &Ctx, from: &std::path::Path) -> Result<()> {
|
||||||
anyhow::ensure!(from.exists(), "{} does not exist", from.display());
|
anyhow::ensure!(from.exists(), "{} does not exist", from.display());
|
||||||
let source = db::Db::open(&from.display().to_string()).await?;
|
let source = db::Db::open(&from.display().to_string()).await?;
|
||||||
@@ -808,10 +865,10 @@ async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> {
|
async fn list(ctx: &Ctx) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
if cfg.feeds.is_empty() {
|
if cfg.feeds.is_empty() {
|
||||||
println!("No feeds configured in {}", config_path.display());
|
println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for (id, feed) in &cfg.feeds {
|
for (id, feed) in &cfg.feeds {
|
||||||
@@ -847,7 +904,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
/// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
|
||||||
|
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
let subs = subscriptions(ctx).await?;
|
let subs = subscriptions(ctx).await?;
|
||||||
if let Some(id) = only
|
if let Some(id) = only
|
||||||
@@ -858,7 +916,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
|||||||
|
|
||||||
let mut scanned = 0;
|
let mut scanned = 0;
|
||||||
let mut fresh: Vec<String> = vec![];
|
let mut fresh: Vec<String> = vec![];
|
||||||
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
let in_scope = |s: &Sub| {
|
||||||
|
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
|
||||||
|
};
|
||||||
|
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
|
||||||
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
let (id, feed_cfg) = (&sub.id, &sub.cfg);
|
||||||
let state = ctx.db.http_state(id).await?;
|
let state = ctx.db.http_state(id).await?;
|
||||||
|
|
||||||
@@ -1193,6 +1254,10 @@ async fn scan_one(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if scan.new_entries > 0 {
|
||||||
|
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
|
||||||
|
}
|
||||||
|
|
||||||
let budget = policy.budget;
|
let budget = policy.budget;
|
||||||
if policy.auto_download && budget > 0 {
|
if policy.auto_download && budget > 0 {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
@@ -1397,15 +1462,10 @@ fn reject(
|
|||||||
entry.description.as_deref().unwrap_or(""),
|
entry.description.as_deref().unwrap_or(""),
|
||||||
categories.as_str(),
|
categories.as_str(),
|
||||||
];
|
];
|
||||||
// One file serves everyone subscribed, so an item is wanted if it is wanted by
|
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
|
||||||
// anyone: any one person's keyword set matching is enough.
|
if !policy.wanted(&haystacks, &text) {
|
||||||
let wanted_by_someone = policy.keyword_sets.is_empty()
|
// Worded for whichever filter could have let it through, the keywords if none blocked it.
|
||||||
|| policy
|
return Some(if policy.wanted(&haystacks, &[]) { "blocked word" } else { "no keyword match" });
|
||||||
.keyword_sets
|
|
||||||
.iter()
|
|
||||||
.any(|set| download::matches_keywords(set, &haystacks));
|
|
||||||
if !wanted_by_someone {
|
|
||||||
return Some("no keyword match");
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -1419,11 +1479,28 @@ fn reject(
|
|||||||
pub struct Policy {
|
pub struct Policy {
|
||||||
pub auto_download: bool,
|
pub auto_download: bool,
|
||||||
pub allow_explicit: bool,
|
pub allow_explicit: bool,
|
||||||
/// Empty means take everything. Otherwise one set per subscriber who filters.
|
/// What each subscriber fetching the feed wants.
|
||||||
pub keyword_sets: Vec<Vec<String>>,
|
pub wants: Vec<Want>,
|
||||||
pub budget: usize,
|
pub budget: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One person's filters: an item is theirs if it matches their keywords (all of it, with none)
|
||||||
|
/// and none of their blocked words.
|
||||||
|
pub struct Want {
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
pub blocked: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Policy {
|
||||||
|
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
|
||||||
|
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
|
||||||
|
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
|
||||||
|
self.wants.iter().any(|w| {
|
||||||
|
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
|
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
|
||||||
let global = ctx.cfg().general.max_new_per_check;
|
let global = ctx.cfg().general.max_new_per_check;
|
||||||
Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global))
|
Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global))
|
||||||
@@ -1436,11 +1513,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
return Policy {
|
return Policy {
|
||||||
auto_download: feed_cfg.auto_download,
|
auto_download: feed_cfg.auto_download,
|
||||||
allow_explicit: feed_cfg.allow_explicit,
|
allow_explicit: feed_cfg.allow_explicit,
|
||||||
keyword_sets: if feed_cfg.keywords.is_empty() {
|
wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
|
||||||
vec![]
|
|
||||||
} else {
|
|
||||||
vec![feed_cfg.keywords.clone()]
|
|
||||||
},
|
|
||||||
budget: cap(feed_cfg.max_new_per_check),
|
budget: cap(feed_cfg.max_new_per_check),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1448,7 +1521,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
let mut policy = Policy {
|
let mut policy = Policy {
|
||||||
auto_download: false,
|
auto_download: false,
|
||||||
allow_explicit: false,
|
allow_explicit: false,
|
||||||
keyword_sets: vec![],
|
wants: vec![],
|
||||||
budget: 0,
|
budget: 0,
|
||||||
};
|
};
|
||||||
for sub in subs {
|
for sub in subs {
|
||||||
@@ -1460,12 +1533,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
policy.budget = policy
|
policy.budget = policy
|
||||||
.budget
|
.budget
|
||||||
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
|
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
|
||||||
let kw = sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone());
|
policy.wants.push(Want {
|
||||||
if kw.is_empty() {
|
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
|
||||||
// Somebody takes everything, so no filter can apply to the shared copy.
|
blocked: sub.blocked.clone(),
|
||||||
return Policy { keyword_sets: vec![], ..policy };
|
});
|
||||||
}
|
|
||||||
policy.keyword_sets.push(kw);
|
|
||||||
}
|
}
|
||||||
policy
|
policy
|
||||||
}
|
}
|
||||||
@@ -1670,6 +1741,32 @@ fn duration(secs: u64) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"[general]\nschedule = \"every 2h\"\n[web]\ntoken = \"t\"\n[feeds.show]\nurl = \"http://x/show.xml\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let db = db::Db::memory().await.unwrap();
|
||||||
|
|
||||||
|
let first = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
|
||||||
|
assert_eq!(first.feeds.keys().collect::<Vec<_>>(), ["show"]);
|
||||||
|
assert_eq!(first.general.schedule, "every 2h");
|
||||||
|
assert!(dir.join("config.toml.pre-database").exists(), "the original is kept");
|
||||||
|
assert!(!config::Config::file_holds_stored(&path), "and the file no longer lists them");
|
||||||
|
|
||||||
|
// The next start reads them from the database, the trimmed file notwithstanding.
|
||||||
|
let next = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
|
||||||
|
assert_eq!(next.feeds.keys().collect::<Vec<_>>(), ["show"]);
|
||||||
|
assert_eq!(next.general.schedule, "every 2h");
|
||||||
|
assert_eq!(next.web.token, "t");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn feed() -> config::Feed {
|
fn feed() -> config::Feed {
|
||||||
// Whatever `ipx add` would write, which is the shape every code path sees.
|
// Whatever `ipx add` would write, which is the shape every code path sees.
|
||||||
let mut cfg = config::Config::default();
|
let mut cfg = config::Config::default();
|
||||||
@@ -1708,6 +1805,7 @@ mod tests {
|
|||||||
auto_download: auto,
|
auto_download: auto,
|
||||||
allow_explicit: None,
|
allow_explicit: None,
|
||||||
max_new_per_check: max,
|
max_new_per_check: max,
|
||||||
|
blocked: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1717,7 +1815,7 @@ mod tests {
|
|||||||
let p = merge_policy(&[], &feed(), 3);
|
let p = merge_policy(&[], &feed(), 3);
|
||||||
assert!(p.auto_download);
|
assert!(p.auto_download);
|
||||||
assert_eq!(p.budget, 3);
|
assert_eq!(p.budget, 3);
|
||||||
assert!(p.keyword_sets.is_empty());
|
assert!(p.wanted(&["anything"], &[]));
|
||||||
|
|
||||||
// Two filters: an item wanted by either of them is fetched, since one file serves
|
// Two filters: an item wanted by either of them is fetched, since one file serves
|
||||||
// both. The larger per-scan cap wins for the same reason.
|
// both. The larger per-scan cap wins for the same reason.
|
||||||
@@ -1726,12 +1824,21 @@ mod tests {
|
|||||||
&feed(),
|
&feed(),
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
assert_eq!(p.keyword_sets.len(), 2);
|
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
|
||||||
|
assert!(!p.wanted(&["python"], &[]));
|
||||||
assert_eq!(p.budget, 9);
|
assert_eq!(p.budget, 9);
|
||||||
|
|
||||||
// One person taking everything removes the filter for the shared copy.
|
// One person taking everything removes the filter for the shared copy.
|
||||||
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
|
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
|
||||||
assert!(p.keyword_sets.is_empty());
|
assert!(p.wanted(&["python"], &[]));
|
||||||
|
|
||||||
|
// A blocked word keeps an item from being fetched for that person, not for the rest.
|
||||||
|
let blocking = |w: &str| db::Sub { blocked: vec![w.into()], ..sub(None, None, None) };
|
||||||
|
let p = merge_policy(&[blocking("politics")], &feed(), 3);
|
||||||
|
assert!(!p.wanted(&[], &["Politics today"]));
|
||||||
|
assert!(p.wanted(&[], &["Cooking today"]));
|
||||||
|
let p = merge_policy(&[blocking("politics"), sub(None, None, None)], &feed(), 3);
|
||||||
|
assert!(p.wanted(&[], &["Politics today"]), "someone else still wants it");
|
||||||
|
|
||||||
// Everyone has auto-download off: nothing is fetched automatically.
|
// Everyone has auto-download off: nothing is fetched automatically.
|
||||||
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);
|
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);
|
||||||
|
|||||||
147
src/web.rs
147
src/web.rs
@@ -16,7 +16,6 @@ use serde::Deserialize;
|
|||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use tower_http::services::ServeFile;
|
use tower_http::services::ServeFile;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
@@ -28,9 +27,10 @@ const COOKIE: &str = "ipx_token";
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebState {
|
pub struct WebState {
|
||||||
pub ctx: Arc<Ctx>,
|
pub ctx: Arc<Ctx>,
|
||||||
pub config_path: PathBuf,
|
|
||||||
pub cmds: mpsc::Sender<Command>,
|
pub cmds: mpsc::Sender<Command>,
|
||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
|
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||||
|
pub access: Arc<crate::access::Keys>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
@@ -67,6 +67,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/login", get(login_page))
|
.route("/login", get(login_page))
|
||||||
.route("/api/login", post(login))
|
.route("/api/login", post(login))
|
||||||
.route("/icon.png", get(icon))
|
.route("/icon.png", get(icon))
|
||||||
|
.route("/logo.svg", get(logo))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.route("/favicon.png", get(favicon))
|
.route("/favicon.png", get(favicon))
|
||||||
.route("/apple-touch-icon.png", get(touch_icon))
|
.route("/apple-touch-icon.png", get(touch_icon))
|
||||||
@@ -101,7 +102,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
// 1. A header, but only from a hop we were told to believe.
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
let vouched = vouched_name(&cfg, &req);
|
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -203,20 +204,31 @@ struct Proxied(bool);
|
|||||||
|
|
||||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||||
/// be whoever they liked.
|
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||||
let peer = req
|
///
|
||||||
.extensions()
|
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
/// the future unsendable, as a body is not `Sync`.
|
||||||
.map(|c| c.0.ip().to_string())
|
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
.unwrap_or_default();
|
|
||||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
req.headers()
|
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
.get(&cfg.web.trusted_header)
|
let Some((team, aud)) = cfg.web.access() else {
|
||||||
.and_then(|v| v.to_str().ok())
|
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||||
.and_then(crate::auth::name_from_header)
|
};
|
||||||
|
// The plain header still has to be there, as it is what switches this path on for a
|
||||||
|
// request; who it names is the token's to say.
|
||||||
|
header(&cfg.web.trusted_header)?;
|
||||||
|
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||||
|
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(req: &Request) -> String {
|
||||||
|
req.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
@@ -321,29 +333,40 @@ async fn me(
|
|||||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||||
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||||
|
"blocked": blocked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct MePatch {
|
struct MePatch {
|
||||||
theme: String,
|
theme: Option<String>,
|
||||||
mode: String,
|
mode: Option<String>,
|
||||||
|
/// Words that hide an item in every feed you read.
|
||||||
|
blocked: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the theme to the account, so it follows the person rather than the browser.
|
/// Saves the theme to the account, so it follows the person rather than the browser, and the
|
||||||
|
/// block list for every feed.
|
||||||
async fn patch_me(
|
async fn patch_me(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<MePatch>,
|
Json(body): Json<MePatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
if body.theme.is_some() || body.mode.is_some() {
|
||||||
// into the page's <html> tag, which is where index() puts it.
|
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
|
||||||
if !theme_ok(&body.theme, &body.mode) {
|
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
||||||
return Err(ApiError::bad_request("not a theme"));
|
// into the page's <html> tag, which is where index() puts it.
|
||||||
|
if !theme_ok(&theme, &mode) {
|
||||||
|
return Err(ApiError::bad_request("not a theme"));
|
||||||
|
}
|
||||||
|
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
|
||||||
|
}
|
||||||
|
if let Some(words) = body.blocked {
|
||||||
|
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
|
||||||
}
|
}
|
||||||
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +499,7 @@ async fn remove_user(
|
|||||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
/// the form only made it look as if they were not.
|
/// the form only made it look as if they were not.
|
||||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
@@ -530,8 +553,16 @@ fn script(js: &'static str) -> impl IntoResponse {
|
|||||||
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
/// The logo the pages show, served once rather than inlined into each. The sign-in page shows
|
||||||
/// sign-in page shows it, so it sits outside the auth layer with /login.
|
/// it, so it sits outside the auth layer with /login.
|
||||||
|
async fn logo() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_str!("../web/logo.svg"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
|
||||||
async fn icon() -> impl IntoResponse {
|
async fn icon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
@@ -539,9 +570,8 @@ async fn icon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
|
/// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
|
||||||
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
|
/// own; behind the auth layer it answered 401, and the tab stayed blank.
|
||||||
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
|
|
||||||
async fn favicon() -> impl IntoResponse {
|
async fn favicon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
@@ -549,8 +579,8 @@ async fn favicon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
|
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
|
||||||
/// one is on white.
|
/// a transparent icon's background black and cuts its own corners.
|
||||||
async fn touch_icon() -> impl IntoResponse {
|
async fn touch_icon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
@@ -628,6 +658,8 @@ struct FeedRow {
|
|||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
feed_category: Option<String>,
|
feed_category: Option<String>,
|
||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
|
/// Your words that hide an item of this feed, beside your list for every feed.
|
||||||
|
blocked: Vec<String>,
|
||||||
allow_explicit: bool,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
max_new_per_check: Option<usize>,
|
max_new_per_check: Option<usize>,
|
||||||
@@ -704,6 +736,7 @@ async fn feeds(
|
|||||||
.clone()
|
.clone()
|
||||||
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
||||||
.unwrap_or_else(|| feed.keywords.clone()),
|
.unwrap_or_else(|| feed.keywords.clone()),
|
||||||
|
blocked: state.ctx.db.blocklist(user.id, id).await?,
|
||||||
allow_explicit: mine
|
allow_explicit: mine
|
||||||
.allow_explicit
|
.allow_explicit
|
||||||
.or(up.and_then(|u| u.allow_explicit))
|
.or(up.and_then(|u| u.allow_explicit))
|
||||||
@@ -1146,12 +1179,7 @@ async fn entry_page(
|
|||||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
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 search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
// Currently Listening keeps its own order, pinned or not: it is what you are part-way through.
|
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"),
|
|
||||||
filter != crate::db::Filter::InProgress,
|
|
||||||
);
|
|
||||||
let mut rows =
|
let mut rows =
|
||||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
||||||
let mut sanitizer = feed_sanitizer();
|
let mut sanitizer = feed_sanitizer();
|
||||||
@@ -1234,8 +1262,7 @@ async fn add_feed(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
state.ctx.db.subscribe(user.id, &id).await?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
||||||
scan_soon(&state, Some(id.clone())).await;
|
scan_soon(&state, Some(id.clone())).await;
|
||||||
@@ -1247,7 +1274,7 @@ async fn add_feed(
|
|||||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||||
let force = feed.is_some();
|
let force = feed.is_some();
|
||||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
|
||||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1294,7 @@ struct FeedPatch {
|
|||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
category: Option<Option<String>>,
|
category: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
|
blocked: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
@@ -1274,6 +1302,21 @@ struct FeedPatch {
|
|||||||
pinned: Option<bool>,
|
pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
|
||||||
|
/// looked for in every item of every feed the list covers.
|
||||||
|
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
|
||||||
|
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
|
||||||
|
out.push(w.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
|
||||||
|
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
where
|
where
|
||||||
T: Deserialize<'de>,
|
T: Deserialize<'de>,
|
||||||
@@ -1322,6 +1365,9 @@ async fn patch_feed(
|
|||||||
if touched {
|
if touched {
|
||||||
state.ctx.db.set_subscription(user.id, &mine).await?;
|
state.ctx.db.set_subscription(user.id, &mine).await?;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = body.blocked.clone() {
|
||||||
|
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rest describes the feed itself -- where its files land, its address, when it is
|
// The rest describes the feed itself -- where its files land, its address, when it is
|
||||||
@@ -1383,8 +1429,7 @@ async fn patch_feed(
|
|||||||
if let Some(v) = body.category {
|
if let Some(v) = body.category {
|
||||||
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
if url_changed {
|
if url_changed {
|
||||||
// Refreshing a rotated auth token is the common case; entries and download history
|
// Refreshing a rotated auth token is the common case; entries and download history
|
||||||
// are keyed by feed id, so they survive the change.
|
// are keyed by feed id, so they survive the change.
|
||||||
@@ -1420,8 +1465,7 @@ async fn remove_feed(
|
|||||||
state.ctx.db.drop_managed(&id).await?;
|
state.ctx.db.drop_managed(&id).await?;
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
crate::retire_group(&state.ctx, &id).await?;
|
crate::retire_group(&state.ctx, &id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1537,11 +1581,21 @@ struct FetchBody {
|
|||||||
|
|
||||||
async fn fetch_now(
|
async fn fetch_now(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
Json(body): Json<FetchBody>,
|
Json(body): Json<FetchBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
|
||||||
|
let feeds = if body.feed.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
|
||||||
|
};
|
||||||
|
if body.feed.is_none() && feeds.is_empty() {
|
||||||
|
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Fetch { feed: body.feed, force: body.force })
|
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
@@ -1718,7 +1772,7 @@ async fn import_opml(
|
|||||||
// file arrives as text, is read here, and is gone when the request ends.
|
// file arrives as text, is read here, and is gone when the request ends.
|
||||||
let doc = opml::OPML::from_str(&body.xml)
|
let doc = opml::OPML::from_str(&body.xml)
|
||||||
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
.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).await?;
|
let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
|
||||||
if added > 0 {
|
if added > 0 {
|
||||||
scan_soon(&state, None).await;
|
scan_soon(&state, None).await;
|
||||||
}
|
}
|
||||||
@@ -1792,8 +1846,7 @@ async fn patch_settings(
|
|||||||
if let Some(v) = body.max_age_days {
|
if let Some(v) = body.max_age_days {
|
||||||
cfg.general.max_age_days = v;
|
cfg.general.max_age_days = v;
|
||||||
}
|
}
|
||||||
cfg.save(&state.config_path)?;
|
state.ctx.store_cfg(cfg).await?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
100
tests/contrast.js
Normal file
100
tests/contrast.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// 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 sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
|
||||||
|
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
|
||||||
|
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,
|
||||||
|
// and a panel's top-left corner under its sheen at full strength.
|
||||||
|
const panel = over(rgb(p.panel), .7, g);
|
||||||
|
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
|
||||||
|
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.
|
// and every server-side test passed too, because the server was fine.
|
||||||
//
|
//
|
||||||
// node tests/page-smoke.js
|
// node tests/page-smoke.js
|
||||||
const vm = require('vm');
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
const PAGE = process.argv[2] || 'index.html';
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
|
|
||||||
const { buildPage } = require('../web/build.mjs');
|
const { buildPage } = require('../web/build.mjs');
|
||||||
@@ -19,57 +19,7 @@ if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && P
|
|||||||
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||||
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||||
}
|
}
|
||||||
// Ids in the page, and in the markup the script builds for its dialogs.
|
const { ctx, missing } = makeContext(html, script);
|
||||||
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
|
||||||
|
|
||||||
const missing = [];
|
|
||||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
|
||||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
|
||||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
|
||||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
|
||||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
||||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
querySelector(sel) {
|
|
||||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
||||||
return el(sel);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => el('created'),
|
|
||||||
addEventListener(){}, body: el('body'),
|
|
||||||
documentElement: { dataset: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
document, console,
|
|
||||||
window: { isSecureContext: false, addEventListener(){} },
|
|
||||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
||||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
||||||
fetch: (url) => Promise.resolve({
|
|
||||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
||||||
json: () => Promise.resolve(
|
|
||||||
String(url).includes('/api/settings')
|
|
||||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
||||||
: String(url).includes('/api/users')
|
|
||||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
||||||
: /\/api\/(popular|directory)/.test(String(url))
|
|
||||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
||||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
||||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
||||||
}),
|
|
||||||
EventSource: function () { this.close = () => {}; },
|
|
||||||
MediaMetadata: function () {},
|
|
||||||
Blob: function () {},
|
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
||||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
||||||
};
|
|
||||||
ctx.globalThis = ctx;
|
|
||||||
ctx.window.location = { href: '', hash: '' };
|
|
||||||
ctx.location = ctx.window.location;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
|
|||||||
@@ -558,10 +558,10 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
const ctx = await browser.newContext();
|
const ctx = await browser.newContext();
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
// The sign-in page shows the icon, so it has to load before anyone has signed in.
|
// The sign-in page shows the logo, so it has to load before anyone has signed in.
|
||||||
const icon = await page.request.get('/icon.png');
|
const logo = await page.request.get('/logo.svg');
|
||||||
expect(icon.status()).toBe(200);
|
expect(logo.status()).toBe(200);
|
||||||
expect(icon.headers()['content-type']).toBe('image/png');
|
expect(logo.headers()['content-type']).toBe('image/svg+xml');
|
||||||
await page.locator('#name').fill('sam');
|
await page.locator('#name').fill('sam');
|
||||||
await page.locator('#pw').fill('sampassword');
|
await page.locator('#pw').fill('sampassword');
|
||||||
await page.locator('button[type=submit]').click();
|
await page.locator('button[type=submit]').click();
|
||||||
@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await expect(page.locator('#admin')).toHaveCount(0);
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
||||||
// Asking for it anyway goes back to the app, and its script is refused.
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
@@ -1179,7 +1179,7 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||||
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
|
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
||||||
// A browser asks for /favicon.ico on its own, signed in or not.
|
// A browser asks for /favicon.ico on its own, signed in or not.
|
||||||
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
|
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) {
|
||||||
const r = await page.request.get(path, { headers: { cookie: '' } });
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||||
@@ -1211,14 +1211,15 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
||||||
async function drag(page, from, to) {
|
async function drag(page, from, to, lift = true) {
|
||||||
const cdp = await page.context().newCDPSession(page);
|
const cdp = await page.context().newCDPSession(page);
|
||||||
const steps = 8;
|
const steps = 8;
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
||||||
for (let i = 1; i <= steps; i++)
|
for (let i = 1; i <= steps; i++)
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
||||||
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
return cdp;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
||||||
@@ -1231,6 +1232,17 @@ test.describe('touch gestures on a phone', () => {
|
|||||||
const shown = page.locator('#detail .dt');
|
const shown = page.locator('#detail .dt');
|
||||||
await expect(shown).toHaveText(titles[0]);
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
|
||||||
|
// Held partway: the next item is beside the reader, not the list under it (#52), and
|
||||||
|
// let go short of a swipe, it goes again.
|
||||||
|
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
|
||||||
|
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
|
||||||
|
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
|
||||||
|
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await expect(page.locator('#dpeek')).toHaveCount(0);
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
await expect(shown).toHaveText(titles[1]);
|
await expect(shown).toHaveText(titles[1]);
|
||||||
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
||||||
@@ -1300,19 +1312,74 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|||||||
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a pinned item goes to the top of its list, and back when unpinned', async ({ page }) => {
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('words in Settings hide the items that mention them', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
|
||||||
const guids = () => page.locator('.ep').evaluateAll(rows => rows.map(r => r.dataset.guid));
|
|
||||||
const before = await guids();
|
await page.locator('#prefs').click();
|
||||||
const last = before[before.length - 1];
|
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
|
||||||
const row = page.locator(`.ep[data-guid="${last}"]`);
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
await row.locator('[data-a="flag"]').click();
|
await page.locator('#sblock').press('Tab');
|
||||||
await expect.poll(async () => (await guids())[0]).toBe(last);
|
expect((await saved).status()).toBe(204);
|
||||||
// It is the server's order, so it holds on a reload.
|
await page.locator('#modal .cardx').click();
|
||||||
await page.reload();
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
|
||||||
await expect.poll(async () => (await guids())[0]).toBe(last);
|
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
|
||||||
await page.locator(`.ep[data-guid="${last}"] [data-a="flag"]`).click();
|
|
||||||
await expect.poll(guids).toEqual(before);
|
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
|
||||||
|
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="share"]').click();
|
||||||
|
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
||||||
|
await page.locator('.encbox [data-a="share"]').first().click();
|
||||||
|
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
||||||
|
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
||||||
|
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>iPodderX admin</title>
|
<title>iPX admin</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
<body class="adminpage">
|
<body class="adminpage">
|
||||||
<!-- The server sends this page, and its script, to admins only. -->
|
<!-- The server sends this page, and its script, to admins only. -->
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
|
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||||
<img class="logo" src="/icon.png" alt="" width="26">
|
<img class="logo" src="/logo.svg" alt="" width="26">
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<nav class="tabs" id="atabs">
|
<nav class="tabs" id="atabs">
|
||||||
|
|||||||
478
web/app.css
478
web/app.css
@@ -12,7 +12,7 @@
|
|||||||
--line:#2c3849;
|
--line:#2c3849;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
@@ -32,12 +32,12 @@
|
|||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
--dim:#c4c4c8;
|
--dim:#c4c4c8;
|
||||||
--faint:#a0a0a7;
|
--faint:#a0a0a7;
|
||||||
--accent:#81d0ff;
|
--accent:#81d0ff;
|
||||||
--accent2:#3584e4;
|
--accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
|
||||||
--ink:#1d1d20;
|
--ink:#1d1d20;
|
||||||
--good:#78e9ab;
|
--good:#78e9ab;
|
||||||
--warn:#ffc252;
|
--warn:#ffc252;
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
--dim:#57575d;
|
--dim:#57575d;
|
||||||
--faint:#66666c;
|
--faint:#66666c;
|
||||||
--accent:#0461be;
|
--accent:#0461be;
|
||||||
--accent2:#3584e4;
|
--accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#00753a;
|
--good:#00753a;
|
||||||
--warn:#905400;
|
--warn:#905400;
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
--dim:#5c616c; /* fg */
|
--dim:#5c616c; /* fg */
|
||||||
--faint:#686d78;
|
--faint:#686d78;
|
||||||
--accent:#0060f0; /* link */
|
--accent:#0060f0; /* link */
|
||||||
--accent2:#fd7d00;
|
--accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#23794f;
|
--good:#23794f;
|
||||||
--warn:#a85400;
|
--warn:#a85400;
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
--dim:#3d3a33;
|
--dim:#3d3a33;
|
||||||
--faint:#5a564c;
|
--faint:#5a564c;
|
||||||
--accent:#1a5fae;
|
--accent:#1a5fae;
|
||||||
--accent2:#b58900;
|
--accent2:#957100; /* taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#216609;
|
--good:#216609;
|
||||||
--warn:#795a00;
|
--warn:#795a00;
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
--panel:#3b4252; /* nord1 */
|
--panel:#3b4252; /* nord1 */
|
||||||
--panel2:#434c5e; /* nord2 */
|
--panel2:#434c5e; /* nord2 */
|
||||||
--raise:#4c566a; /* nord3 */
|
--raise:#4c566a; /* nord3 */
|
||||||
--line:#434c5e;
|
--line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
|
||||||
--fg:#eceff4; /* nord6 */
|
--fg:#eceff4; /* nord6 */
|
||||||
--dim:#d8dee9; /* nord4 */
|
--dim:#d8dee9; /* nord4 */
|
||||||
--faint:#b4bccb;
|
--faint:#b4bccb;
|
||||||
@@ -232,13 +232,158 @@
|
|||||||
--dim:#3b4252; /* nord1 */
|
--dim:#3b4252; /* nord1 */
|
||||||
--faint:#4c566a; /* nord3 */
|
--faint:#4c566a; /* nord3 */
|
||||||
--accent:#3a5e8a;
|
--accent:#3a5e8a;
|
||||||
--accent2:#b0674e;
|
--accent2:#ab644c;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#446632;
|
--good:#446632;
|
||||||
--warn:#7e590e;
|
--warn:#7e590e;
|
||||||
--bad:#9c3f49;
|
--bad:#9c3f49;
|
||||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||||
}
|
}
|
||||||
|
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
|
||||||
|
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||||
|
and peach for the rest. */
|
||||||
|
:root[data-theme="catppuccin"] {
|
||||||
|
--bg:#1e1e2e; /* base */
|
||||||
|
--panel:#181825; /* mantle */
|
||||||
|
--panel2:#313244; /* surface0 */
|
||||||
|
--raise:#45475a; /* surface1 */
|
||||||
|
--line:#45475a;
|
||||||
|
--fg:#cdd6f4; /* text */
|
||||||
|
--dim:#bac2de; /* subtext1 */
|
||||||
|
--faint:#a6adc8; /* subtext0 */
|
||||||
|
--accent:#cba6f7; /* mauve */
|
||||||
|
--accent2:#fab387; /* peach */
|
||||||
|
--ink:#11111b; /* crust */
|
||||||
|
--good:#a6e3a1;
|
||||||
|
--warn:#f9e2af;
|
||||||
|
--bad:#f38ba8;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||||
|
--bg:#eff1f5; /* base */
|
||||||
|
--panel:#e6e9ef; /* mantle */
|
||||||
|
--panel2:#dce0e8; /* crust */
|
||||||
|
--raise:#ccd0da; /* surface0 */
|
||||||
|
--line:#bcc0cc; /* surface1 */
|
||||||
|
--fg:#4c4f69; /* text */
|
||||||
|
--dim:#5c5f77; /* subtext1 */
|
||||||
|
--faint:#5f6276; /* subtext0 */
|
||||||
|
--accent:#8638ec; /* mauve */
|
||||||
|
--accent2:#c74e09; /* peach */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#3a9127;
|
||||||
|
--warn:#925d13;
|
||||||
|
--bad:#d00f39;
|
||||||
|
--shadow:0 8px 28px rgba(76,79,105,.15);
|
||||||
|
}
|
||||||
|
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
|
||||||
|
half as gruvbox itself does. Blue for the accent, orange for what is new. */
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--bg:#282828; /* bg0 */
|
||||||
|
--panel:#1d2021; /* bg0_h */
|
||||||
|
--panel2:#32302f; /* bg0_s */
|
||||||
|
--raise:#3c3836; /* bg1 */
|
||||||
|
--line:#504945; /* bg2 */
|
||||||
|
--fg:#ebdbb2; /* fg */
|
||||||
|
--dim:#d5c4a1; /* fg2 */
|
||||||
|
--faint:#a89984; /* fg4 */
|
||||||
|
--accent:#83a598; /* blue */
|
||||||
|
--accent2:#fe8019; /* orange */
|
||||||
|
--ink:#1d2021;
|
||||||
|
--good:#b8bb26;
|
||||||
|
--warn:#fabd2f;
|
||||||
|
--bad:#fb533f;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||||
|
--bg:#fbf1c7; /* bg0 */
|
||||||
|
--panel:#f2e5bc; /* bg0_s */
|
||||||
|
--panel2:#ebdbb2; /* bg1 */
|
||||||
|
--raise:#d5c4a1; /* bg2 */
|
||||||
|
--line:#d5c4a1;
|
||||||
|
--fg:#3c3836; /* fg */
|
||||||
|
--dim:#504945; /* fg2 */
|
||||||
|
--faint:#665c54; /* fg3 */
|
||||||
|
--accent:#076678; /* faded blue */
|
||||||
|
--accent2:#af3a03; /* faded orange */
|
||||||
|
--ink:#fbf1c7;
|
||||||
|
--good:#79740e;
|
||||||
|
--warn:#8d5c10;
|
||||||
|
--bad:#9d0006;
|
||||||
|
--shadow:0 8px 28px rgba(60,56,54,.15);
|
||||||
|
}
|
||||||
|
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
|
||||||
|
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
|
||||||
|
:root[data-theme="solarized"] {
|
||||||
|
--bg:#002b36; /* base03 */
|
||||||
|
--panel:#073642; /* base02 */
|
||||||
|
--panel2:#0b3f4c;
|
||||||
|
--raise:#124a58;
|
||||||
|
--line:#1d5563;
|
||||||
|
--fg:#eee8d5; /* base2 */
|
||||||
|
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
|
||||||
|
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
|
||||||
|
--accent:#4b9fda; /* blue */
|
||||||
|
--accent2:#b58900; /* yellow */
|
||||||
|
--ink:#002b36;
|
||||||
|
--good:#859900;
|
||||||
|
--warn:#bb9315;
|
||||||
|
--bad:#e87674;
|
||||||
|
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
:root[data-theme="solarized"][data-mode="light"] {
|
||||||
|
--bg:#fdf6e3; /* base3 */
|
||||||
|
--panel:#eee8d5; /* base2 */
|
||||||
|
--panel2:#e6dfca;
|
||||||
|
--raise:#ddd5bd;
|
||||||
|
--line:#d3cab0;
|
||||||
|
--fg:#073642; /* base02 */
|
||||||
|
--dim:#52666d; /* base01 */
|
||||||
|
--faint:#54666d; /* base00 */
|
||||||
|
--accent:#1e6da5; /* blue */
|
||||||
|
--accent2:#cb4b16; /* orange */
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#768700;
|
||||||
|
--warn:#846400;
|
||||||
|
--bad:#c52d2a;
|
||||||
|
--shadow:0 8px 28px rgba(0,43,54,.14);
|
||||||
|
}
|
||||||
|
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
|
||||||
|
that show. For reading, not for looks. */
|
||||||
|
:root[data-theme="contrast"] {
|
||||||
|
--bg:#000000;
|
||||||
|
--panel:#000000;
|
||||||
|
--panel2:#121212;
|
||||||
|
--raise:#333333;
|
||||||
|
--line:#9a9a9a;
|
||||||
|
--fg:#ffffff;
|
||||||
|
--dim:#ffffff;
|
||||||
|
--faint:#d6d6d6;
|
||||||
|
--accent:#8cc8ff;
|
||||||
|
--accent2:#ffd400;
|
||||||
|
--ink:#000000;
|
||||||
|
--good:#7ee787;
|
||||||
|
--warn:#ffd400;
|
||||||
|
--bad:#ff9a8f;
|
||||||
|
--shadow:0 0 0 1px #9a9a9a;
|
||||||
|
}
|
||||||
|
:root[data-theme="contrast"][data-mode="light"] {
|
||||||
|
--bg:#ffffff;
|
||||||
|
--panel:#ffffff;
|
||||||
|
--panel2:#f0f0f0;
|
||||||
|
--raise:#d6d6d6;
|
||||||
|
--line:#555555;
|
||||||
|
--fg:#000000;
|
||||||
|
--dim:#000000;
|
||||||
|
--faint:#303030;
|
||||||
|
--accent:#0033a0;
|
||||||
|
--accent2:#7a3d00;
|
||||||
|
--ink:#ffffff;
|
||||||
|
--good:#0a5c1c;
|
||||||
|
--warn:#6b4500;
|
||||||
|
--bad:#a30000;
|
||||||
|
--shadow:0 0 0 1px #555555;
|
||||||
|
}
|
||||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||||
:root[data-theme="classic"] {
|
:root[data-theme="classic"] {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
@@ -250,14 +395,65 @@
|
|||||||
--fg:#000000;
|
--fg:#000000;
|
||||||
--dim:#444444;
|
--dim:#444444;
|
||||||
--faint:#666666;
|
--faint:#666666;
|
||||||
--accent:#3875d7; /* Aqua selection blue */
|
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
|
||||||
--accent2:#2a5db0;
|
--accent2:#2a5db0;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#237a23;
|
--good:#237a23;
|
||||||
--warn:#a15f00;
|
--warn:#9b5b00;
|
||||||
--bad:#c42b1c;
|
--bad:#c42b1c;
|
||||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||||
}
|
}
|
||||||
|
/* 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,.26);
|
||||||
|
--glass-lo:rgba(255,255,255,.10);
|
||||||
|
--glass-sheen:rgba(255,255,255,.07);
|
||||||
|
--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);
|
||||||
|
--glass-lo:rgba(255,255,255,.45);
|
||||||
|
--glass-sheen:rgba(255,255,255,.5);
|
||||||
|
--wash1:rgba(64,120,255,.22);
|
||||||
|
--wash2:rgba(175,82,222,.16);
|
||||||
|
--wash3:rgba(48,176,199,.18);
|
||||||
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||||
[hidden]{display:none!important}
|
[hidden]{display:none!important}
|
||||||
@@ -299,7 +495,7 @@ a{color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
|
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
|
||||||
.brand .logo{
|
.brand .logo{
|
||||||
width:30px;height:30px;flex:none;object-fit:contain;
|
width:30px;height:30px;flex:none;border-radius:7px;
|
||||||
}
|
}
|
||||||
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
|
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
|
||||||
.iconbtn{
|
.iconbtn{
|
||||||
@@ -308,8 +504,23 @@ a{color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
.iconbtn:hover{background:var(--raise);color:var(--fg)}
|
||||||
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
|
||||||
|
/* The display's own intrusions -- the Dynamic Island and status bar along the top (#54), 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-t:env(safe-area-inset-top,0px);
|
||||||
|
--safe-b:env(safe-area-inset-bottom,0px);
|
||||||
|
--safe-l:env(safe-area-inset-left,0px);
|
||||||
|
--safe-r:env(safe-area-inset-right,0px);
|
||||||
|
}
|
||||||
#topbar{
|
#topbar{
|
||||||
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
|
display:flex;align-items:center;gap:10px;min-width:0;overflow:hidden;
|
||||||
|
/* Longhand, and it has to stay longhand: the minifier runs a calc() in a padding shorthand
|
||||||
|
into the value after it -- `calc(12px + var(--safe-r))7px` -- and the browser then throws
|
||||||
|
the whole declaration away, leaving the bar with no padding at all. */
|
||||||
|
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
||||||
|
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||||
background:var(--panel);border-bottom:1px solid var(--line);
|
background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
}
|
}
|
||||||
#topbar .grow{flex:1}
|
#topbar .grow{flex:1}
|
||||||
@@ -368,6 +579,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.chev:hover{color:var(--fg)}
|
.chev:hover{color:var(--fg)}
|
||||||
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||||
|
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
|
||||||
|
.feed.scanning .badge{display:none}
|
||||||
|
.feed.scanning::after{
|
||||||
|
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
||||||
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
||||||
a child's included, which is why it moves left by the child's indent. */
|
a child's included, which is why it moves left by the child's indent. */
|
||||||
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
||||||
@@ -421,9 +639,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||||||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||||
.tile:hover .txt b{color:var(--accent)}
|
.tile:hover .txt b{color:var(--accent)}
|
||||||
|
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
||||||
|
themes, where on the row's own ground they clear it. */
|
||||||
.tag{
|
.tag{
|
||||||
font-size:11px;font-weight:600;
|
font-size:11px;font-weight:600;
|
||||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
|
||||||
}
|
}
|
||||||
.art{
|
.art{
|
||||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
@@ -444,6 +664,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||||||
}
|
}
|
||||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
/* A selected row is --raise too, and the zero count's pill vanished into it. */
|
||||||
|
.sel .badge.zero{background:var(--bg)}
|
||||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
@@ -457,7 +679,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
|
|
||||||
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
/* Three panes, as the original had: feeds beside, items above, the item below. */
|
||||||
#split{
|
#split{
|
||||||
display:grid;flex:1;min-height:0;
|
display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */
|
||||||
grid-template-columns:minmax(0,1fr) 270px;
|
grid-template-columns:minmax(0,1fr) 270px;
|
||||||
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
||||||
grid-template-areas:"list files" "grab grab" "detail detail";
|
grid-template-areas:"list files" "grab grab" "detail detail";
|
||||||
@@ -546,7 +768,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
|
|||||||
body.adminpage{display:block;overflow:auto}
|
body.adminpage{display:block;overflow:auto}
|
||||||
body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
body.adminpage #topbar{position:sticky;top:0;z-index:5}
|
||||||
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
|
||||||
body.adminpage #topbar .logo{border-radius:5px}
|
body.adminpage #topbar .logo{border-radius:6px}
|
||||||
body.adminpage #topbar > a.btn{text-decoration:none}
|
body.adminpage #topbar > a.btn{text-decoration:none}
|
||||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||||
@@ -627,7 +849,9 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
|||||||
#player{
|
#player{
|
||||||
border-top:1px solid var(--line);background:var(--panel);
|
border-top:1px solid var(--line);background:var(--panel);
|
||||||
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
|
||||||
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
padding-top:9px;padding-bottom:calc(9px + var(--safe-b));
|
||||||
|
padding-left:calc(16px + var(--safe-l));padding-right:calc(16px + var(--safe-r));
|
||||||
|
box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||||
}
|
}
|
||||||
#player.on{display:grid}
|
#player.on{display:grid}
|
||||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||||
@@ -690,6 +914,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
||||||
.logbar .grow{flex:1;min-width:120px}
|
.logbar .grow{flex:1;min-width:120px}
|
||||||
.card h3{margin:0 0 14px;font-size:17px}
|
.card h3{margin:0 0 14px;font-size:17px}
|
||||||
|
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
|
||||||
|
at the foot of a long card. */
|
||||||
|
.cardx{float:right;margin:-5px -6px 0 8px}
|
||||||
.field{display:grid;gap:4px;margin-bottom:12px}
|
.field{display:grid;gap:4px;margin-bottom:12px}
|
||||||
.field label{font-size:12px;color:var(--dim)}
|
.field label{font-size:12px;color:var(--dim)}
|
||||||
.field .hint{font-size:11.5px;color:var(--faint)}
|
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||||
@@ -706,14 +933,20 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||||
}
|
}
|
||||||
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||||
|
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||||
#burger,#dback{display:none}
|
#burger,#dback{display:none}
|
||||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||||
#status{
|
#status{
|
||||||
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
padding-top:3px;padding-bottom:calc(3px + var(--safe-b));
|
||||||
|
padding-left:calc(14px + var(--safe-l));padding-right:calc(14px + var(--safe-r));
|
||||||
|
min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
|
||||||
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||||
}
|
}
|
||||||
|
/* Only the last bar along the bottom owes the indicator anything, and with a player open that
|
||||||
|
is the player, not this. Without :has() the worst of it is a gap above the player bar. */
|
||||||
|
body:has(#player.on) #status{padding-bottom:3px}
|
||||||
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
/* The feed's own header, kept to one line so the table starts high, as it did. */
|
||||||
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
|
||||||
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
|
||||||
@@ -734,14 +967,21 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
|
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
#shell{grid-template-columns:1fr}
|
#shell{grid-template-columns:1fr}
|
||||||
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
||||||
#sidebar.open{transform:none}
|
#sidebar.open{transform:none}
|
||||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
|
#player{
|
||||||
|
position:relative;z-index:39;gap:8px;
|
||||||
|
padding-top:7px;padding-bottom:calc(7px + var(--safe-b));
|
||||||
|
padding-left:calc(10px + var(--safe-l));padding-right:calc(10px + var(--safe-r));
|
||||||
|
}
|
||||||
|
|
||||||
/* The feed list is reachable whether or not anything is playing. */
|
/* The feed list is reachable whether or not anything is playing. */
|
||||||
#burger{display:grid}
|
#burger{display:grid}
|
||||||
#topbar{gap:6px;padding:6px 8px}
|
#topbar{
|
||||||
|
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
|
||||||
|
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));
|
||||||
|
}
|
||||||
#topbar .grow,.tgroup.item{display:none}
|
#topbar .grow,.tgroup.item{display:none}
|
||||||
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
#topbar #epSearch{width:auto;flex:1;min-width:0}
|
||||||
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
.tgroup button,.tgroup a{padding:4px 8px;min-width:32px}
|
||||||
@@ -754,9 +994,21 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
#files,.ephead,.ep .fd,.ep .file,.ep .size,.ep .rowacts{display:none}
|
||||||
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
|
||||||
#grab{display:none}
|
#grab{display:none}
|
||||||
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
|
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))}
|
||||||
body.reading #detail{display:block}
|
body.reading #detail{display:block}
|
||||||
#dback{display:inline-block;margin-bottom:10px}
|
#dback{display:inline-block;margin-bottom:10px}
|
||||||
|
/* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the
|
||||||
|
page's own background between them, so the list under the reader never shows (#52). */
|
||||||
|
#dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none}
|
||||||
|
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
||||||
|
#dpeek.next>div{margin-left:16px}
|
||||||
|
#dpeek .encbox{margin:0 0 10px}
|
||||||
|
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
|
||||||
|
#dpeek.end p{max-width:4.5em;margin:0}
|
||||||
|
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
||||||
|
reader, shadowed along its edge, is drawn off it. */
|
||||||
|
#dpeek.dim{width:100%;z-index:37;background:#000}
|
||||||
|
#detail.popping{box-shadow:-12px 0 32px rgba(0,0,0,.28)}
|
||||||
|
|
||||||
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
|
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
|
||||||
#content>.fhead{padding-top:12px}
|
#content>.fhead{padding-top:12px}
|
||||||
@@ -788,6 +1040,90 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
#logbox .tg{display:none}
|
#logbox .tg{display:none}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- A phone, drawn the way iOS draws its own apps (#53) ---------- */
|
||||||
|
/* Filled shapes rather than outlines, corners that grow rounder as the shape grows and nest inside
|
||||||
|
each other, 44px to tap and 17px to read. The outlines stay where they are what makes a control
|
||||||
|
visible at all: the high-contrast theme, and anyone who has asked the system for more contrast.
|
||||||
|
--edge is the one switch for that. */
|
||||||
|
@media (max-width:820px){
|
||||||
|
:root{--edge:transparent}
|
||||||
|
:root[data-theme="contrast"]{--edge:var(--line)}
|
||||||
|
/* Glass rules its panels off with an edge of its own; the light along them (--glass-rim) stays. */
|
||||||
|
:root[data-theme="glass"],:root[data-theme="glass"][data-mode="light"]{--glass-edge:transparent}
|
||||||
|
}
|
||||||
|
@media (max-width:820px) and (prefers-contrast:more){
|
||||||
|
:root{--edge:var(--line)}
|
||||||
|
}
|
||||||
|
@media (max-width:820px){
|
||||||
|
/* The bars run into the page instead of being ruled off from it. */
|
||||||
|
#topbar{background:var(--bg);border-bottom-color:var(--edge);gap:8px}
|
||||||
|
#status{background:var(--bg);border-top-color:var(--edge)}
|
||||||
|
#player{border-top-color:var(--edge)}
|
||||||
|
.sidefoot{border-top-color:var(--edge)}
|
||||||
|
|
||||||
|
/* Buttons: filled and round. Hover is reset because Safari keeps it on whatever was last
|
||||||
|
tapped, which left an accent outline on it. */
|
||||||
|
.btn,.sidetools button,.sidefoot button,.tgroup,.toast,kbd,#logbox{border-color:var(--edge)}
|
||||||
|
.btn:hover,.sidetools button:hover,.sidefoot button:hover{border-color:var(--edge)}
|
||||||
|
.btn,.sidetools button,.sidefoot button{border-radius:999px;min-height:40px;padding-left:14px;padding-right:14px}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||||
|
.btn.ico,#dback{min-width:40px;height:40px;padding:0 10px;display:inline-grid;place-items:center}
|
||||||
|
.btn.tiny{min-height:0;padding:2px 9px}
|
||||||
|
.iconbtn{width:40px;height:40px;border-radius:999px;background:var(--panel2)}
|
||||||
|
.tgroup{border-radius:999px}
|
||||||
|
.tgroup button+button,.tgroup button+a{border-left-color:var(--edge)}
|
||||||
|
.tgroup button,.tgroup a{min-width:36px;height:40px;padding:0 8px}
|
||||||
|
|
||||||
|
/* Text boxes filled, and at 16px or more: Safari zooms the page in on one any smaller. */
|
||||||
|
input[type=search],input[type=text],input[type=password],input[type=number],select{
|
||||||
|
font-size:17px;background:var(--panel2);border-color:var(--edge);border-radius:10px;padding:9px 12px;
|
||||||
|
}
|
||||||
|
#topbar #epSearch{font-size:16px;border-radius:999px;padding:9px 14px}
|
||||||
|
input:focus,select:focus{border-color:var(--accent)}
|
||||||
|
|
||||||
|
/* The feed's name as the page's large title, the way Podcasts heads a show. */
|
||||||
|
.fhead h2,.fhead.slim h2{font-size:26px;font-weight:700;letter-spacing:-.01em;line-height:1.2}
|
||||||
|
.fhead .sub{font-size:15px}
|
||||||
|
.acts{gap:8px}
|
||||||
|
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
||||||
|
|
||||||
|
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
||||||
|
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
||||||
|
.tabs button,.tabs a{flex:1;text-align:center;padding:7px 4px;border-radius:8px;font-size:14px}
|
||||||
|
.tabs button.on,.tabs a.on{box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||||
|
|
||||||
|
/* Rows: taller, larger type, and a hairline between them that starts where the text does. */
|
||||||
|
.ep{padding:10px 8px;border-radius:10px;min-height:44px}
|
||||||
|
.ep.sel{border-color:var(--edge)}
|
||||||
|
.ep:hover:not(.sel){background-color:transparent}
|
||||||
|
.ep .t{font-size:17px}
|
||||||
|
.ep .line,.ep .date{font-size:15px}
|
||||||
|
.ep+.ep{background-image:linear-gradient(var(--line),var(--line));background-size:calc(100% - 64px) 1px;background-position:64px 0;background-repeat:no-repeat}
|
||||||
|
.ep.sel,.ep.sel+.ep{background-image:none}
|
||||||
|
.feed{padding:9px 10px;min-height:44px;border-radius:10px}
|
||||||
|
.places{border-bottom-color:var(--edge);padding-bottom:10px;margin-bottom:10px}
|
||||||
|
.feed .txt b{font-size:17px}
|
||||||
|
.feed .txt small{font-size:13px}
|
||||||
|
|
||||||
|
/* The reader. */
|
||||||
|
.dt{font-size:26px;font-weight:700;line-height:1.2;letter-spacing:-.01em;margin-bottom:8px}
|
||||||
|
.dmeta{font-size:15px;gap:10px}
|
||||||
|
.encbox{border-color:var(--edge);border-radius:14px;padding:8px 8px 8px 14px}
|
||||||
|
.dbody{font-size:17px;line-height:1.55}
|
||||||
|
|
||||||
|
/* Dialogs are sheets, up from the bottom. */
|
||||||
|
#modal{place-items:end stretch;padding:0}
|
||||||
|
.card,.card.wide{
|
||||||
|
width:100%;max-height:calc(100dvh - var(--safe-t) - 12px);border-color:var(--edge);border-radius:20px 20px 0 0;
|
||||||
|
padding:20px 16px calc(20px + var(--safe-b));animation:sheet .3s cubic-bezier(.2,.8,.2,1);
|
||||||
|
}
|
||||||
|
.card h3{font-size:22px}
|
||||||
|
.field label,.field .hint{font-size:13px}
|
||||||
|
.check{font-size:17px}
|
||||||
|
.toast{border-radius:14px}
|
||||||
|
}
|
||||||
|
@keyframes sheet{from{transform:translateY(100%)}}
|
||||||
|
|
||||||
/* ---------- Classic: the 2004 Mac app ---------- */
|
/* ---------- Classic: the 2004 Mac app ---------- */
|
||||||
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
||||||
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
||||||
@@ -838,10 +1174,102 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
:root[data-theme="classic"] .ep.sel .st,
|
:root[data-theme="classic"] .ep.sel .st,
|
||||||
:root[data-theme="classic"] .ep.sel .fl,
|
:root[data-theme="classic"] .ep.sel .fl,
|
||||||
:root[data-theme="classic"] .ep.sel .file,
|
:root[data-theme="classic"] .ep.sel .file,
|
||||||
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
|
:root[data-theme="classic"] .ep.sel .kind,
|
||||||
|
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
|
||||||
/* Green and red stay green and red on the blue, just lighter so they read. */
|
/* Green and red stay green and red on the blue, just lighter so they read. */
|
||||||
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
||||||
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
||||||
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
|
||||||
:root[data-theme="classic"] .childrow{background:#fff}
|
:root[data-theme="classic"] .childrow{background:#fff}
|
||||||
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
||||||
|
|
||||||
|
/* ---------- Glass: after Apple's Liquid Glass ---------- */
|
||||||
|
/* Translucent panels over a coloured wash, blurred and saturated the way macOS and iOS do it.
|
||||||
|
Apple's glass also bends light at its edges; that needs an SVG displacement filter that only
|
||||||
|
Chromium applies to a backdrop, and only on a shape of fixed size, so it is left out (#43).
|
||||||
|
What is kept is the light: the rim catches it at the top-left edges and, fainter, the far ones,
|
||||||
|
as it passes through (#51). */
|
||||||
|
:root[data-theme="glass"]{--glass-rim:inset 1px 1px 0 var(--glass-hi),inset -1px -1px 0 var(--glass-lo)}
|
||||||
|
: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"] #dpeek>div,
|
||||||
|
:root[data-theme="glass"] .card,
|
||||||
|
:root[data-theme="glass"] .toast{
|
||||||
|
/* A sheen from the top-left corner, where the light comes from. It is a background layer rather
|
||||||
|
than a pseudo-element because the detail pane, the file list and the dialogs scroll, and an
|
||||||
|
absolutely placed layer inside a scroller scrolls away with the content. */
|
||||||
|
background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),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:var(--glass-rim);
|
||||||
|
}
|
||||||
|
: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:var(--glass-rim),var(--shadow)}
|
||||||
|
: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);box-shadow:var(--glass-rim);
|
||||||
|
}
|
||||||
|
/* Rounder than the other themes; a phone has shapes of its own (#53), which these would outrank. */
|
||||||
|
@media (min-width:821px){
|
||||||
|
:root[data-theme="glass"] .card{border-radius:20px}
|
||||||
|
:root[data-theme="glass"] .tgroup,
|
||||||
|
:root[data-theme="glass"] .tabs{border-radius:12px}
|
||||||
|
: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)}
|
||||||
|
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
||||||
|
light only catches the top edge there, and the bars at the top and bottom have none. */
|
||||||
|
@media (max-width:820px){
|
||||||
|
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
||||||
|
:root[data-theme="glass"] #topbar,
|
||||||
|
:root[data-theme="glass"] #status{box-shadow:none}
|
||||||
|
}
|
||||||
|
/* 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"] #dpeek>div,
|
||||||
|
: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)}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
const PAGES = {
|
const PAGES = {
|
||||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] },
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
'login.html': { script: 'login.js', src: ['login'] },
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
};
|
};
|
||||||
@@ -38,7 +38,14 @@ export function buildStyle({ minify = true } = {}) {
|
|||||||
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||||
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||||
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||||
|
// The minifier drops the space between a calc() and the value after it in a shorthand --
|
||||||
|
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
|
||||||
|
// declaration away, so the element silently loses its padding. It reports no error and the
|
||||||
|
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
|
||||||
|
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
|
||||||
|
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
@@ -65,6 +72,11 @@ export function buildPage(name, { minify = true } = {}) {
|
|||||||
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
||||||
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
||||||
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
||||||
|
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||||
|
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||||
|
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||||
|
out = out.replace(/(href|src)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg)"/g,
|
||||||
|
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||||
if (!minify) return { html: out, js, script };
|
if (!minify) return { html: out, js, script };
|
||||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||||
|
|||||||
BIN
web/favicon.png
BIN
web/favicon.png
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>iPodderX</title>
|
<title>iPX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" data-src="app.css">
|
<link rel="stylesheet" data-src="app.css">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div id="shell">
|
<div id="shell">
|
||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img class="logo" src="/icon.png" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1>
|
<img class="logo" src="/logo.svg" alt="iPX"><h1>iPX</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||||
<div id="feedlist"></div>
|
<div id="feedlist"></div>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<title>Sign in — iPodderX</title>
|
<!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 — iPX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
@@ -12,7 +17,7 @@
|
|||||||
--line:#2c3849;
|
--line:#2c3849;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0e131b; /* text on an accent fill */
|
||||||
@@ -22,7 +27,8 @@
|
|||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(6,10,16,.55);
|
||||||
--r:10px;
|
--r:10px;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] {
|
/* Signed out, there is no account to take a theme from, so this follows the system. */
|
||||||
|
@media (prefers-color-scheme:light){:root {
|
||||||
--bg:#f2f4f7;
|
--bg:#f2f4f7;
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
--panel:#ffffff; /* #FFFFFF device body */
|
||||||
--panel2:#e9edf3;
|
--panel2:#e9edf3;
|
||||||
@@ -30,15 +36,15 @@
|
|||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#6b6b6b;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#b06f10;
|
--accent2:#985e0a;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a;
|
||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
}
|
}}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
@@ -49,9 +55,9 @@ form{
|
|||||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||||
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
||||||
}
|
}
|
||||||
/* The one place the 2004 icon is shown at the size it was drawn for. */
|
/* The logo is a square app icon, so it gets an app icon's corners. */
|
||||||
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
||||||
.brand img{width:96px;height:auto}
|
.brand img{width:72px;height:72px;border-radius:16px}
|
||||||
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
||||||
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
||||||
input{
|
input{
|
||||||
@@ -68,7 +74,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
|
<div class="brand"><img src="/logo.svg" alt=""><h1>iPX</h1></div>
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
<input id="name" name="name" autocomplete="username" autofocus required>
|
<input id="name" name="name" autocomplete="username" autofocus required>
|
||||||
<label for="pw">Password</label>
|
<label for="pw">Password</label>
|
||||||
|
|||||||
19
web/logo-dark.svg
Normal file
19
web/logo-dark.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
|
||||||
|
guidelines ask, so the icon reads as itself in either. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1d4468"/>
|
||||||
|
<stop offset="1" stop-color="#0c2238"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="scale" fill="#8fc2ea">
|
||||||
|
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||||
|
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||||
|
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||||
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff9f2e"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1003 B |
22
web/logo.svg
Normal file
22
web/logo.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
|
||||||
|
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
|
||||||
|
transparency black), one background and flat foreground layers with hard edges, no
|
||||||
|
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
|
||||||
|
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#dff0fb"/>
|
||||||
|
<stop offset="1" stop-color="#9fccef"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||||
|
<g id="scale" fill="#2f6aa0">
|
||||||
|
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||||
|
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||||
|
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||||
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#f7931e"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -218,7 +218,7 @@ function due(ts){
|
|||||||
async function prefsModal(){
|
async function prefsModal(){
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const admin = !!(S.me&&S.me.admin);
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<h3>Settings</h3>
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
<div class="field"><label>Theme</label>
|
<div class="field"><label>Theme</label>
|
||||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
@@ -234,17 +234,31 @@ async function prefsModal(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
subscribes you to every feed in one.</span></div>
|
subscribes you to every feed in one.</span></div>
|
||||||
|
<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||||
|
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
|
||||||
|
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
||||||
|
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||||
|
settings can add more.</span></div>
|
||||||
<div class="field"><label>Feeds are checked every</label>
|
<div class="field"><label>Feeds are checked every</label>
|
||||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#gopml').onclick=opmlModal;
|
||||||
|
$('#sblock').onchange=async e=>{
|
||||||
|
const blocked=splitWords(e.target.value);
|
||||||
|
try{
|
||||||
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||||
|
if(S.me) S.me.blocked=blocked;
|
||||||
|
toast('Saved'); await loadFeeds(true); loadEntries();
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||||
|
|
||||||
function settingsModal(f, newUrl?: string){
|
function settingsModal(f, newUrl?: string){
|
||||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
@@ -258,6 +272,11 @@ function settingsModal(f, newUrl?: string){
|
|||||||
<div class="field"><label>Keywords</label>
|
<div class="field"><label>Keywords</label>
|
||||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||||
|
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||||
|
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
||||||
|
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
||||||
|
is hidden from you and not downloaded for you, as well as those your Settings hide
|
||||||
|
everywhere.</span></div>`}
|
||||||
<div class="field"><label>Max new downloads per scan</label>
|
<div class="field"><label>Max new downloads per scan</label>
|
||||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
||||||
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
|
||||||
@@ -295,9 +314,10 @@ function settingsModal(f, newUrl?: string){
|
|||||||
const max=$('#smax').value;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
const patch: Record<string, unknown>={
|
||||||
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
keywords:splitWords($('#skw').value),
|
||||||
max_new_per_check:max===''?null:Number(max),
|
max_new_per_check:max===''?null:Number(max),
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||||
|
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
||||||
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||||
if(S.me&&S.me.admin){
|
if(S.me&&S.me.admin){
|
||||||
patch.url=$('#surl').value.trim();
|
patch.url=$('#surl').value.trim();
|
||||||
@@ -371,7 +391,8 @@ function opmlModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ function connect(){
|
|||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshFeeds=soon(()=>loadFeeds(true));
|
const refreshFeeds=soon(()=>loadFeeds(true));
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
let fresh={};
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const tellNew=soon(()=>{
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
|
|
||||||
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
|
|
||||||
fresh={};
|
|
||||||
},900);
|
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
@@ -23,22 +19,28 @@ function connect(){
|
|||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries(); refreshFeeds();
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
}
|
}
|
||||||
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
|
setScanning(ev.feed,false);
|
||||||
|
if(!mine(ev.feed)) return;
|
||||||
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
}
|
}
|
||||||
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
// red ! marks the feed instead, and its page says why.
|
// red ! marks the feed instead, and its page says why.
|
||||||
else if(ev.ev==='feed_error') refreshFeeds();
|
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
||||||
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function renderFeed(){
|
|||||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,6 +121,7 @@ function renderGroup(f,kids){
|
|||||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +158,7 @@ function renderGroup(f,kids){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
async function feedAction(a,f){
|
||||||
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
@@ -166,6 +168,7 @@ async function feedAction(a,f){
|
|||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(e.message,true); }
|
||||||
}
|
}
|
||||||
if(a==='settings') settingsModal(f);
|
if(a==='settings') settingsModal(f);
|
||||||
|
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||||
if(a==='dl') downloadLatestModal(f);
|
if(a==='dl') downloadLatestModal(f);
|
||||||
}
|
}
|
||||||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ const VIEWS={
|
|||||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
@@ -104,6 +120,7 @@ function renderFeeds(){
|
|||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
|
paintScanning();
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id){
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
// the document because the panes are rebuilt every time a feed renders.
|
// the document because the panes are rebuilt every time a feed renders.
|
||||||
|
|
||||||
const PULL = 70; // px down, from the list's top, that counts as a pull
|
const PULL = 70; // px down, from the list's top, that counts as a pull
|
||||||
const SWIPE = 60; // px across that counts as a swipe
|
// A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
|
||||||
|
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
|
||||||
|
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
|
||||||
|
const GAP = 16; // px of page between two items side by side, so they read as two sheets
|
||||||
|
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
|
||||||
|
|
||||||
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
||||||
|
|
||||||
@@ -34,7 +39,6 @@ function pullShow(dy: number){
|
|||||||
function refreshFeed(){
|
function refreshFeed(){
|
||||||
const f = S.feeds.find(x => x.id === S.feed);
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
if(!f && S.feed !== ':all') return;
|
if(!f && S.feed !== ':all') return;
|
||||||
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
|
|
||||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
.catch(e => toast(e.message, true));
|
.catch(e => toast(e.message, true));
|
||||||
@@ -47,9 +51,15 @@ document.addEventListener('touchstart', ev => {
|
|||||||
const t = ev.touches[0], target = ev.target as Element;
|
const t = ev.touches[0], target = ev.target as Element;
|
||||||
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
||||||
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
||||||
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
if(detail && S.sel && !ownsSwipe(target)){
|
||||||
|
touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
||||||
|
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
|
||||||
|
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
|
||||||
|
// back button is never swallowed with it.
|
||||||
|
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
||||||
|
}
|
||||||
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||||
}, {passive: true});
|
}, {passive: false});
|
||||||
|
|
||||||
document.addEventListener('touchmove', ev => {
|
document.addEventListener('touchmove', ev => {
|
||||||
if(!touch) return;
|
if(!touch) return;
|
||||||
@@ -61,11 +71,74 @@ document.addEventListener('touchmove', ev => {
|
|||||||
pullShow(touch.dy);
|
pullShow(touch.dy);
|
||||||
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
||||||
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
||||||
touch = null; // scrolling the text, not swiping; the first few px
|
slide(0); touch = null; // scrolling the text, not swiping; the first few px
|
||||||
// decide nothing, being mostly jitter
|
// decide nothing, being mostly jitter
|
||||||
|
}else if(Math.abs(touch.dx) > 10){
|
||||||
|
// The reader follows the finger, so it is plain before letting go whether this will move on;
|
||||||
|
// with nothing that way it only gives a little.
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
|
const end = touch.dx < 0 && i >= S.entries.length - 1;
|
||||||
|
slide(end ? touch.dx / 3 : touch.dx, 0);
|
||||||
}
|
}
|
||||||
}, {passive: false});
|
}, {passive: false});
|
||||||
|
|
||||||
|
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
||||||
|
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
||||||
|
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
||||||
|
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
||||||
|
/// copy of the reader's markup repeats the reader's ids.
|
||||||
|
function beside(dx: number){
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
|
||||||
|
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
|
||||||
|
let p = $('#dpeek');
|
||||||
|
if(!p){
|
||||||
|
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.append(p);
|
||||||
|
}
|
||||||
|
if(p.dataset.key !== kind + (e?.guid || '')){
|
||||||
|
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
||||||
|
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
|
||||||
|
/// beside it with it.
|
||||||
|
function slide(dx: number, ms = 160){
|
||||||
|
const d = $('#detail'); if(!d) return;
|
||||||
|
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
|
||||||
|
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
|
||||||
|
d.style.transition = tr;
|
||||||
|
d.style.transform = dx ? `translateX(${dx}px)` : '';
|
||||||
|
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
|
||||||
|
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
|
||||||
|
const p = dx ? beside(dx) : $('#dpeek');
|
||||||
|
d.classList.toggle('popping', !!dx && p?.className === 'dim');
|
||||||
|
if(!p) return;
|
||||||
|
p.style.transition = tr;
|
||||||
|
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
|
||||||
|
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
|
||||||
|
// Gone once the reader is back, unless another drag has already begun.
|
||||||
|
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reader carries on the way the finger went, from wherever it was let go, and the item
|
||||||
|
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
|
||||||
|
/// motion the stylesheet turns transitions off and transitionend never comes.
|
||||||
|
function slideOn(dx: number, go: () => void){
|
||||||
|
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
|
||||||
|
if(!narrow()){
|
||||||
|
// A pane beside the list: off one side, and the next item in from the other.
|
||||||
|
slide(dir * w);
|
||||||
|
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
|
||||||
|
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
|
||||||
|
slide(to, ms);
|
||||||
|
setTimeout(() => { go(); slide(0, 0); }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('touchend', () => {
|
document.addEventListener('touchend', () => {
|
||||||
const g = touch; touch = null;
|
const g = touch; touch = null;
|
||||||
if(!g) return;
|
if(!g) return;
|
||||||
@@ -75,9 +148,11 @@ document.addEventListener('touchend', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Across, mostly sideways, and not a slow drag while selecting text.
|
// Across, mostly sideways, and not a slow drag while selecting text.
|
||||||
if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return;
|
|
||||||
const i = S.entries.findIndex(x => x.guid === S.sel);
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
|
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
|
||||||
if(i > 0) stepEntry(-1); else showDetail(null);
|
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
|
||||||
|
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
|
||||||
|
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
|
||||||
|
slideOn(g.dx, () => showDetail(null));
|
||||||
});
|
});
|
||||||
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });
|
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
|
||||||
|
|||||||
@@ -230,6 +230,19 @@ function showDetail(e){
|
|||||||
}
|
}
|
||||||
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
||||||
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
||||||
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
box.innerHTML=detailHtml(e);
|
||||||
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||||
|
$('#dback').onclick=()=>showDetail(null);
|
||||||
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||||
|
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||||
|
syncPlayButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
||||||
|
/// previous item beside the reader while it is swiped (gestures.ts).
|
||||||
|
function detailHtml(e){
|
||||||
const encs=e.enclosures.map(encBox).join('');
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
// A phone has no room for the files pane, so the files go above the text there instead.
|
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||||
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||||
@@ -237,7 +250,8 @@ function showDetail(e){
|
|||||||
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
box.innerHTML=`
|
// description was sanitized server-side with ammonia before it ever reached here
|
||||||
|
return `
|
||||||
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
||||||
<div class="dmeta">
|
<div class="dmeta">
|
||||||
@@ -249,16 +263,11 @@ function showDetail(e){
|
|||||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||||
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
|
||||||
</div>
|
</div>
|
||||||
${narrow?encs:''}
|
${narrow?encs:''}
|
||||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
|
||||||
// description was sanitized server-side with ammonia before it ever reached here
|
|
||||||
$('#dback').onclick=()=>showDetail(null);
|
|
||||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
|
||||||
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
|
||||||
syncPlayButtons();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||||
@@ -273,6 +282,8 @@ function encBox(x){
|
|||||||
: 'Delete file';
|
: 'Delete file';
|
||||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||||
|
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||||
|
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||||
if(x.path && !isPlayable(x)){
|
if(x.path && !isPlayable(x)){
|
||||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
@@ -280,6 +291,7 @@ function encBox(x){
|
|||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
||||||
${saveBtn}
|
${saveBtn}
|
||||||
|
${shareBtn}
|
||||||
${delBtn}
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -291,6 +303,7 @@ function encBox(x){
|
|||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||||
${saveBtn}
|
${saveBtn}
|
||||||
|
${shareBtn}
|
||||||
${delBtn}
|
${delBtn}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -303,6 +316,7 @@ function encBox(x){
|
|||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||||
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||||
|
${shareBtn}
|
||||||
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -314,9 +328,8 @@ async function epAction(a: string, e, el, encId?: number){
|
|||||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
try{
|
try{
|
||||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||||
// A pinned item sits at the top of its list (the server sorts it there), so the list is
|
if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el);
|
||||||
// asked for again rather than the row redrawn where it stands.
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); loadEntries(); }
|
|
||||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
if(!enc) return;
|
if(!enc) return;
|
||||||
|
|||||||
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'],
|
['Anywhere'],
|
||||||
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
];
|
];
|
||||||
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||||||
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,19 @@
|
|||||||
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
// the system, so no palette is written twice.
|
// the system, so no palette is written twice.
|
||||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
modern: {name: 'Modern', modes: true},
|
|
||||||
classic: {name: 'Classic, the 2004 Mac app', modes: false},
|
|
||||||
dracula: {name: 'Dracula', modes: true},
|
|
||||||
material: {name: 'Material', modes: true},
|
|
||||||
adwaita: {name: 'Adwaita', modes: true},
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
catppuccin: {name: 'Catppuccin', modes: true},
|
||||||
|
classic: {name: 'Classic', modes: false},
|
||||||
|
dracula: {name: 'Dracula', modes: true},
|
||||||
flatremix: {name: 'Flat Remix', modes: true},
|
flatremix: {name: 'Flat Remix', modes: true},
|
||||||
paper: {name: 'Paper', modes: false},
|
glass: {name: 'Glass', modes: true},
|
||||||
|
gruvbox: {name: 'Gruvbox', modes: true},
|
||||||
|
contrast: {name: 'High contrast', modes: true},
|
||||||
|
material: {name: 'Material', modes: true},
|
||||||
|
modern: {name: 'Modern', modes: true},
|
||||||
nordic: {name: 'Nordic', modes: true},
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
solarized: {name: 'Solarized', modes: true},
|
||||||
};
|
};
|
||||||
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>
|
|||||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||||
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||||
const ICON={
|
const ICON={
|
||||||
|
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||||
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||||
@@ -26,7 +27,6 @@ const ICON={
|
|||||||
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
|
|
||||||
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
@@ -40,7 +40,6 @@ const ICON={
|
|||||||
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
|
|
||||||
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
@@ -94,6 +93,27 @@ async function copyText(text,btn){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
|
||||||
|
/// Asks first when the address looks like it carries your own access to a paid or private
|
||||||
|
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
|
||||||
|
/// gets it your subscription (issue #48).
|
||||||
|
///
|
||||||
|
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
||||||
|
/// without asking; add its pattern here when one turns up.
|
||||||
|
async function share(title: string, url: string, btn?){
|
||||||
|
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
||||||
|
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
||||||
|
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||||
|
if(navigator.share){
|
||||||
|
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||||
|
try{ await navigator.share({title, url}); }
|
||||||
|
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyText(url,btn);
|
||||||
|
toast('Link copied');
|
||||||
|
}
|
||||||
|
|
||||||
function toast(msg: string, bad?: boolean){
|
function toast(msg: string, bad?: boolean){
|
||||||
const t=document.createElement('div');
|
const t=document.createElement('div');
|
||||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||||
|
|||||||
Reference in New Issue
Block a user