Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 | |||
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 | |||
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 | |||
| bc53e0f730 | |||
| bd8f6ab855 | |||
| 611716d8b7 | |||
| a68bfb179b | |||
| 6bf1ad6b31 | |||
| d7f8f2df1d | |||
| 4927677e66 | |||
| 484aaa1849 | |||
| c99e17bd80 | |||
| e312f11bb1 | |||
| a465fa8471 | |||
| aeb686163b | |||
| 2d158a4540 | |||
| fc425bffa6 | |||
| 42a1136e2e | |||
| 53341264a7 | |||
| 9c16408d04 | |||
| 9b2537761f | |||
| 3b00e2721a | |||
| fc09e8a6b7 | |||
| 5483355021 | |||
| e2969bcee8 | |||
| 26802d2b23 | |||
| fbba447ca6 | |||
| d7a4a0b663 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
/web/dist
|
||||||
|
|||||||
156
CHANGELOG.md
156
CHANGELOG.md
@@ -5,11 +5,153 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
The long form, with what was wrong before and how it was found, is in
|
|
||||||
[docs/history.md](docs/history.md).
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.4] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||||
|
goes solid when the system asks for less transparency or more contrast.
|
||||||
|
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||||
|
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||||
|
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||||
|
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||||
|
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||||
|
home screen. In landscape the bars keep clear of the notch as well.
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||||
|
says where the token is kept instead: `[web] token` in config.toml.
|
||||||
|
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||||
|
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||||
|
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||||
|
docs/sso.md.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||||
|
now on have it.
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||||
|
contrast, black and white with every colour at 7:1 or more.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The themes in Settings are listed in alphabetical order.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||||
|
read comfortably. A failed-action message is easier to read in every theme.
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-09-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
|
||||||
|
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||||
|
"Downloaded" toast is only for a file on your screen.
|
||||||
|
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||||
|
- The Classic theme is listed in Settings as just "Classic".
|
||||||
|
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
|
||||||
|
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
|
||||||
|
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
|
||||||
|
drawing at desktop width.
|
||||||
|
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
|
||||||
|
- In Nordic's dark half, button and toolbar borders show.
|
||||||
|
- A zero unread count on the selected feed no longer disappears into the selection.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||||
|
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||||
|
across, everything in one go.
|
||||||
|
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||||
|
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||||
|
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||||
|
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||||
|
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||||
|
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||||
|
be opened by a 0.7 release first, which brings its tables up to date.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
|
||||||
|
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
|
||||||
|
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
|
||||||
|
A theme chosen before this carries over.
|
||||||
|
- Your theme is kept on your account rather than in the browser, so it follows you to another
|
||||||
|
browser or computer, and the page arrives in it with no flash of the default. The theme a
|
||||||
|
browser already had is saved to your account the first time you load the page.
|
||||||
|
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
|
||||||
|
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
|
||||||
|
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||||
|
swipe the item you are reading left for the next one and right for the one before, or back
|
||||||
|
to the list from the first.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
|
||||||
|
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
|
||||||
|
Settings is now yours alone: your theme and your subscriptions.
|
||||||
|
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
|
||||||
|
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
|
||||||
|
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
|
||||||
|
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
|
||||||
|
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
|
||||||
|
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
||||||
|
them, where long notes left them looking missing.
|
||||||
|
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
||||||
|
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
|
||||||
|
`web/src`, type-checked, and built with swc; building ipx needs node.
|
||||||
|
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
|
||||||
|
between visits and fetches it again only when an update changes it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
||||||
|
items: of two lists asked for at once, only the later one is shown.
|
||||||
|
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
|
||||||
|
their own, and on white for an iPhone's home screen.
|
||||||
|
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
||||||
|
higher than a downloaded one's.
|
||||||
|
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
||||||
|
now show: ipx asks for them without saying it is the page showing them.
|
||||||
|
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
|
||||||
|
pause, as the player bar's does, and pause it when pressed.
|
||||||
|
- An item you open stays read. A list refresh that crossed with marking it read could put its
|
||||||
|
unread dot back until the next refresh.
|
||||||
|
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
||||||
|
one, rather than a few read items lingering until a refresh cleared them.
|
||||||
|
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
|
||||||
|
leaves it out of their page.
|
||||||
|
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
||||||
|
points at the post's site rather than at ipx, and shows.
|
||||||
|
|
||||||
## [0.6.1] - 2026-09-15
|
## [0.6.1] - 2026-09-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -430,7 +572,13 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- 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/ipodderx-rs/compare/v0.8.4...main
|
||||||
|
[0.8.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.3...v0.8.4
|
||||||
|
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||||
|
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||||
|
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||||
|
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||||
|
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||||
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
|
||||||
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
|
||||||
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
||||||
|
|||||||
106
CLAUDE.md
106
CLAUDE.md
@@ -13,15 +13,36 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
|
|||||||
| | Host | In the container |
|
| | Host | In the container |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
||||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
| Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||||
| Database | `/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 `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
|
||||||
|
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| 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/ipodderx-rs/issues, not in a
|
||||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
||||||
|
|
||||||
|
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||||
|
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||||
|
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||||
|
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||||
|
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipodderx-rs"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||||
|
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||||
|
t issues close $R 40
|
||||||
|
```
|
||||||
|
|
||||||
|
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||||
|
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||||
|
|
||||||
|
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||||
|
for later stays open, and that is how it gets picked up again.
|
||||||
|
|
||||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -56,23 +77,41 @@ 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
|
||||||
|
|
||||||
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before
|
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||||
it is visible. It is one file: markup, CSS and script.
|
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||||
|
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
||||||
|
make up each page's script. `build.rs` runs
|
||||||
|
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
|
||||||
|
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
|
||||||
|
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
|
||||||
|
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
|
||||||
|
deploy changes them and their names. So **every page change needs a
|
||||||
|
rebuild** before it is visible, and building needs node and `npm ci` run once.
|
||||||
|
|
||||||
|
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||||
|
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||||
|
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
||||||
|
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
||||||
|
|
||||||
After any edit to it:
|
After any edit to it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npx tsc -p .
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
```
|
```
|
||||||
|
|
||||||
That loads the script against a stub DOM and checks every selector it wires at load actually
|
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
||||||
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently
|
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||||
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed.
|
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
||||||
|
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||||
|
`ReferenceError` while every server-side test passed.
|
||||||
|
|
||||||
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
||||||
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
||||||
@@ -80,9 +119,12 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # ~51 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
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
npx playwright test # 16 browser tests against a real daemon on fixture feeds
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Things about the browser suite that have cost time:
|
Things about the browser suite that have cost time:
|
||||||
@@ -108,10 +150,18 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
||||||
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
* **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 `migrate()` now drops them.
|
(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
|
||||||
@@ -123,10 +173,16 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||||
different task. `ipx status` checks the control socket and the database; to see the worker
|
different task. `ipx status` checks the control socket and the database; to see the worker
|
||||||
getting through its jobs, watch for `scan complete` in the log.
|
getting through its jobs, watch for `scan complete` in the log.
|
||||||
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
|
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
|
||||||
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
|
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||||
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
|
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||||
for `daemon started` in the log. Copy `state.db` aside before deploying one.
|
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||||
|
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||||
|
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
|
||||||
|
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||||
|
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
||||||
|
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
||||||
|
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
|
||||||
|
|
||||||
## House style
|
## House style
|
||||||
|
|
||||||
@@ -137,9 +193,9 @@ addressed to the person using it.
|
|||||||
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||||
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
||||||
say, such as what was wrong before or what it cost to find out, write it up at the top of
|
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
||||||
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
|
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||||
than once.
|
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||||
|
|
||||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||||
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
||||||
@@ -150,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
|
||||||
|
|||||||
915
Cargo.lock
generated
915
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.6.1"
|
version = "0.8.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,13 +12,14 @@ 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"
|
||||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||||
rss = "2.1.1"
|
rss = "2.1.1"
|
||||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
||||||
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
||||||
|
# build.rs builds the web pages from TypeScript with swc, which needs node.
|
||||||
FROM rust:1-slim-bookworm AS build
|
FROM rust:1-slim-bookworm AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential nodejs npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
||||||
&& cargo build --release --locked \
|
&& cargo build --release --locked \
|
||||||
&& rm -rf src
|
&& rm -rf src
|
||||||
|
|
||||||
|
COPY build.rs ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
||||||
| [docs/history.md](docs/history.md) | How it was built, with what was wrong and why |
|
|
||||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -71,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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
14
build.rs
Normal file
14
build.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
|
||||||
|
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=web");
|
||||||
|
println!("cargo:rerun-if-changed=package-lock.json");
|
||||||
|
let out = std::env::var("OUT_DIR").unwrap();
|
||||||
|
let status = Command::new("node")
|
||||||
|
.args(["web/build.mjs", &out])
|
||||||
|
.status()
|
||||||
|
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
|
||||||
|
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ services:
|
|||||||
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:
|
||||||
|
- ipodderx.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
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ it to a running daemon.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||||
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||||
| `src/db.rs` | SQLite schema, migrations, every query | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||||
|
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||||
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
||||||
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||||
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||||
@@ -19,9 +20,15 @@ it to a running daemon.
|
|||||||
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||||
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||||
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||||
| `web/index.html` | The whole front end, `include_str!`d into the binary | — |
|
| `web/index.html` | The app's markup | — |
|
||||||
|
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||||
|
| `web/app.css` | The stylesheet both pages share | — |
|
||||||
|
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||||
|
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
|
||||||
|
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
|
||||||
|
|
||||||
The page is compiled in, so **editing `web/index.html` needs a rebuild**.
|
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
|
||||||
|
build needs node and `npm ci` run once.
|
||||||
|
|
||||||
## A scan
|
## A scan
|
||||||
|
|
||||||
@@ -59,14 +66,14 @@ entry_state user_id, feed_id, guid, read, flagged, position
|
|||||||
```
|
```
|
||||||
|
|
||||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
||||||
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
|
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||||
older database.
|
|
||||||
|
|
||||||
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
|
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
|
||||||
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
|
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||||
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
|
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||||
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
|
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
||||||
path as `Db::open`, so a migration cannot pass the tests while missing in production.
|
database the same way, so the tests run on the schema production gets. A database from before
|
||||||
|
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
||||||
|
|
||||||
## Control socket
|
## Control socket
|
||||||
|
|
||||||
@@ -134,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 |
|
||||||
|
|||||||
1732
docs/history.md
1732
docs/history.md
File diff suppressed because it is too large
Load Diff
36
docs/sso.md
36
docs/sso.md
@@ -39,11 +39,17 @@ enabled = true
|
|||||||
bind = "0.0.0.0:8099"
|
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 ipodderx`.
|
||||||
|
|
||||||
@@ -172,9 +178,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
|||||||
sides. Never list a LAN address or range: anyone there could then send
|
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>`).
|
||||||
|
|||||||
887
package-lock.json
generated
887
package-lock.json
generated
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/core": "^1.16.2",
|
||||||
|
"@swc/html": "^1.16.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0"
|
"@playwright/test": "^1.56.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
@@ -25,6 +30,847 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.28"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.16.2",
|
||||||
|
"@swc/core-darwin-x64": "1.16.2",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.16.2",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.16.2",
|
||||||
|
"@swc/core-linux-ppc64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-s390x-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-x64-musl": "1.16.2",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.16.2",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.16.2",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.16.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html/-/html-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/html-darwin-arm64": "1.16.2",
|
||||||
|
"@swc/html-darwin-x64": "1.16.2",
|
||||||
|
"@swc/html-linux-arm-gnueabihf": "1.16.2",
|
||||||
|
"@swc/html-linux-arm64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-arm64-musl": "1.16.2",
|
||||||
|
"@swc/html-linux-ppc64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-s390x-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-x64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-x64-musl": "1.16.2",
|
||||||
|
"@swc/html-win32-arm64-msvc": "1.16.2",
|
||||||
|
"@swc/html-win32-ia32-msvc": "1.16.2",
|
||||||
|
"@swc/html-win32-x64-msvc": "1.16.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-darwin-arm64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-darwin-x64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-ppc64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-s390x-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-x64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-x64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-arm64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-ia32-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-x64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
|
||||||
|
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
@@ -53,6 +899,41 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,13 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.",
|
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "node web/build.mjs",
|
||||||
|
"typecheck": "tsc -p .",
|
||||||
|
"smoke": "node tests/page-smoke.js",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed"
|
||||||
"smoke": "node tests/page-smoke.js"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0"
|
"@playwright/test": "^1.56.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/core": "^1.16.2",
|
||||||
|
"@swc/html": "^1.16.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/config.rs
125
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,14 +277,80 @@ 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)
|
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
|
||||||
}
|
}
|
||||||
let text = toml::to_string_pretty(self)?;
|
|
||||||
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||||
|
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
||||||
|
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
||||||
|
/// or decides who gets in, and stays in config.toml.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Stored {
|
||||||
|
pub schedule: String,
|
||||||
|
pub max_total_gb: f64,
|
||||||
|
pub max_age_days: u64,
|
||||||
|
pub max_new_per_check: usize,
|
||||||
|
pub media_types: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
|
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
pub fn of(cfg: &Config) -> Self {
|
||||||
|
let g = &cfg.general;
|
||||||
|
Self {
|
||||||
|
schedule: g.schedule.clone(),
|
||||||
|
max_total_gb: g.max_total_gb,
|
||||||
|
max_age_days: g.max_age_days,
|
||||||
|
max_new_per_check: g.max_new_per_check,
|
||||||
|
media_types: g.media_types.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))?;
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||||
// Passwords may live in here.
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -276,7 +358,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||||
pub fn config_path() -> PathBuf {
|
pub fn config_path() -> PathBuf {
|
||||||
@@ -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(
|
||||||
|
|||||||
285
src/entity.rs
Normal file
285
src/entity.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! The database's tables as SeaORM entities: the one description of the schema, from which
|
||||||
|
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
|
||||||
|
//! `db::create_missing`). Times are Unix seconds.
|
||||||
|
|
||||||
|
pub mod feeds {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "feeds")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub url: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// The channel's first <itunes:category>, for the Directory.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub etag: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
pub last_checked: Option<i64>,
|
||||||
|
pub ttl_mins: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
||||||
|
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
||||||
|
/// later) from a feed that has been down for a day.
|
||||||
|
pub error_since: Option<i64>,
|
||||||
|
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub orphaned: bool,
|
||||||
|
/// The OPML subscription this feed came from.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
||||||
|
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
||||||
|
/// are re-derived instead. Customising one promotes it to config.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub managed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod entries {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "entries")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub published: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub first_seen: i64,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub episode: Option<i64>,
|
||||||
|
pub season: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod enclosures {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "enclosures")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
|
||||||
|
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
||||||
|
#[sea_orm(unique, column_type = "Text")]
|
||||||
|
pub url: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub mime: Option<String>,
|
||||||
|
pub length: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub state: String,
|
||||||
|
#[sea_orm(default_value = 0)]
|
||||||
|
pub bytes_done: i64,
|
||||||
|
pub downloaded_at: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod users {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
|
||||||
|
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
||||||
|
/// check, and leaving it empty is not the same as leaving it unset.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub pass_hash: Option<String>,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub is_admin: bool,
|
||||||
|
/// For whoever maintains the server. NULL where it is not known.
|
||||||
|
pub created: Option<i64>,
|
||||||
|
pub last_login: Option<i64>,
|
||||||
|
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub theme: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub theme_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table of one person's rows, gone when they are.
|
||||||
|
macro_rules! owned_by_user {
|
||||||
|
() => {
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
||||||
|
/// part that is not. NULL in a column means: follow the feed's own setting.
|
||||||
|
pub mod subscriptions {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "subscriptions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
/// JSON array of strings; NULL follows the feed.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub keywords: Option<String>,
|
||||||
|
pub auto_download: Option<bool>,
|
||||||
|
pub allow_explicit: Option<bool>,
|
||||||
|
pub max_new_per_check: Option<i64>,
|
||||||
|
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read, kept and how far in. One row per person per item, created on first touch; an item
|
||||||
|
/// nobody has touched has no row at all, which is what unread means.
|
||||||
|
pub mod entry_state {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "entry_state")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub read: bool,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub flagged: bool,
|
||||||
|
#[sea_orm(default_value = 0)]
|
||||||
|
pub position: i64,
|
||||||
|
/// The length this person's player measured, beside the position it is measured against.
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod sessions {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "sessions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub seen: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||||
|
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||||
|
pub mod catalogue {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "catalogue")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub spec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
||||||
|
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
||||||
|
/// configuration has moved in (issue #18).
|
||||||
|
pub mod settings {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
77
src/feed.rs
77
src/feed.rs
@@ -698,10 +698,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|||||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
/// 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"?>
|
||||||
|
|||||||
18
src/ipc.rs
18
src/ipc.rs
@@ -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)]
|
||||||
@@ -176,7 +181,9 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
|||||||
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
||||||
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
||||||
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||||
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
|
/// A future, since reading the counts is a database query.
|
||||||
|
pub type StatusFn =
|
||||||
|
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
|
||||||
|
|
||||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
@@ -249,7 +256,7 @@ async fn handle(
|
|||||||
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
||||||
Ok(Command::Status) => {
|
Ok(Command::Status) => {
|
||||||
tracing::info!(target: "ipx::io", "-> {line}");
|
tracing::info!(target: "ipx::io", "-> {line}");
|
||||||
let ev = status();
|
let ev = status().await;
|
||||||
if let Ok(json) = serde_json::to_string(&ev) {
|
if let Ok(json) = serde_json::to_string(&ev) {
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
tracing::info!(target: "ipx::io", "<- {json}");
|
||||||
}
|
}
|
||||||
@@ -299,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 }));
|
||||||
@@ -366,7 +373,8 @@ mod tests {
|
|||||||
// Another client, watching a scan: it must not be handed someone else's answer, which
|
// Another client, watching a scan: it must not be handed someone else's answer, which
|
||||||
// would end its session.
|
// would end its session.
|
||||||
let mut watcher = events.subscribe();
|
let mut watcher = events.subscribe();
|
||||||
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
|
let status: StatusFn =
|
||||||
|
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
|
||||||
let (client, server) = UnixStream::pair().unwrap();
|
let (client, server) = UnixStream::pair().unwrap();
|
||||||
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
||||||
|
|
||||||
|
|||||||
458
src/main.rs
458
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -45,28 +45,28 @@ pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||||
let mut report = Report::default();
|
let mut report = Report::default();
|
||||||
|
|
||||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||||
for (id, path) in db.missing_files()? {
|
for (id, path) in db.missing_files().await? {
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
db.mark_reaped(id)?;
|
db.mark_reaped(id).await?;
|
||||||
}
|
}
|
||||||
tracing::debug!(path, "file gone, row reaped");
|
tracing::debug!(path, "file gone, row reaped");
|
||||||
report.reconciled += 1;
|
report.reconciled += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = db.reap_candidates()?;
|
let candidates = db.reap_candidates().await?;
|
||||||
|
|
||||||
if cfg.general.max_age_days > 0 {
|
if cfg.general.max_age_days > 0 {
|
||||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||||
report.aged_out = aged(&candidates, cutoff);
|
report.aged_out = aged(&candidates, cutoff);
|
||||||
for c in &report.aged_out {
|
for c in &report.aged_out {
|
||||||
report.bytes_freed += remove(db, c, dry_run)?;
|
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||||
}
|
}
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
report.entries_pruned = db.prune_entries(cutoff)?;
|
report.entries_pruned = db.prune_entries(cutoff).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,14 +81,14 @@ pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||||
report.over_quota = pick(&remaining, total, limit);
|
report.over_quota = pick(&remaining, total, limit);
|
||||||
for c in &report.over_quota {
|
for c in &report.over_quota {
|
||||||
report.bytes_freed += remove(db, c, dry_run)?;
|
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||||
if dry_run {
|
if dry_run {
|
||||||
return Ok(c.bytes.max(0) as u64);
|
return Ok(c.bytes.max(0) as u64);
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
db.mark_reaped(c.id)?;
|
db.mark_reaped(c.id).await?;
|
||||||
Ok(size)
|
Ok(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +149,12 @@ mod tests {
|
|||||||
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||||
// One file serves both subscribers, so it takes both of them to release it.
|
// One file serves both subscribers, so it takes both of them to release it.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
||||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'keep', 0),
|
('f', 'keep', 0),
|
||||||
@@ -163,20 +163,20 @@ mod tests {
|
|||||||
('f', 'read', 0);
|
('f', 'read', 0);
|
||||||
-- Starred by one of the two, so it stays whatever the other thinks.
|
-- Starred by one of the two, so it stays whatever the other thinks.
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
(1, 'f', 'keep', 1, 1),
|
(1, 'f', 'keep', true, true),
|
||||||
(2, 'f', 'keep', 1, 0),
|
(2, 'f', 'keep', true, false),
|
||||||
(1, 'f', 'half', 1, 0),
|
(1, 'f', 'half', true, false),
|
||||||
(1, 'f', 'read', 1, 0),
|
(1, 'f', 'read', true, false),
|
||||||
(2, 'f', 'read', 1, 0);
|
(2, 'f', 'read', true, false);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
||||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||||
)
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
|
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
got,
|
got,
|
||||||
vec![4, 2, 3],
|
vec![4, 2, 3],
|
||||||
@@ -185,12 +185,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn prune_keeps_entries_that_still_have_a_file() {
|
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
('f', 'no-file', 100),
|
('f', 'no-file', 100),
|
||||||
@@ -198,9 +198,9 @@ mod tests {
|
|||||||
('f', 'recent', 900);
|
('f', 'recent', 900);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||||
)
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(db.prune_entries(500).unwrap(), 1, "only the old, fileless, unflagged one");
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
474
src/web.rs
474
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,15 +27,16 @@ 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 {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/api/me", get(me))
|
.route("/api/me", get(me).patch(patch_me))
|
||||||
.route("/api/logout", post(logout))
|
.route("/api/logout", post(logout))
|
||||||
.route("/api/feeds", get(feeds).post(add_feed))
|
.route("/api/feeds", get(feeds).post(add_feed))
|
||||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||||
@@ -59,12 +59,20 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||||
.route("/api/logs", get(logs))
|
.route("/api/logs", get(logs))
|
||||||
.route("/api/events", get(events))
|
.route("/api/events", get(events))
|
||||||
|
.route("/admin", get(admin_page))
|
||||||
|
.route("/admin.js", get(admin_js))
|
||||||
.route("/media/{id}", get(media))
|
.route("/media/{id}", get(media))
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
||||||
.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("/favicon.ico", get(favicon))
|
||||||
|
.route("/favicon.png", get(favicon))
|
||||||
|
.route("/apple-touch-icon.png", get(touch_icon))
|
||||||
|
.route("/app.js", get(app_js))
|
||||||
|
.route("/app.css", get(app_css))
|
||||||
|
.route("/login.js", get(login_js))
|
||||||
.route("/inter.woff2", get(inter))
|
.route("/inter.woff2", get(inter))
|
||||||
.layer(middleware::from_fn(access_log))
|
.layer(middleware::from_fn(access_log))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
@@ -93,22 +101,22 @@ 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;
|
||||||
|
|
||||||
if let Some(name) = vouched {
|
if let Some(name) = vouched {
|
||||||
user = match state.ctx.db.user_by_name(&name) {
|
user = match state.ctx.db.user_by_name(&name).await {
|
||||||
Ok(Some(u)) => Some(u),
|
Ok(Some(u)) => Some(u),
|
||||||
Ok(None) if cfg.web.auto_create_users => {
|
Ok(None) if cfg.web.auto_create_users => {
|
||||||
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
||||||
state
|
// The first account made is the admin.
|
||||||
.ctx
|
let first = state.ctx.db.users().await.map(|u| u.is_empty()).unwrap_or(false);
|
||||||
.db
|
match state.ctx.db.create_user(&name, None, first).await {
|
||||||
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
|
Ok(id) => state.ctx.db.user_by_id(id).await.ok().flatten(),
|
||||||
.ok()
|
Err(_) => None,
|
||||||
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
||||||
@@ -122,7 +130,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
|
// Every request comes vouched for; signed_in keeps one an hour. Failing to note the time
|
||||||
// must not turn anyone away, so its error goes unanswered.
|
// must not turn anyone away, so its error goes unanswered.
|
||||||
if let Some(u) = &user {
|
if let Some(u) = &user {
|
||||||
let _ = state.ctx.db.signed_in(u.id);
|
let _ = state.ctx.db.signed_in(u.id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let by_proxy = user.is_some();
|
let by_proxy = user.is_some();
|
||||||
@@ -133,7 +141,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
user = state
|
user = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
|
.session_user(&sid, cfg.web.session_days.max(1) * 86_400).await
|
||||||
.unwrap_or(None);
|
.unwrap_or(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,11 +155,11 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
if user.is_none() && !token.is_empty() {
|
if user.is_none() && !token.is_empty() {
|
||||||
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
||||||
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
||||||
user = admin_user(&state);
|
user = admin_user(&state).await;
|
||||||
if from_query.is_some() {
|
if from_query.is_some() {
|
||||||
// The token link is a sign-in; the cookie it leaves behind is not one each time.
|
// The token link is a sign-in; the cookie it leaves behind is not one each time.
|
||||||
if let Some(u) = &user {
|
if let Some(u) = &user {
|
||||||
let _ = state.ctx.db.signed_in(u.id);
|
let _ = state.ctx.db.signed_in(u.id).await;
|
||||||
}
|
}
|
||||||
set_cookie = Some(format!(
|
set_cookie = Some(format!(
|
||||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
||||||
@@ -195,20 +203,31 @@ struct Proxied(bool);
|
|||||||
|
|
||||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||||
/// be whoever they liked.
|
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||||
let peer = req
|
///
|
||||||
.extensions()
|
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
/// the future unsendable, as a body is not `Sync`.
|
||||||
.map(|c| c.0.ip().to_string())
|
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
.unwrap_or_default();
|
|
||||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
req.headers()
|
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
.get(&cfg.web.trusted_header)
|
let Some((team, aud)) = cfg.web.access() else {
|
||||||
.and_then(|v| v.to_str().ok())
|
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||||
.and_then(crate::auth::name_from_header)
|
};
|
||||||
|
// The plain header still has to be there, as it is what switches this path on for a
|
||||||
|
// request; who it names is the token's to say.
|
||||||
|
header(&cfg.web.trusted_header)?;
|
||||||
|
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||||
|
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(req: &Request) -> String {
|
||||||
|
req.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
@@ -239,8 +258,8 @@ fn cookie(req: &Request, name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The account the shared token stands for: the first admin, or the first user at all.
|
/// The account the shared token stands for: the first admin, or the first user at all.
|
||||||
fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
async fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
||||||
let users = state.ctx.db.users().ok()?;
|
let users = state.ctx.db.users().await.ok()?;
|
||||||
users
|
users
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.is_admin)
|
.find(|u| u.is_admin)
|
||||||
@@ -259,7 +278,7 @@ async fn login(
|
|||||||
Json(body): Json<Credentials>,
|
Json(body): Json<Credentials>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let name = body.name.trim().to_ascii_lowercase();
|
let name = body.name.trim().to_ascii_lowercase();
|
||||||
let user = state.ctx.db.user_by_name(&name)?;
|
let user = state.ctx.db.user_by_name(&name).await?;
|
||||||
// The same answer either way: whether a name exists is not something to leak.
|
// The same answer either way: whether a name exists is not something to leak.
|
||||||
let ok = user
|
let ok = user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -272,8 +291,8 @@ async fn login(
|
|||||||
|
|
||||||
let user = user.expect("verified above");
|
let user = user.expect("verified above");
|
||||||
let token = crate::auth::new_session_token();
|
let token = crate::auth::new_session_token();
|
||||||
state.ctx.db.create_session(user.id, &token)?;
|
state.ctx.db.create_session(user.id, &token).await?;
|
||||||
state.ctx.db.signed_in(user.id)?;
|
state.ctx.db.signed_in(user.id).await?;
|
||||||
tracing::info!(user = %user.name, "signed in");
|
tracing::info!(user = %user.name, "signed in");
|
||||||
|
|
||||||
let days = state.ctx.cfg().web.session_days.max(1);
|
let days = state.ctx.cfg().web.session_days.max(1);
|
||||||
@@ -290,7 +309,7 @@ async fn login(
|
|||||||
|
|
||||||
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||||
let _ = state.ctx.db.delete_session(&sid);
|
let _ = state.ctx.db.delete_session(&sid).await;
|
||||||
}
|
}
|
||||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||||
for c in [
|
for c in [
|
||||||
@@ -312,7 +331,37 @@ async fn me(
|
|||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
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);
|
||||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out }))
|
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MePatch {
|
||||||
|
theme: String,
|
||||||
|
mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the theme to the account, so it follows the person rather than the browser.
|
||||||
|
async fn patch_me(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Json(body): Json<MePatch>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// The page's script knows the themes; this only makes sure what is kept is safe to write
|
||||||
|
// into the page's <html> tag, which is where index() puts it.
|
||||||
|
if !theme_ok(&body.theme, &body.mode) {
|
||||||
|
return Err(ApiError::bad_request("not a theme"));
|
||||||
|
}
|
||||||
|
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_ok(theme: &str, mode: &str) -> bool {
|
||||||
|
(1..=20).contains(&theme.len())
|
||||||
|
&& theme.bytes().all(|b| b.is_ascii_lowercase())
|
||||||
|
&& ["light", "dark", "auto"].contains(&mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- accounts: admin only ----
|
// ---- accounts: admin only ----
|
||||||
@@ -340,7 +389,7 @@ async fn list_users(
|
|||||||
let users: Vec<_> = state
|
let users: Vec<_> = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.users()?
|
.users().await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| {
|
.map(|u| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -371,7 +420,7 @@ async fn add_user(
|
|||||||
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
||||||
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
||||||
})?;
|
})?;
|
||||||
if state.ctx.db.user_by_name(&name)?.is_some() {
|
if state.ctx.db.user_by_name(&name).await?.is_some() {
|
||||||
return Err(ApiError::bad_request(format!("{name} already exists")));
|
return Err(ApiError::bad_request(format!("{name} already exists")));
|
||||||
}
|
}
|
||||||
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
||||||
@@ -380,7 +429,7 @@ async fn add_user(
|
|||||||
} else {
|
} else {
|
||||||
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
||||||
};
|
};
|
||||||
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
|
state.ctx.db.create_user(&name, hash.as_deref(), body.admin).await?;
|
||||||
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
@@ -397,7 +446,7 @@ async fn patch_user(
|
|||||||
Json(body): Json<UserPatch>,
|
Json(body): Json<UserPatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
let users = state.ctx.db.users()?;
|
let users = state.ctx.db.users().await?;
|
||||||
let target = users
|
let target = users
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.id == id)
|
.find(|u| u.id == id)
|
||||||
@@ -408,7 +457,7 @@ async fn patch_user(
|
|||||||
target.name
|
target.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
state.ctx.db.set_admin(id, body.admin)?;
|
state.ctx.db.set_admin(id, body.admin).await?;
|
||||||
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -419,7 +468,7 @@ async fn remove_user(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
let users = state.ctx.db.users()?;
|
let users = state.ctx.db.users().await?;
|
||||||
let target = users
|
let target = users
|
||||||
.iter()
|
.iter()
|
||||||
.find(|u| u.id == id)
|
.find(|u| u.id == id)
|
||||||
@@ -430,7 +479,7 @@ async fn remove_user(
|
|||||||
target.name
|
target.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
state.ctx.db.delete_user(id)?;
|
state.ctx.db.delete_user(id).await?;
|
||||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -438,10 +487,58 @@ 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();
|
||||||
}
|
}
|
||||||
Html(include_str!("../web/login.html")).into_response()
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pages are checked on every visit, so a browser always has the one naming the current
|
||||||
|
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
|
||||||
|
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
|
||||||
|
/// changes its name in the page, and the browser fetches it.
|
||||||
|
const PAGE_CACHE: &str = "no-cache";
|
||||||
|
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
|
||||||
|
|
||||||
|
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
|
||||||
|
/// page needs its own before anyone has signed in, and neither holds anything private.
|
||||||
|
async fn app_js() -> impl IntoResponse {
|
||||||
|
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stylesheet the app and admin pages share, named by hash in each as the scripts are.
|
||||||
|
async fn app_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/app.css")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The admin page and its script go to admins only: not just hidden from everyone else, never
|
||||||
|
/// sent. Anyone else asking for the page is sent back to the app.
|
||||||
|
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response {
|
||||||
|
if !user.is_admin {
|
||||||
|
return Redirect::to("/").into_response();
|
||||||
|
}
|
||||||
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
||||||
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_js(user: crate::db::User) -> Response {
|
||||||
|
if !user.is_admin {
|
||||||
|
return (StatusCode::FORBIDDEN, "only an admin").into_response();
|
||||||
|
}
|
||||||
|
script(include_str!(concat!(env!("OUT_DIR"), "/admin.js"))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_js() -> impl IntoResponse {
|
||||||
|
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(js: &'static str) -> impl IntoResponse {
|
||||||
|
([(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 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
||||||
@@ -453,6 +550,25 @@ async fn icon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
|
||||||
|
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
|
||||||
|
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
|
||||||
|
async fn favicon() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_bytes!("../web/favicon.png").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
|
||||||
|
/// one is on white.
|
||||||
|
async fn touch_icon() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
|
||||||
|
include_bytes!("../web/apple-touch-icon.png").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
|
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
|
||||||
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
|
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
|
||||||
/// Open Font License, is web/Inter-LICENSE.txt.
|
/// Open Font License, is web/Inter-LICENSE.txt.
|
||||||
@@ -471,8 +587,45 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
|
|||||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index() -> Html<&'static str> {
|
// Built by build.rs from web/index.html and web/src, and minified: attribute values lose their
|
||||||
Html(include_str!("../web/index.html"))
|
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
|
||||||
|
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
|
||||||
|
const ADMIN_LINK: &str = "<a id=admin ";
|
||||||
|
|
||||||
|
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
||||||
|
/// script instead showed it for a moment on every load, until /api/me answered.
|
||||||
|
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
||||||
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_TAG: &str = "<html lang=en>";
|
||||||
|
|
||||||
|
/// The page for this person: their theme on its <html> tag, and the link to /admin only if they
|
||||||
|
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
|
||||||
|
/// showed it for a moment on every load, until /api/me answered (issue #29).
|
||||||
|
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
|
||||||
|
let mut page = with_theme(INDEX, theme);
|
||||||
|
if !admin
|
||||||
|
&& let Some(at) = page.find(ADMIN_LINK)
|
||||||
|
&& let Some(len) = page[at..].find("</a>")
|
||||||
|
{
|
||||||
|
page.replace_range(at..at + len + "</a>".len(), "");
|
||||||
|
}
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page with the account's theme on its <html> tag, so it is drawn in it from the first frame
|
||||||
|
/// on any browser.
|
||||||
|
fn with_theme(page: &str, theme: (Option<String>, Option<String>)) -> String {
|
||||||
|
let (Some(t), Some(m)) = theme else { return page.to_owned() };
|
||||||
|
if !theme_ok(&t, &m) {
|
||||||
|
return page.to_owned();
|
||||||
|
}
|
||||||
|
// data-choice is light, dark or auto; data-mode, what the CSS reads, is only known here for
|
||||||
|
// the first two. theme.ts works Auto out from the system.
|
||||||
|
let mode = if m == "auto" { String::new() } else { format!(" data-mode={m}") };
|
||||||
|
page.replacen(HTML_TAG, &format!("<html lang=en data-theme={t} data-choice={m}{mode}>"), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -510,6 +663,8 @@ struct FeedRow {
|
|||||||
unread: i64,
|
unread: i64,
|
||||||
/// Including you. More than one means every file here is shared.
|
/// Including you. More than one means every file here is shared.
|
||||||
subscribers: i64,
|
subscribers: i64,
|
||||||
|
/// Pinned to the top of your list.
|
||||||
|
pinned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -528,15 +683,16 @@ async fn feeds(
|
|||||||
let cfg = state.ctx.cfg();
|
let cfg = state.ctx.cfg();
|
||||||
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
||||||
// What comes back is only the part of it this person subscribes to.
|
// What comes back is only the part of it this person subscribes to.
|
||||||
let subs = crate::subscriptions(&state.ctx)?;
|
let subs = crate::subscriptions(&state.ctx).await?;
|
||||||
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.subscriptions_for(user.id)?
|
.subscriptions_for(user.id).await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| (s.feed_id.clone(), s))
|
.map(|s| (s.feed_id.clone(), s))
|
||||||
.collect();
|
.collect();
|
||||||
let counts = state.ctx.db.subscriber_counts()?;
|
let counts = state.ctx.db.subscriber_counts().await?;
|
||||||
|
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
||||||
let mut out = Vec::with_capacity(mine.len());
|
let mut out = Vec::with_capacity(mine.len());
|
||||||
for sub in &subs {
|
for sub in &subs {
|
||||||
let (id, feed) = (&sub.id, &sub.cfg);
|
let (id, feed) = (&sub.id, &sub.cfg);
|
||||||
@@ -544,8 +700,8 @@ async fn feeds(
|
|||||||
// the same fallback the scanner uses (`Db::subscribers`).
|
// the same fallback the scanner uses (`Db::subscribers`).
|
||||||
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
let up = feed.group.as_deref().and_then(|g| mine.get(g));
|
||||||
let Some(mine) = mine.get(id) else { continue };
|
let Some(mine) = mine.get(id) else { continue };
|
||||||
let s = state.ctx.db.feed_summary(id)?;
|
let s = state.ctx.db.feed_summary(id).await?;
|
||||||
let st = state.ctx.db.http_state(id)?;
|
let st = state.ctx.db.http_state(id).await?;
|
||||||
out.push(FeedRow {
|
out.push(FeedRow {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
url: feed.url.clone(),
|
url: feed.url.clone(),
|
||||||
@@ -595,8 +751,9 @@ async fn feeds(
|
|||||||
last_error: s.last_error,
|
last_error: s.last_error,
|
||||||
entries: s.entries,
|
entries: s.entries,
|
||||||
downloaded: s.downloaded,
|
downloaded: s.downloaded,
|
||||||
unread: state.ctx.db.unread_count(user.id, id)?,
|
unread: state.ctx.db.unread_count(user.id, id).await?,
|
||||||
subscribers: counts.get(id).copied().unwrap_or(0),
|
subscribers: counts.get(id).copied().unwrap_or(0),
|
||||||
|
pinned: pinned.contains(id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
@@ -654,13 +811,13 @@ struct PopularRow {
|
|||||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||||
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||||
/// feeds inside it and never itself: both lists are for finding a show.
|
/// feeds inside it and never itself: both lists are for finding a show.
|
||||||
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let counts = db.subscriber_counts()?;
|
let counts = db.subscriber_counts().await?;
|
||||||
let media = db.media_feeds()?;
|
let media = db.media_feeds().await?;
|
||||||
let catalogue = crate::subscriptions(&state.ctx)?;
|
let catalogue = crate::subscriptions(&state.ctx).await?;
|
||||||
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
||||||
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||||
let is_folder: std::collections::HashSet<&str> =
|
let is_folder: std::collections::HashSet<&str> =
|
||||||
@@ -677,7 +834,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sum = db.feed_summary(&s.id)?;
|
let sum = db.feed_summary(&s.id).await?;
|
||||||
let subscribed = mine.contains(&s.id);
|
let subscribed = mine.contains(&s.id);
|
||||||
out.push(PopularRow {
|
out.push(PopularRow {
|
||||||
id: s.id.clone(),
|
id: s.id.clone(),
|
||||||
@@ -698,7 +855,7 @@ async fn get_popular(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id)?;
|
let mut rows = popular(&state, user.id).await?;
|
||||||
rows.truncate(10);
|
rows.truncate(10);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -708,7 +865,7 @@ async fn get_directory(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id)?;
|
let mut rows = popular(&state, user.id).await?;
|
||||||
rows.sort_by_key(sort_name);
|
rows.sort_by_key(sort_name);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -724,10 +881,10 @@ async fn subscribe_popular(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
if !popular(&state, user.id).await?.iter().any(|p| p.id == id) {
|
||||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||||
}
|
}
|
||||||
state.ctx.db.subscribe(user.id, &id)?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
Ok(Json(serde_json::json!({ "id": id })))
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,6 +937,13 @@ impl ApiError {
|
|||||||
status: StatusCode::FORBIDDEN,
|
status: StatusCode::FORBIDDEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn not_found(msg: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
error: anyhow::Error::msg(msg.into()),
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
@@ -795,6 +959,38 @@ impl IntoResponse for ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_relative_image_resolves_against_the_post() {
|
||||||
|
let mut b = feed_sanitizer();
|
||||||
|
let html = r#"<img src="images/a.webp"><a href="/about">x</a><script>bad()</script>"#;
|
||||||
|
let out = clean_description(&mut b, html, Some("https://example.com/2026/04/post/"));
|
||||||
|
assert!(out.contains(r#"src="https://example.com/2026/04/post/images/a.webp""#), "{out}");
|
||||||
|
assert!(out.contains(r#"href="https://example.com/about""#), "{out}");
|
||||||
|
assert!(!out.contains("bad()"), "{out}");
|
||||||
|
assert!(out.contains(r#"referrerpolicy="no-referrer""#), "{out}");
|
||||||
|
assert!(clean_description(&mut b, html, None).contains(r#"src="images/a.webp""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_an_admin_is_sent_the_admin_link() {
|
||||||
|
// If the markup drifts from ADMIN_LINK, find matches nothing and says nothing.
|
||||||
|
assert!(page_for(true, (None, None)).contains(ADMIN_LINK));
|
||||||
|
let page = page_for(false, (None, None));
|
||||||
|
assert!(!page.contains(ADMIN_LINK) && !page.contains("href=/admin"), "the link is gone");
|
||||||
|
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_page_arrives_in_the_theme_the_account_chose() {
|
||||||
|
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
|
||||||
|
// If the minifier ever writes the tag differently, HTML_TAG matches nothing, silently.
|
||||||
|
assert!(page("dracula", "dark").contains("<html lang=en data-theme=dracula data-choice=dark data-mode=dark>"));
|
||||||
|
assert!(page("nordic", "auto").contains("<html lang=en data-theme=nordic data-choice=auto>"));
|
||||||
|
// Whatever is in the column is written into markup, so only a plain name gets there.
|
||||||
|
assert!(!page("x onload=alert(1)", "dark").contains("onload"));
|
||||||
|
assert!(page("modern", "dark\"").contains("<html lang=en>"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_feed_with_a_credential_is_never_popular() {
|
fn a_feed_with_a_credential_is_never_popular() {
|
||||||
let f = |url: &str| crate::config::Feed {
|
let f = |url: &str| crate::config::Feed {
|
||||||
@@ -939,7 +1135,7 @@ async fn entries(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Query(page): Query<Page>,
|
Query(page): Query<Page>,
|
||||||
) -> Result<Json<EntryPage>, ApiError> {
|
) -> Result<Json<EntryPage>, ApiError> {
|
||||||
entry_page(&state, user.id, Some(&id), &page)
|
entry_page(&state, user.id, Some(&id), &page).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every subscribed feed's items together, newest first: All Subscriptions.
|
/// Every subscribed feed's items together, newest first: All Subscriptions.
|
||||||
@@ -948,11 +1144,11 @@ async fn all_entries(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Query(page): Query<Page>,
|
Query(page): Query<Page>,
|
||||||
) -> Result<Json<EntryPage>, ApiError> {
|
) -> Result<Json<EntryPage>, ApiError> {
|
||||||
entry_page(&state, user.id, None, &page)
|
entry_page(&state, user.id, None, &page).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
||||||
fn entry_page(
|
async fn entry_page(
|
||||||
state: &WebState,
|
state: &WebState,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
feed: Option<&str>,
|
feed: Option<&str>,
|
||||||
@@ -961,26 +1157,43 @@ 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;
|
||||||
let order = crate::db::order_sql(
|
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
|
||||||
page.sort.as_deref().unwrap_or("published"),
|
|
||||||
page.dir.as_deref().unwrap_or("desc"),
|
|
||||||
);
|
|
||||||
let mut rows =
|
let mut rows =
|
||||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
|
||||||
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
let mut sanitizer = feed_sanitizer();
|
||||||
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
|
|
||||||
// keeps that safe -- so following one in show notes never navigates away from ipx.
|
|
||||||
let mut sanitizer = ammonia::Builder::new();
|
|
||||||
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
|
|
||||||
for row in &mut rows {
|
for row in &mut rows {
|
||||||
if let Some(d) = &row.description {
|
if let Some(d) = &row.description {
|
||||||
row.description = Some(sanitizer.clean(d).to_string());
|
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let total = db.count_in(user_id, feed, filter, search)?;
|
let total = db.count_in(user_id, feed, filter, search).await?;
|
||||||
Ok(Json(EntryPage { total, entries: rows }))
|
Ok(Json(EntryPage { total, entries: rows }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||||
|
fn feed_sanitizer() -> ammonia::Builder<'static> {
|
||||||
|
let mut b = ammonia::Builder::new();
|
||||||
|
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
|
||||||
|
// keeps that safe -- so following one in show notes never navigates away from ipx.
|
||||||
|
b.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
|
||||||
|
// An image is asked for without saying it is shown on ipx. A site that refuses images to
|
||||||
|
// other sites' pages goes by that: jeffgeerling.com answers 403, and his posts showed a
|
||||||
|
// broken image on iOS and only the alt text on desktop.
|
||||||
|
b.add_tag_attributes("img", &["referrerpolicy"]).set_tag_attribute_value("img", "referrerpolicy", "no-referrer");
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relative `src` or `href` in a post means relative to the post, not to ipx: The
|
||||||
|
/// Observation Deck's `images/37k-a-day-bro.webp` came up as a broken image.
|
||||||
|
fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<&str>) -> String {
|
||||||
|
let base = link.and_then(|l| url::Url::parse(l).ok());
|
||||||
|
sanitizer.url_relative(match base {
|
||||||
|
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
|
||||||
|
None => ammonia::UrlRelative::PassThrough,
|
||||||
|
});
|
||||||
|
sanitizer.clean(html).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct NewFeed {
|
struct NewFeed {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -995,10 +1208,10 @@ struct NewFeed {
|
|||||||
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
|
/// The Add feed dialog's explicit box. Like everything on a feed's own dialog it is yours, so it
|
||||||
/// goes on your subscription, and before the first scan, which would otherwise skip every
|
/// goes on your subscription, and before the first scan, which would otherwise skip every
|
||||||
/// explicit item.
|
/// explicit item.
|
||||||
fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
|
async fn explicit_on_add(state: &WebState, user_id: i64, feed_id: &str, allow: bool) -> Result<(), ApiError> {
|
||||||
if allow {
|
if allow {
|
||||||
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
|
let sub = crate::db::Sub { feed_id: feed_id.to_owned(), allow_explicit: Some(true), ..Default::default() };
|
||||||
state.ctx.db.set_subscription(user_id, &sub)?;
|
state.ctx.db.set_subscription(user_id, &sub).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1012,14 +1225,14 @@ async fn add_feed(
|
|||||||
let url = crate::feed::expand_input(&body.url);
|
let url = crate::feed::expand_input(&body.url);
|
||||||
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
||||||
// second copy on disk, just another name against the same feed.
|
// second copy on disk, just another name against the same feed.
|
||||||
if let Some(existing) = crate::subscriptions(&state.ctx)?
|
if let Some(existing) = crate::subscriptions(&state.ctx).await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
||||||
{
|
{
|
||||||
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
|
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
|
||||||
state.ctx.db.subscribe(user.id, &existing.id)?;
|
state.ctx.db.subscribe(user.id, &existing.id).await?;
|
||||||
if !already {
|
if !already {
|
||||||
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit)?;
|
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
|
||||||
}
|
}
|
||||||
scan_soon(&state, Some(existing.id.clone())).await;
|
scan_soon(&state, Some(existing.id.clone())).await;
|
||||||
return Ok(Json(
|
return Ok(Json(
|
||||||
@@ -1027,10 +1240,9 @@ 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)?;
|
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
||||||
explicit_on_add(&state, user.id, &id, body.allow_explicit)?;
|
|
||||||
scan_soon(&state, Some(id.clone())).await;
|
scan_soon(&state, Some(id.clone())).await;
|
||||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||||
}
|
}
|
||||||
@@ -1040,7 +1252,7 @@ async fn add_feed(
|
|||||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||||
let force = feed.is_some();
|
let force = feed.is_some();
|
||||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
|
||||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1064,6 +1276,7 @@ struct FeedPatch {
|
|||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
max_new_per_check: Option<Option<usize>>,
|
max_new_per_check: Option<Option<usize>>,
|
||||||
|
pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -1080,13 +1293,19 @@ async fn patch_feed(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<FeedPatch>,
|
Json(body): Json<FeedPatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
|
||||||
|
if let Some(on) = body.pinned
|
||||||
|
&& !state.ctx.db.set_pinned(user.id, &id, on).await?
|
||||||
|
{
|
||||||
|
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
||||||
|
}
|
||||||
// What one person wants -- which items, whether to fetch them, how many at a time --
|
// What one person wants -- which items, whether to fetch them, how many at a time --
|
||||||
// is theirs. It goes on their subscription and nobody else sees the change.
|
// is theirs. It goes on their subscription and nobody else sees the change.
|
||||||
if state.ctx.db.subscription(user.id, &id)?.is_some() {
|
if state.ctx.db.subscription(user.id, &id).await?.is_some() {
|
||||||
let mut mine = state
|
let mut mine = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.subscription(user.id, &id)?
|
.subscription(user.id, &id).await?
|
||||||
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
|
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
|
||||||
let mut touched = false;
|
let mut touched = false;
|
||||||
if let Some(v) = body.keywords.clone() {
|
if let Some(v) = body.keywords.clone() {
|
||||||
@@ -1106,7 +1325,7 @@ async fn patch_feed(
|
|||||||
touched = true;
|
touched = true;
|
||||||
}
|
}
|
||||||
if touched {
|
if touched {
|
||||||
state.ctx.db.set_subscription(user.id, &mine)?;
|
state.ctx.db.set_subscription(user.id, &mine).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,13 +1346,13 @@ async fn patch_feed(
|
|||||||
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
||||||
// entry: promote it, so the config holds your decisions and nothing else.
|
// entry: promote it, so the config holds your decisions and nothing else.
|
||||||
if !cfg.feeds.contains_key(&id) {
|
if !cfg.feeds.contains_key(&id) {
|
||||||
let subs = crate::subscriptions(&state.ctx)?;
|
let subs = crate::subscriptions(&state.ctx).await?;
|
||||||
let found = subs
|
let found = subs
|
||||||
.iter()
|
.iter()
|
||||||
.find(|s| s.id == id)
|
.find(|s| s.id == id)
|
||||||
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
.ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
||||||
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
||||||
state.ctx.db.unmanage(&id)?;
|
state.ctx.db.unmanage(&id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let checked = match &body.url {
|
let checked = match &body.url {
|
||||||
@@ -1169,12 +1388,11 @@ 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.
|
||||||
state.ctx.db.clear_validators(&id)?;
|
state.ctx.db.clear_validators(&id).await?;
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1186,14 +1404,14 @@ async fn remove_feed(
|
|||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
||||||
// else's alone.
|
// else's alone.
|
||||||
state.ctx.db.unsubscribe(user.id, &id)?;
|
state.ctx.db.unsubscribe(user.id, &id).await?;
|
||||||
for child in crate::subscriptions(&state.ctx)?
|
for child in crate::subscriptions(&state.ctx).await?
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||||
{
|
{
|
||||||
state.ctx.db.unsubscribe(user.id, &child.id)?;
|
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
||||||
}
|
}
|
||||||
if state.ctx.db.subscriber_counts()?.contains_key(&id) {
|
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,12 +1421,11 @@ async fn remove_feed(
|
|||||||
if cfg.feeds.remove(&id).is_none() {
|
if cfg.feeds.remove(&id).is_none() {
|
||||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||||
// read unless you unsubscribe from the OPML itself.
|
// read unless you unsubscribe from the OPML itself.
|
||||||
state.ctx.db.drop_managed(&id)?;
|
state.ctx.db.drop_managed(&id).await?;
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
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)?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,10 +1443,10 @@ async fn set_flags(
|
|||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
use crate::db::EntryFlag;
|
use crate::db::EntryFlag;
|
||||||
if let Some(v) = body.read {
|
if let Some(v) = body.read {
|
||||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v)?;
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
|
||||||
}
|
}
|
||||||
if let Some(v) = body.flagged {
|
if let Some(v) = body.flagged {
|
||||||
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v)?;
|
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1244,12 +1461,12 @@ async fn download_now(
|
|||||||
let enc = state
|
let enc = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id)?
|
.enclosure(id).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
if enc.path.is_some() {
|
if enc.path.is_some() {
|
||||||
return Ok(StatusCode::NO_CONTENT); // Already here.
|
return Ok(StatusCode::NO_CONTENT); // Already here.
|
||||||
}
|
}
|
||||||
state.ctx.db.requeue(id)?;
|
state.ctx.db.requeue(id).await?;
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Download { enclosure: id })
|
.send(Command::Download { enclosure: id })
|
||||||
@@ -1273,13 +1490,13 @@ async fn delete_file(
|
|||||||
let enc = state
|
let enc = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id)?
|
.enclosure(id).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
||||||
|
|
||||||
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
||||||
// it, once, and let them decide.
|
// it, once, and let them decide.
|
||||||
if !q.force {
|
if !q.force {
|
||||||
let (starred, unread) = state.ctx.db.others_wanting(id, user.id)?;
|
let (starred, unread) = state.ctx.db.others_wanting(id, user.id).await?;
|
||||||
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
|
let people = |n: i64| if n == 1 { "person".to_string() } else { format!("{n} people") };
|
||||||
let complaint = match (starred, unread) {
|
let complaint = match (starred, unread) {
|
||||||
(0, 0) => None,
|
(0, 0) => None,
|
||||||
@@ -1305,7 +1522,7 @@ async fn delete_file(
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
|
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
|
||||||
state.ctx.db.mark_reaped(id)?;
|
state.ctx.db.mark_reaped(id).await?;
|
||||||
state.events.send(Event::Reaped {
|
state.events.send(Event::Reaped {
|
||||||
path: enc.path.unwrap_or_default(),
|
path: enc.path.unwrap_or_default(),
|
||||||
bytes: enc.length.unwrap_or(0).max(0) as u64,
|
bytes: enc.length.unwrap_or(0).max(0) as u64,
|
||||||
@@ -1323,11 +1540,21 @@ struct FetchBody {
|
|||||||
|
|
||||||
async fn fetch_now(
|
async fn fetch_now(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
Json(body): Json<FetchBody>,
|
Json(body): Json<FetchBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
|
||||||
|
let feeds = if body.feed.is_some() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
|
||||||
|
};
|
||||||
|
if body.feed.is_none() && feeds.is_empty() {
|
||||||
|
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
|
||||||
|
}
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Fetch { feed: body.feed, force: body.force })
|
.send(Command::Fetch { feed: body.feed, force: body.force, feeds })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
|
||||||
Ok(StatusCode::ACCEPTED)
|
Ok(StatusCode::ACCEPTED)
|
||||||
@@ -1359,7 +1586,7 @@ async fn media(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
|
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
|
||||||
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
||||||
};
|
};
|
||||||
let Some(path) = enc.path else {
|
let Some(path) = enc.path else {
|
||||||
@@ -1384,7 +1611,7 @@ async fn set_position(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<Position>,
|
Json(body): Json<Position>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration)?;
|
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1396,12 +1623,12 @@ async fn read_all(
|
|||||||
// A subscription's own row has no entries, so marking it read means everything under it.
|
// A subscription's own row has no entries, so marking it read means everything under it.
|
||||||
let mut ids = vec![id.clone()];
|
let mut ids = vec![id.clone()];
|
||||||
ids.extend(
|
ids.extend(
|
||||||
crate::subscriptions(&state.ctx)?
|
crate::subscriptions(&state.ctx).await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
||||||
.map(|s| s.id),
|
.map(|s| s.id),
|
||||||
);
|
);
|
||||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
|
||||||
Ok(Json(serde_json::json!({ "marked": n })))
|
Ok(Json(serde_json::json!({ "marked": n })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1412,8 +1639,8 @@ async fn read_all_mine(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let ids: Vec<String> =
|
let ids: Vec<String> =
|
||||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
let n = state.ctx.db.mark_all_read(user.id, &ids).await?;
|
||||||
Ok(Json(serde_json::json!({ "marked": n })))
|
Ok(Json(serde_json::json!({ "marked": n })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1434,9 +1661,9 @@ async fn download_latest(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<HowMany>,
|
Json(body): Json<HowMany>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100))?;
|
let ids = state.ctx.db.undownloaded(&id, body.count.clamp(1, 100)).await?;
|
||||||
for enc in &ids {
|
for enc in &ids {
|
||||||
state.ctx.db.requeue(*enc)?;
|
state.ctx.db.requeue(*enc).await?;
|
||||||
state
|
state
|
||||||
.cmds
|
.cmds
|
||||||
.send(Command::Download { enclosure: *enc })
|
.send(Command::Download { enclosure: *enc })
|
||||||
@@ -1454,7 +1681,7 @@ async fn export_opml(
|
|||||||
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
|
// Yours, not the whole catalogue: other people's feeds, and any private URLs in them, are
|
||||||
// not yours to download. This used to export config.toml to whoever asked.
|
// not yours to download. This used to export config.toml to whoever asked.
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let mut doc = opml::OPML {
|
let mut doc = opml::OPML {
|
||||||
head: Some(opml::Head {
|
head: Some(opml::Head {
|
||||||
title: Some("ipx subscriptions".into()),
|
title: Some("ipx subscriptions".into()),
|
||||||
@@ -1462,7 +1689,7 @@ async fn export_opml(
|
|||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
for s in crate::subscriptions(&state.ctx)? {
|
for s in crate::subscriptions(&state.ctx).await? {
|
||||||
// A feed from an OPML subscription comes back with the OPML itself.
|
// A feed from an OPML subscription comes back with the OPML itself.
|
||||||
if s.managed || !mine.contains(&s.id) {
|
if s.managed || !mine.contains(&s.id) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1470,7 +1697,7 @@ async fn export_opml(
|
|||||||
let title = state
|
let title = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.feed_summary(&s.id)
|
.feed_summary(&s.id).await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|sum| sum.title)
|
.and_then(|sum| sum.title)
|
||||||
.unwrap_or_else(|| s.id.clone());
|
.unwrap_or_else(|| s.id.clone());
|
||||||
@@ -1504,7 +1731,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)?;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1578,8 +1805,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
tests/contrast.js
Normal file
96
tests/contrast.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
|
||||||
|
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||||
|
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||||
|
//
|
||||||
|
// node tests/contrast.js
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
||||||
|
const blocks = {};
|
||||||
|
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||||
|
const vars = {};
|
||||||
|
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||||
|
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lum = h => {
|
||||||
|
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||||
|
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||||
|
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
||||||
|
};
|
||||||
|
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||||
|
|
||||||
|
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||||
|
const RULES = [
|
||||||
|
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||||
|
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||||
|
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||||
|
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
|
||||||
|
['ink', ['accent'], 4.5], // a primary button's label
|
||||||
|
['ink', ['accent2'], 4.5], // the unread count on its badge
|
||||||
|
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
|
||||||
|
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
|
||||||
|
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
|
||||||
|
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||||
|
];
|
||||||
|
|
||||||
|
const base = blocks[':root'];
|
||||||
|
const themes = {};
|
||||||
|
for (const sel of Object.keys(blocks)) {
|
||||||
|
const t = sel.match(/data-theme="(\w+)"/);
|
||||||
|
if (!t) continue;
|
||||||
|
const light = /data-mode="light"/.test(sel);
|
||||||
|
// A theme's dark half is :root under its own block; its light half adds the light block.
|
||||||
|
const name = `${t[1]}${light ? ' light' : ''}`;
|
||||||
|
themes[name] = light
|
||||||
|
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
|
||||||
|
: { ...base, ...blocks[sel] };
|
||||||
|
}
|
||||||
|
themes['modern'] = base;
|
||||||
|
|
||||||
|
let bad = 0;
|
||||||
|
for (const [name, p] of Object.entries(themes)) {
|
||||||
|
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||||
|
const r = ratio(p[fg], p[g]);
|
||||||
|
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||||
|
}
|
||||||
|
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||||
|
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
|
||||||
|
}
|
||||||
|
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
|
||||||
|
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
|
||||||
|
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
||||||
|
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
||||||
|
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
||||||
|
const rgba = (sel, n) => {
|
||||||
|
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
||||||
|
return [[+m[1], +m[2], +m[3]], +m[4]];
|
||||||
|
};
|
||||||
|
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
|
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
||||||
|
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
||||||
|
const p = themes[name];
|
||||||
|
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
||||||
|
const worst = {};
|
||||||
|
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||||
|
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||||
|
let g = rgb(p.bg);
|
||||||
|
for (const [w, [c, a]] of tint) {
|
||||||
|
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||||
|
g = over(c, a * Math.max(0, 1 - d), g);
|
||||||
|
}
|
||||||
|
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it.
|
||||||
|
for (const [gn, gc] of [['the wash', g], ['a panel', over(rgb(p.panel), .7, g)]])
|
||||||
|
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||||
|
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
||||||
|
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
||||||
|
for (const [k, r] of Object.entries(worst))
|
||||||
|
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
|
||||||
|
}
|
||||||
|
if (bad) process.exit(1);
|
||||||
|
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);
|
||||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-forger.der
Normal file
Binary file not shown.
BIN
tests/data/access-test.der
Normal file
BIN
tests/data/access-test.der
Normal file
Binary file not shown.
12
tests/data/access-test.jwks.json
Normal file
12
tests/data/access-test.jwks.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "k1",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"e": "AQAB",
|
||||||
|
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
131
tests/dom-stub.js
Normal file
131
tests/dom-stub.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
||||||
|
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
||||||
|
// to find what it reaches for, and no more.
|
||||||
|
//
|
||||||
|
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
||||||
|
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
||||||
|
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
||||||
|
// nothing about whether it worked.
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
function makeContext(html, script, { media = false } = {}) {
|
||||||
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||||
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||||
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||||
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||||
|
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||||
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||||
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||||
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||||
|
|
||||||
|
const body = el('body');
|
||||||
|
const audio = media ? makeMediaElement() : null;
|
||||||
|
if (media) {
|
||||||
|
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
||||||
|
// one has to be a real set rather than something that always says no.
|
||||||
|
const classes = new Set();
|
||||||
|
body.classList = {
|
||||||
|
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||||
|
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
querySelector(sel) {
|
||||||
|
if (sel === '#audio' && audio) return audio;
|
||||||
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||||
|
return el(sel);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => el('created'),
|
||||||
|
addEventListener(){}, body,
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
document, console,
|
||||||
|
window: { isSecureContext: false, addEventListener(){} },
|
||||||
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||||
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||||
|
fetch: (url) => Promise.resolve({
|
||||||
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||||
|
json: () => Promise.resolve(
|
||||||
|
String(url).includes('/api/settings')
|
||||||
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||||
|
: String(url).includes('/api/users')
|
||||||
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||||
|
: /\/api\/(popular|directory)/.test(String(url))
|
||||||
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||||
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||||
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||||
|
}),
|
||||||
|
EventSource: function () { this.close = () => {}; },
|
||||||
|
MediaMetadata: function () {},
|
||||||
|
Blob: function () {},
|
||||||
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||||
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||||
|
};
|
||||||
|
if (media) {
|
||||||
|
ctx.HTMLMediaElement = MediaElement;
|
||||||
|
ctx.Event = Event;
|
||||||
|
ctx.isFinite = isFinite;
|
||||||
|
ctx.CSS = { escape: s => String(s) };
|
||||||
|
}
|
||||||
|
ctx.globalThis = ctx;
|
||||||
|
ctx.window.location = { href: '', hash: '' };
|
||||||
|
ctx.location = ctx.window.location;
|
||||||
|
|
||||||
|
return { ctx, missing, audio, body, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
||||||
|
|
||||||
|
function Event(type) { this.type = type; }
|
||||||
|
|
||||||
|
function MediaElement() {
|
||||||
|
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
||||||
|
this._ready = 0; this._vol = 1; this._rate = 1;
|
||||||
|
this._listeners = {};
|
||||||
|
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
||||||
|
// Every call that reached the real element, so a test can say the host took over rather than
|
||||||
|
// the element quietly playing as well.
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
||||||
|
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
||||||
|
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
||||||
|
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
||||||
|
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
||||||
|
};
|
||||||
|
MediaElement.prototype.dispatchEvent = function(ev){
|
||||||
|
for (const l of (this._listeners[ev.type] || []).slice()) {
|
||||||
|
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
||||||
|
l.fn(ev);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
||||||
|
MediaElement.prototype.setAttribute = function(){};
|
||||||
|
MediaElement.prototype.getAttribute = function(){ return null; };
|
||||||
|
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
||||||
|
configurable: true,
|
||||||
|
get(){ return this[field]; },
|
||||||
|
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
||||||
|
});
|
||||||
|
accessor('src', '_src', true);
|
||||||
|
accessor('currentTime', '_t', true);
|
||||||
|
accessor('volume', '_vol');
|
||||||
|
accessor('playbackRate', '_rate');
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
||||||
|
|
||||||
|
function makeMediaElement(){ return new MediaElement(); }
|
||||||
|
|
||||||
|
module.exports = { makeContext, vm };
|
||||||
117
tests/native-bridge.js
Normal file
117
tests/native-bridge.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
||||||
|
// hand it to the host, while everything in player.ts carries on talking to the element.
|
||||||
|
//
|
||||||
|
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
||||||
|
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
||||||
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||||
|
// road, with nothing to look at.
|
||||||
|
//
|
||||||
|
// node tests/native-bridge.js
|
||||||
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
|
const { buildPage } = require('../web/build.mjs');
|
||||||
|
|
||||||
|
const { html, js: script } = buildPage('index.html');
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||||
|
|
||||||
|
/* ---- a browser: nothing installs ---- */
|
||||||
|
{
|
||||||
|
const { ctx } = makeContext(html, script, { media: true });
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
|
||||||
|
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
||||||
|
const audio = ctx.document.querySelector('#audio');
|
||||||
|
audio.src = '/media/1';
|
||||||
|
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- inside the shell ---- */
|
||||||
|
const posted = [];
|
||||||
|
const { ctx, audio, body } = makeContext(html, script, { media: true });
|
||||||
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||||
|
|
||||||
|
const last = t => [...posted].reverse().find(m => m.t === t);
|
||||||
|
const since = () => posted.splice(0, posted.length);
|
||||||
|
|
||||||
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||||
|
ok(last('ready'), 'the host was never told the bridge is in');
|
||||||
|
since();
|
||||||
|
|
||||||
|
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
||||||
|
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
||||||
|
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
||||||
|
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
||||||
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
||||||
|
|
||||||
|
body.classList.toggle('has-video', false);
|
||||||
|
audio.calls.length = 0;
|
||||||
|
audio.src = '/media/42';
|
||||||
|
|
||||||
|
const load = last('load');
|
||||||
|
ok(load, 'setting the src told the host nothing');
|
||||||
|
if (load) {
|
||||||
|
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
||||||
|
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
||||||
|
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
||||||
|
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
||||||
|
}
|
||||||
|
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
||||||
|
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
||||||
|
|
||||||
|
// player.ts sets currentTime=0 straight after the src.
|
||||||
|
since();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
|
||||||
|
|
||||||
|
since();
|
||||||
|
audio.play();
|
||||||
|
ok(last('play'), 'play did not reach the host');
|
||||||
|
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
||||||
|
|
||||||
|
// The host answers, and the page must move as it would have on its own.
|
||||||
|
let played = 0, timed = 0;
|
||||||
|
audio.addEventListener('play', () => played++);
|
||||||
|
audio.addEventListener('timeupdate', () => timed++);
|
||||||
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
||||||
|
ok(played === 1, 'the page never saw the host start playing');
|
||||||
|
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
||||||
|
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
||||||
|
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
||||||
|
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
||||||
|
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
||||||
|
|
||||||
|
// The 15-second key: a read and a write through the shim.
|
||||||
|
since();
|
||||||
|
audio.currentTime -= 15;
|
||||||
|
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
|
||||||
|
|
||||||
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||||
|
since();
|
||||||
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||||
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||||
|
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
|
||||||
|
|
||||||
|
// Closing the player has to stop the host, not just blank the element.
|
||||||
|
since();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
ok(last('stop'), 'closing the player left the host playing');
|
||||||
|
|
||||||
|
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
||||||
|
// buys nothing.
|
||||||
|
since();
|
||||||
|
audio.calls.length = 0;
|
||||||
|
body.classList.toggle('has-video', true);
|
||||||
|
audio.src = '/media/99';
|
||||||
|
ok(!last('load'), 'a video was handed to the host');
|
||||||
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||||
|
|
||||||
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||||
|
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
||||||
|
process.exit(0);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// Executes web/index.html's script against a stub DOM and fails on anything thrown.
|
// Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
|
||||||
|
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
|
||||||
//
|
//
|
||||||
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
||||||
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
||||||
@@ -6,65 +7,23 @@
|
|||||||
// 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 fs = require('fs');
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
const path = require('path');
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
const vm = require('vm');
|
|
||||||
|
|
||||||
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8');
|
const { buildPage } = require('../web/build.mjs');
|
||||||
const script = html.split('<script>')[1].split('</script>')[0];
|
// What ships: minified, so an id may have lost its quotes.
|
||||||
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
const { html, js: script, script: file } = buildPage(PAGE);
|
||||||
|
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
|
||||||
const missing = [];
|
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
|
||||||
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,
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
}
|
||||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
const { ctx, missing } = makeContext(html, script);
|
||||||
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: '' };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -78,19 +37,21 @@ const feed = {
|
|||||||
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
||||||
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
||||||
};
|
};
|
||||||
const drive = [
|
const drive = PAGE === 'admin.html' ? [
|
||||||
|
['drawServer', () => ctx.drawServer()],
|
||||||
|
['drawAccounts', () => ctx.drawAccounts()],
|
||||||
|
['drawLogView', () => ctx.drawLogView()],
|
||||||
|
] : [
|
||||||
['settingsModal', () => ctx.settingsModal(feed)],
|
['settingsModal', () => ctx.settingsModal(feed)],
|
||||||
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
||||||
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
||||||
['removeFeed', () => ctx.removeFeed(feed)],
|
['removeFeed', () => ctx.removeFeed(feed)],
|
||||||
['prefsModal', () => ctx.prefsModal()],
|
['prefsModal', () => ctx.prefsModal()],
|
||||||
['usersModal', () => ctx.usersModal()],
|
|
||||||
['opmlModal', () => ctx.opmlModal()],
|
['opmlModal', () => ctx.opmlModal()],
|
||||||
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||||
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
||||||
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
||||||
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||||
['logsModal', () => ctx.logsModal()],
|
|
||||||
['keysModal', () => ctx.keysModal()],
|
['keysModal', () => ctx.keysModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||||
// of a vm context the way var and function declarations do.
|
// of a vm context the way var and function declarations do.
|
||||||
@@ -108,10 +69,18 @@ for (const [name, fn] of drive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
||||||
|
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
||||||
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
||||||
|
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('OK: page script loads clean, every selector it wires at load exists');
|
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
|
||||||
// logsModal arms a poll timer; without this the pending interval keeps node alive.
|
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
|
||||||
|
if (PAGE === 'index.html') {
|
||||||
|
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
|
||||||
|
process.exit(r.status);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -20,62 +20,130 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
|||||||
expect(errors, 'the page script must not throw at load').toEqual([]);
|
expect(errors, 'the page script must not throw at load').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme toggle actually changes the theme', async ({ page }) => {
|
test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
|
||||||
// Regression: this button was wired after a line that threw, so it did nothing.
|
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
|
||||||
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
|
const doc = await page.reload();
|
||||||
await page.locator('#theme').click();
|
const r = await js;
|
||||||
await expect
|
// The page is checked every visit, so it always names the current script...
|
||||||
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
|
expect(doc.headers()['cache-control']).toBe('no-cache');
|
||||||
.not.toBe(before);
|
// ...by a hash of its contents, which is why the script itself can be kept for a year.
|
||||||
|
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
|
||||||
|
expect(r.headers()['cache-control']).toContain('immutable');
|
||||||
|
expect(r.headers()['content-type']).toContain('javascript');
|
||||||
|
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
|
||||||
|
// The sign-in page's script loads before signing in.
|
||||||
|
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
|
||||||
|
expect(login.status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
|
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
|
||||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
|
||||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||||
expect(await theme()).toBe('classic');
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
|
await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
|
||||||
|
|
||||||
|
await page.locator('#stheme').selectOption('modern');
|
||||||
|
await page.locator('#smode').selectOption('auto');
|
||||||
|
// Auto follows the system, live, with no reload.
|
||||||
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
|
await expect.poll(root).toEqual(['modern', 'light']);
|
||||||
|
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await expect.poll(root).toEqual(['modern', 'dark']);
|
||||||
|
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
|
||||||
|
|
||||||
|
// Dracula, then its light half, Alucard.
|
||||||
|
await page.locator('#stheme').selectOption('dracula');
|
||||||
|
await page.locator('#smode').selectOption('dark');
|
||||||
|
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
|
||||||
|
await page.locator('#smode').selectOption('light');
|
||||||
|
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
|
||||||
|
|
||||||
|
// Classic and Paper come one way only, so there is nothing to choose.
|
||||||
|
await page.locator('#stheme').selectOption('paper');
|
||||||
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
|
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
||||||
|
// The save that says Classic: the ones before it may still be answering.
|
||||||
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
|
||||||
|
&& r.request().postDataJSON().theme === 'classic');
|
||||||
|
await page.locator('#stheme').selectOption('classic');
|
||||||
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
|
await saved; // kept on the account, not the browser
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect.poll(theme).toBe('classic');
|
await expect.poll(root).toEqual(['classic', 'light']);
|
||||||
// The 2004 Mac app set its type in Lucida Grande.
|
// The 2004 Mac app set its type in Lucida Grande.
|
||||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||||
|
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
|
||||||
|
|
||||||
|
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await page.locator('#stheme').selectOption('nordic');
|
||||||
|
await expect(page.locator('#smode')).toHaveValue('light');
|
||||||
|
await page.locator('#smode').selectOption('dark');
|
||||||
|
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
|
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
|
||||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#stheme')).toHaveValue(await theme());
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.locator('#stheme').selectOption('flatremix');
|
||||||
|
expect((await saved).status()).toBe(204);
|
||||||
|
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.locator('#smode').selectOption('light');
|
||||||
|
await saved2;
|
||||||
|
|
||||||
await page.locator('#stheme').selectOption('auto');
|
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
|
||||||
await expect.poll(theme).toBe('auto');
|
// written onto <html> by the server rather than set once the script has run.
|
||||||
// Auto follows the system; emulating a light system must show the light palette live,
|
const other = await browser.newContext();
|
||||||
// no reload needed, since it is a media query rather than something JS picks per click.
|
const p2 = await other.newPage();
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
const res = await p2.goto(`/?token=${TOKEN}`);
|
||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
||||||
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
|
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
await page.emulateMedia({ colorScheme: 'dark' });
|
.toEqual(['flatremix', 'light']);
|
||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
await other.close();
|
||||||
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
|
|
||||||
|
|
||||||
// The header button and the dropdown are the same one setting, not two.
|
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
|
|
||||||
await page.locator('#theme').click();
|
|
||||||
expect(await theme()).toBe('dark');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
|
||||||
await page.locator('#prefs').click();
|
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
|
||||||
await expect(page.locator('#modal.on')).toBeVisible();
|
const who = `theme-${Date.now()}@example.com`;
|
||||||
await expect(page.locator('#gnum')).toBeVisible();
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
|
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
||||||
|
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.goto('/');
|
||||||
|
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
|
.toEqual(['modern', 'light']);
|
||||||
|
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
|
||||||
|
await ctx.close();
|
||||||
|
|
||||||
|
// Anywhere else now, it comes from the account.
|
||||||
|
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
|
const p2 = await fresh.newPage();
|
||||||
|
const res = await p2.goto('/');
|
||||||
|
expect(await res.text()).toContain('data-theme=modern data-choice=light');
|
||||||
|
await fresh.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin page saves the global schedule', async ({ page }) => {
|
||||||
|
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
|
||||||
|
await page.locator('#admin').click();
|
||||||
|
await expect(page).toHaveURL(/\/admin$/);
|
||||||
|
await expect(page.locator('#atabs a.on')).toHaveText('Server');
|
||||||
await page.locator('#gnum').fill('4');
|
await page.locator('#gnum').fill('4');
|
||||||
await page.locator('#gunit').selectOption('h');
|
await page.locator('#gunit').selectOption('h');
|
||||||
await page.locator('#gsave').click();
|
await page.locator('#gsave').click();
|
||||||
await expect(page.locator('#modal.on')).toBeHidden();
|
await expect(page.locator('#toasts')).toContainText('Settings saved');
|
||||||
|
|
||||||
// It must survive a reload, i.e. actually reach the config.
|
// It must survive a reload, i.e. actually reach the config.
|
||||||
await page.locator('#prefs').click();
|
await page.reload();
|
||||||
await expect(page.locator('#gnum')).toHaveValue('4');
|
await expect(page.locator('#gnum')).toHaveValue('4');
|
||||||
await expect(page.locator('#gunit')).toHaveValue('h');
|
await expect(page.locator('#gunit')).toHaveValue('h');
|
||||||
|
// And Settings in the app no longer has it.
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await expect(page.locator('#modalCard')).toContainText('Theme');
|
||||||
|
await expect(page.locator('#gnum')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
||||||
@@ -120,6 +188,35 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
|
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('while an episode plays, its play buttons all say pause, and pause it', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
|
||||||
|
await expect(downloaded).toBeVisible({ timeout: 20_000 });
|
||||||
|
await downloaded.click();
|
||||||
|
// What the buttons do, not whether this browser decodes the fixture: it does not, reliably,
|
||||||
|
// and a load error pauses the player, rightly turning every button back to play. So the
|
||||||
|
// player plays and pauses here as a real one does, events and all, with no file behind it.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let paused = true;
|
||||||
|
Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true });
|
||||||
|
audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); };
|
||||||
|
audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); };
|
||||||
|
});
|
||||||
|
const pane = page.locator('#files [data-a="play"]');
|
||||||
|
await pane.click();
|
||||||
|
await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true);
|
||||||
|
// The files pane, the row and the toolbar all follow the player bar, not only the bar.
|
||||||
|
await expect(pane).toHaveAttribute('title', 'Pause');
|
||||||
|
await expect(downloaded.locator('[data-a="play"]')).toHaveAttribute('title', 'Pause');
|
||||||
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Pause');
|
||||||
|
await pane.click(); // and pressing it pauses
|
||||||
|
await expect.poll(() => page.evaluate(() => audio.paused)).toBe(true);
|
||||||
|
await expect(pane).toHaveAttribute('title', 'Play');
|
||||||
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Play the selected item');
|
||||||
|
await page.locator('#pclose').click();
|
||||||
|
});
|
||||||
|
|
||||||
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||||
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
||||||
// header image rendered as a broken player.
|
// header image rendered as a broken player.
|
||||||
@@ -233,6 +330,32 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
|||||||
await expect(page.locator('#count')).toContainText('0 items');
|
await expect(page.locator('#count')).toContainText('0 items');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on the Unread tab an item stays while you read it and goes when you move on', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Earlier tests read things; make the first two unread with their own dots.
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
const row = page.locator('.ep').nth(i);
|
||||||
|
if (await row.evaluate(r => r.classList.contains('read'))) {
|
||||||
|
await row.locator('[data-a="read"]').click();
|
||||||
|
await expect(page.locator('.ep').nth(i)).not.toHaveClass(/\bread\b/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
|
const first = page.locator('.ep').first();
|
||||||
|
const guid = await first.getAttribute('data-guid');
|
||||||
|
await first.click();
|
||||||
|
const it = page.locator(`.ep[data-guid="${guid}"]`);
|
||||||
|
await expect(it).toHaveClass(/\bread\b/);
|
||||||
|
await page.waitForTimeout(1500); // past the SSE refresh debounce and loadFeeds
|
||||||
|
await expect(it).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.ep').nth(1).click();
|
||||||
|
await expect(it).toHaveCount(0);
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('#content .acts [data-a="settings"]').click();
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
@@ -248,7 +371,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
||||||
await page.locator('#logs').click();
|
await page.goto('/admin#log');
|
||||||
await expect(page.locator('#logbox')).toBeVisible();
|
await expect(page.locator('#logbox')).toBeVisible();
|
||||||
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
||||||
|
|
||||||
@@ -446,18 +569,22 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
|
|
||||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||||
// Settings stays: Sam has their own subscriptions to export and import, and the
|
// Settings stays: Sam has their own theme and subscriptions. The admin page -- the server's
|
||||||
// schedule and quota are worth seeing even without a say in them. Only the log and the
|
// settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
|
||||||
// users screen -- and the server -- are an admin's alone.
|
// link to it, never mind the page.
|
||||||
await expect(page.locator('#prefs')).toBeVisible();
|
await expect(page.locator('#prefs')).toBeVisible();
|
||||||
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('#gsave')).toBeHidden();
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await expect(page.locator('#gusers')).toBeHidden();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
// Hiding the button is not the guard; the server is.
|
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.
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page).toHaveURL(/:8791\/$/);
|
||||||
|
expect((await page.request.get('/admin.js')).status()).toBe(403);
|
||||||
|
// Hiding the way in is not the guard; the server is.
|
||||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||||
await expect(page.locator('#logs')).toBeHidden();
|
|
||||||
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||||
|
|
||||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||||
@@ -513,11 +640,12 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|||||||
|
|
||||||
// Every row says "Admin" on its checkbox, so match the name exactly.
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
||||||
const userRow = (page, name) =>
|
const userRow = (page, name) =>
|
||||||
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
||||||
|
|
||||||
async function openUsers(page) {
|
async function openUsers(page) {
|
||||||
await page.locator('#prefs').click();
|
await page.goto('/admin');
|
||||||
await page.locator('#gusers').click();
|
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin#accounts$/);
|
||||||
await expect(userRow(page, 'admin')).toBeVisible();
|
await expect(userRow(page, 'admin')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,9 +891,10 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
await pick('tabs', 'All').click();
|
await pick('tabs', 'All').click();
|
||||||
await expect(tiles).toHaveCount(dir.length);
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
|
||||||
// Add a feed opened over Directory fills its own list, not the pane behind it.
|
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
|
||||||
await piper.locator('#addFeed').click();
|
await piper.locator('#addFeed').click();
|
||||||
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
|
await expect(piper.locator('#nurl')).toBeVisible();
|
||||||
|
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
|
||||||
await expect(tiles).toHaveCount(dir.length);
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
await piper.locator('#modalCard button[title="Cancel"]').click();
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||||
|
|
||||||
@@ -842,9 +971,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
|||||||
const dialogs = [
|
const dialogs = [
|
||||||
() => page.locator('#addFeed').click(),
|
() => page.locator('#addFeed').click(),
|
||||||
() => page.locator('#prefs').click(),
|
() => page.locator('#prefs').click(),
|
||||||
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
|
|
||||||
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
||||||
() => page.locator('#logs').click(),
|
|
||||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||||
@@ -869,6 +996,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
|
|||||||
// its own button makes it unread again.
|
// its own button makes it unread again.
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
||||||
|
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
|
||||||
|
// begin with, and a mark-unread still in flight could land after the read-all below.
|
||||||
|
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
|
||||||
await expect(all.locator('.badge')).not.toHaveText('0');
|
await expect(all.locator('.badge')).not.toHaveText('0');
|
||||||
|
|
||||||
page.once('dialog', d => d.accept());
|
page.once('dialog', d => d.accept());
|
||||||
@@ -1015,3 +1145,199 @@ test('the pinned heading sits over its pins, and the page is set in Inter', asyn
|
|||||||
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
|
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
|
||||||
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
|
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
test('a downloaded file can be deleted, from above the show notes', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const del = page.locator('#detail [data-a="del"]');
|
||||||
|
await expect(del).toBeInViewport();
|
||||||
|
// Below a long set of notes it was screens down and looked missing (issue #21).
|
||||||
|
expect(await page.locator('#detail').evaluate(d =>
|
||||||
|
!!(d.querySelector('.encbox').compareDocumentPosition(d.querySelector('.dbody')) & Node.DOCUMENT_POSITION_FOLLOWING)))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
|
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
|
||||||
|
// The icon's middle against the date's, downloaded or not. The download bar used to take a
|
||||||
|
// line of its own and lift the icon of every pending file (issue #31).
|
||||||
|
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
|
||||||
|
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
|
||||||
|
if (!k || !d) return null;
|
||||||
|
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
|
||||||
|
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
|
||||||
|
}).filter(x => x !== null));
|
||||||
|
expect(offsets.length).toBeGreaterThan(1);
|
||||||
|
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||||
|
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/favicon.png');
|
||||||
|
// 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']) {
|
||||||
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||||
|
expect(r.status(), path).toBe(200);
|
||||||
|
expect(r.headers()['content-type'], path).toBe('image/png');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => {
|
||||||
|
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
||||||
|
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
||||||
|
await page.locator('.feed.group .chev').first().click();
|
||||||
|
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
||||||
|
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out';
|
||||||
|
renderFeeds();
|
||||||
|
});
|
||||||
|
await expect(page.locator('.ferr')).toHaveCount(2);
|
||||||
|
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
|
||||||
|
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
||||||
|
const xs = await page.$$eval('.chev, .ferr', els =>
|
||||||
|
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); }));
|
||||||
|
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
|
||||||
|
await page.reload(); // put the real list back
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('touch gestures on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
||||||
|
async function drag(page, from, to) {
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
const steps = 8;
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
||||||
|
for (let i = 1; i <= steps; i++)
|
||||||
|
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 }] });
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const titles = await page.locator('.ep .t').allTextContents();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const shown = page.locator('#detail .dt');
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
const box = await page.locator('#list').boundingBox();
|
||||||
|
const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST');
|
||||||
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
|
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
||||||
|
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('an item with no files, on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
test('shows no files box at all', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
// An item with no enclosure, found from the list the page itself has.
|
||||||
|
const guid = await page.evaluate(() => S.entries.find(e => !e.enclosures.length)?.guid);
|
||||||
|
expect(guid, 'the fixtures have an item with no files').toBeTruthy();
|
||||||
|
await page.locator(`.ep[data-guid="${guid}"]`).click();
|
||||||
|
await expect(page.locator('#detail .dt')).toBeVisible();
|
||||||
|
await expect(page.locator('#detail .encbox')).toHaveCount(0);
|
||||||
|
await expect(page.locator('#detail')).not.toContainText('No files');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||||
|
const rows = page.locator('#feedlist .feed');
|
||||||
|
const group = page.locator('.feed.group').first();
|
||||||
|
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||||
|
const child = page.locator('.feed.child').first();
|
||||||
|
const name = (await child.locator('.txt b').textContent()).trim();
|
||||||
|
await child.click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
|
||||||
|
// First in the list, out of its folder, marked, and with the rule under it.
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
await expect(rows.first()).toHaveClass(/\bpinned\b/);
|
||||||
|
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
|
||||||
|
await expect(rows.first()).toHaveClass(/\blastpin\b/);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
|
||||||
|
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
// It is on the account: a reload keeps it.
|
||||||
|
await page.reload();
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
|
||||||
|
// Unpinned, it goes back into its folder.
|
||||||
|
await rows.first().click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge')).toBeVisible();
|
||||||
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
|
await page.locator('#scanAll').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
||||||
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
||||||
|
// Listen as the page does, then ask.
|
||||||
|
await piper.evaluate(() => {
|
||||||
|
window.started = []; window.done = false;
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
||||||
|
if (ev.ev === 'scan_done') window.done = true; };
|
||||||
|
});
|
||||||
|
await piper.waitForTimeout(500);
|
||||||
|
await piper.locator('#scanAll').click();
|
||||||
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
||||||
|
const started = await piper.evaluate(() => window.started);
|
||||||
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
||||||
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mjs does that with swc.
|
||||||
|
// The files are one script in one scope, not modules, which is why there are no imports.
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": false,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["web/src/*.ts"]
|
||||||
|
}
|
||||||
34
web/admin.html
Normal file
34
web/admin.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>iPodderX admin</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
</head>
|
||||||
|
<body class="adminpage">
|
||||||
|
<!-- The server sends this page, and its script, to admins only. -->
|
||||||
|
<header id="topbar">
|
||||||
|
<a class="btn ico" href="/" title="Back to iPodderX" aria-label="Back to iPodderX" data-icon="left"></a>
|
||||||
|
<img class="logo" src="/icon.png" alt="" width="26">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<nav class="tabs" id="atabs">
|
||||||
|
<a href="#server" data-t="server">Server</a>
|
||||||
|
<a href="#accounts" data-t="accounts">Accounts</a>
|
||||||
|
<a href="#log" data-t="log">Log</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="wrap plain" id="admin">
|
||||||
|
<section id="server" hidden></section>
|
||||||
|
<section id="accounts" hidden></section>
|
||||||
|
<section id="log" hidden></section>
|
||||||
|
</main>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
|
||||||
|
<script data-src="web/src"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1151
web/app.css
Normal file
1151
web/app.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/apple-touch-icon.png
Normal file
BIN
web/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
91
web/build.mjs
Normal file
91
web/build.mjs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
||||||
|
// by swc into a script of its own (app.js, login.js), and the page minified, its
|
||||||
|
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
||||||
|
// include_str!s the results, so the binary still carries everything and nothing is served
|
||||||
|
// from disk.
|
||||||
|
//
|
||||||
|
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
||||||
|
// server lets a browser keep that for a year without asking again. A changed script is a new
|
||||||
|
// URL, and the page, which the browser checks on every visit, is what carries it.
|
||||||
|
//
|
||||||
|
// node web/build.mjs [out-dir] default out-dir: web/dist
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import swc from '@swc/core';
|
||||||
|
import html from '@swc/html';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||||
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
|
const PAGES = {
|
||||||
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||||
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
|
};
|
||||||
|
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
|
||||||
|
const STYLE = 'app.css';
|
||||||
|
|
||||||
|
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||||||
|
|
||||||
|
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
||||||
|
/// one; the doctype only keeps it from complaining that a fragment has none.
|
||||||
|
export function buildStyle({ minify = true } = {}) {
|
||||||
|
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
|
||||||
|
if (!minify) return css;
|
||||||
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||||
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||||
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
|
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.
|
||||||
|
export function buildPage(name, { minify = true } = {}) {
|
||||||
|
const { script, src: files } = PAGES[name];
|
||||||
|
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
|
||||||
|
const js = swc.transformSync(src, {
|
||||||
|
filename: name + '.ts',
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: 'typescript' },
|
||||||
|
target: 'es2022',
|
||||||
|
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
|
||||||
|
// and the browser tests reach others (player, savePos) through page.evaluate.
|
||||||
|
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
|
||||||
|
},
|
||||||
|
isModule: false,
|
||||||
|
minify,
|
||||||
|
}).code;
|
||||||
|
const page = fs.readFileSync(path.join(here, name), 'utf8');
|
||||||
|
const marker = /<script data-src="[^"]*"><\/script>/;
|
||||||
|
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
|
||||||
|
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
|
||||||
|
// after the markup it wires up, before anything else.
|
||||||
|
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
||||||
|
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
||||||
|
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
||||||
|
if (!minify) return { html: out, js, script };
|
||||||
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
|
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||||
|
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
|
return { html: r.code, js, script };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const out = process.argv[2] || path.join(here, 'dist');
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||||
|
for (const name of Object.keys(PAGES)) {
|
||||||
|
const { html, js, script } = buildPage(name);
|
||||||
|
fs.writeFileSync(path.join(out, name), html);
|
||||||
|
fs.writeFileSync(path.join(out, script), js);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
2254
web/index.html
2254
web/index.html
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>Sign in — iPodderX</title>
|
<title>Sign in — iPodderX</title>
|
||||||
<link rel="icon" href="/icon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
/* Inter, from ipx itself; see the same rule in index.html. */
|
/* Inter, from ipx itself; see the same rule in index.html. */
|
||||||
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
@@ -11,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 */
|
||||||
@@ -21,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;
|
||||||
@@ -29,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{
|
||||||
@@ -76,20 +83,4 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
<p class="msg" id="msg"></p>
|
<p class="msg" id="msg"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script data-src="web/src"></script>
|
||||||
document.getElementById('f').onsubmit = async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const msg = document.getElementById('msg');
|
|
||||||
msg.textContent = '';
|
|
||||||
const r = await fetch('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: document.getElementById('name').value,
|
|
||||||
password: document.getElementById('pw').value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (r.ok) location.href = '/';
|
|
||||||
else msg.textContent = await r.text() || 'Sign in failed';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|||||||
204
web/src/admin.ts
Normal file
204
web/src/admin.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/* ---------------- admin page ---------------- */
|
||||||
|
// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's
|
||||||
|
// hash so a link can go straight to one. The server sends this page and this script to admins
|
||||||
|
// only, and refuses every call below to anyone else; they were parts of Settings and the header,
|
||||||
|
// shown or hidden by the main page's script (issue #19).
|
||||||
|
|
||||||
|
let me: {name: string} | null = null;
|
||||||
|
api('/api/me').then(u => { me = u; }).catch(() => {});
|
||||||
|
|
||||||
|
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
|
||||||
|
|
||||||
|
function showSection(){
|
||||||
|
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
||||||
|
for(const s of $$('#admin > section')) s.hidden = s.id !== t;
|
||||||
|
for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t);
|
||||||
|
// The log polls every two seconds while it is showing, and not otherwise.
|
||||||
|
if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; }
|
||||||
|
SECTIONS[t]();
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', showSection);
|
||||||
|
|
||||||
|
/* ---------------- server ---------------- */
|
||||||
|
async function drawServer(){
|
||||||
|
const box = $('#server');
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
const gs = splitEvery(g.every_mins);
|
||||||
|
box.innerHTML = `<h2>Server</h2>
|
||||||
|
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
|
||||||
|
many items a feed downloads for them, are in that feed's settings.</p>
|
||||||
|
<div class="field"><label>Check feeds every</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||||
|
<select id="gunit">${unitOptions(gs.u)}</select>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||||
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||||
|
<div class="field"><label>Max new downloads per scan, per feed</label>
|
||||||
|
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||||
|
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||||
|
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
|
||||||
|
catalogue the first time a feed is scanned.</span></div>
|
||||||
|
<div class="field"><label>Download these media types automatically</label>
|
||||||
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||||
|
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
||||||
|
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
||||||
|
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
||||||
|
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
||||||
|
<span class="hint">Over this, the oldest played items are deleted first. Pinned
|
||||||
|
items are never touched.</span></div>
|
||||||
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||||
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||||
|
<div class="field"><label>Download folder</label>
|
||||||
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
|
$('#gsave').onclick = async () => {
|
||||||
|
try{
|
||||||
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||||
|
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
|
||||||
|
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
||||||
|
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
|
max_total_gb: Number($('#gquota').value) || 0,
|
||||||
|
max_age_days: Number($('#gage').value) || 0})});
|
||||||
|
toast('Settings saved');
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- accounts ---------------- */
|
||||||
|
async function drawAccounts(){
|
||||||
|
const box = $('#accounts');
|
||||||
|
const users = await api('/api/users') || [];
|
||||||
|
box.innerHTML = `<h2>Accounts</h2>
|
||||||
|
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
||||||
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||||
|
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
|
||||||
|
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
|
||||||
|
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
|
||||||
|
<div class="field" style="margin-top:20px"><label>Add someone</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
||||||
|
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
||||||
|
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
||||||
|
through the proxy. New people start with no feeds.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
|
||||||
|
const change = async (u, opts) => {
|
||||||
|
try{
|
||||||
|
await api(`/api/users/${u.id}`, opts);
|
||||||
|
// Demoting yourself takes this page away; go back to the app rather than stay on a page
|
||||||
|
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
|
||||||
|
if(u.name === me?.name){ location.href = '/'; return; }
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||||
|
};
|
||||||
|
for(const row of $$('#accounts [data-id]')){
|
||||||
|
const u = users.find(x => String(x.id) === row.dataset.id);
|
||||||
|
$('[data-a="admin"]', row).onchange = e =>
|
||||||
|
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
||||||
|
$('[data-a="rm"]', row).onclick = () => {
|
||||||
|
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||||
|
change(u, {method: 'DELETE'});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$('#uadd').onclick = async () => {
|
||||||
|
try{
|
||||||
|
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
||||||
|
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
|
||||||
|
toast('Added'); drawAccounts();
|
||||||
|
}catch(e){ toast(e.message, true); } // keep what was typed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- log ---------------- */
|
||||||
|
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
|
||||||
|
// Which sources belong to each tab. "daemon" is the control protocol itself: every
|
||||||
|
// command in and every event out, whatever sent it.
|
||||||
|
const LOG_TABS = {
|
||||||
|
all: null,
|
||||||
|
daemon: t => t === 'ipx::io',
|
||||||
|
scan: t => t === 'ipx::scan',
|
||||||
|
web: t => t === 'ipx::http',
|
||||||
|
};
|
||||||
|
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||||
|
|
||||||
|
function drawLogView(){
|
||||||
|
logSeq = 0; logLines = [];
|
||||||
|
$('#log').innerHTML = `<h2>Log</h2>
|
||||||
|
<div class="logbar">
|
||||||
|
<div class="tabs" id="logtabs">
|
||||||
|
${Object.keys(LOG_TABS).map(t =>
|
||||||
|
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
|
||||||
|
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<select id="loglevel" style="width:auto">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="INFO">Info and above</option>
|
||||||
|
<option value="WARN">Warnings and errors</option>
|
||||||
|
<option value="ERROR">Errors only</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" id="logq" class="grow" placeholder="Filter…">
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
||||||
|
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||||||
|
</div>
|
||||||
|
<div id="logbox"><p class="empty">Loading…</p></div>
|
||||||
|
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
|
||||||
|
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
|
||||||
|
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||||
|
what it captures.</span>`;
|
||||||
|
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||||
|
logTab = b.dataset.t;
|
||||||
|
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||||
|
drawLog();
|
||||||
|
};
|
||||||
|
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
|
||||||
|
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
|
||||||
|
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||||
|
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
|
||||||
|
pollLog();
|
||||||
|
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLog(){
|
||||||
|
try{
|
||||||
|
const r = await api(`/api/logs?after=${logSeq}&limit=500`);
|
||||||
|
if(r.lines.length){
|
||||||
|
logLines = logLines.concat(r.lines).slice(-2000);
|
||||||
|
logSeq = r.latest;
|
||||||
|
drawLog();
|
||||||
|
}else if(!logLines.length){ drawLog(); }
|
||||||
|
}catch(e){
|
||||||
|
const box = $('#logbox');
|
||||||
|
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function visibleLog(){
|
||||||
|
const min = logLevel ? LEVELS[logLevel] : -1;
|
||||||
|
const tab = LOG_TABS[logTab];
|
||||||
|
return logLines.filter(l =>
|
||||||
|
(!tab || tab(l.target)) &&
|
||||||
|
(LEVELS[l.level] ?? 1) >= min &&
|
||||||
|
(!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter)));
|
||||||
|
}
|
||||||
|
function drawLog(){
|
||||||
|
const box = $('#logbox'); if(!box) return;
|
||||||
|
const follow = $('#logfollow')?.checked;
|
||||||
|
const rows = visibleLog();
|
||||||
|
box.innerHTML = rows.length ? rows.map(l => {
|
||||||
|
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
||||||
|
if(logTab === 'daemon'){
|
||||||
|
const out = l.msg.startsWith('<-');
|
||||||
|
return `<div class="l"><time>${t}</time>` +
|
||||||
|
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
|
||||||
|
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
|
||||||
|
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
|
||||||
|
}).join('') : '<p class="empty">Nothing matches.</p>';
|
||||||
|
if(follow) box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSection();
|
||||||
398
web/src/dialogs.ts
Normal file
398
web/src/dialogs.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
/* ---------------- modals ---------------- */
|
||||||
|
function openModal(html: string, wide?: boolean){
|
||||||
|
$('#modalCard').innerHTML=html;
|
||||||
|
$('#modalCard').classList.toggle('wide',!!wide);
|
||||||
|
$('#modal').classList.add('on');
|
||||||
|
}
|
||||||
|
function closeModal(){
|
||||||
|
$('#modal').classList.remove('on');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
|
||||||
|
|
||||||
|
$('#addFeed').onclick=()=>{
|
||||||
|
openModal(`<h3>Add a feed</h3>
|
||||||
|
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||||
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||||
|
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
||||||
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||||
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||||
|
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||||||
|
$('#nurl').focus();
|
||||||
|
$('#nsave').onclick=async()=>{
|
||||||
|
const url=$('#nurl').value.trim(); if(!url) return;
|
||||||
|
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
||||||
|
try{
|
||||||
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||||
|
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
||||||
|
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
||||||
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
|
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
|
||||||
|
// URL, so a key in someone's feed address never reaches this page.
|
||||||
|
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||||
|
async function listFeeds(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':NONE_LISTED;
|
||||||
|
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
|
||||||
|
/// parts are the same either way; the class lays them out.
|
||||||
|
function listedFeed(p,cls){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className=cls;
|
||||||
|
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||||
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||||
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||||
|
// Yours already: the row opens it instead.
|
||||||
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
||||||
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
|
try{
|
||||||
|
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
|
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(p.id);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
||||||
|
// otherwise clear them.
|
||||||
|
let dirKind='All', dirCat=null;
|
||||||
|
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||||
|
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
||||||
|
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
||||||
|
/// category, as chips). Both filter in place, without asking the server again.
|
||||||
|
async function renderDirectory(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
|
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
|
||||||
|
const bar=$('#dirbar');
|
||||||
|
// Only a filter when the server has both kinds.
|
||||||
|
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
|
||||||
|
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
|
||||||
|
const draw=()=>{
|
||||||
|
if(!both) dirKind='All';
|
||||||
|
const ofKind=rows.filter(KINDS[dirKind]);
|
||||||
|
// No empty chips: only the categories among the feeds the kind lets through.
|
||||||
|
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
||||||
|
if(!cats.includes(dirCat)) dirCat=null;
|
||||||
|
bar.innerHTML=
|
||||||
|
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
||||||
|
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
||||||
|
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
||||||
|
// the button just pressed.
|
||||||
|
for(const b of $$('button',bar)) b.onclick=()=>{
|
||||||
|
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
|
||||||
|
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
|
||||||
|
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
||||||
|
};
|
||||||
|
box.innerHTML='';
|
||||||
|
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory and Popular open in the main pane, as the original's Directory did.
|
||||||
|
async function renderListed(v){
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.add('plain');
|
||||||
|
$('#tbRemove').disabled=true;
|
||||||
|
syncTools(null);
|
||||||
|
$('#epSearch').placeholder='Search items…';
|
||||||
|
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
|
||||||
|
box.innerHTML=`
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${v.icon}</div>
|
||||||
|
<div class="meta"><h2>${v.title}</h2>
|
||||||
|
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
|
||||||
|
</div>
|
||||||
|
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
||||||
|
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
||||||
|
$('#count').textContent=v.title;
|
||||||
|
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
|
||||||
|
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||||
|
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||||
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||||
|
async function renderListening(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||||
|
for(const e of rows){
|
||||||
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
||||||
|
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.entry=e;
|
||||||
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
|
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
||||||
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
|
`<div class="rail"><i></i></div>`;
|
||||||
|
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
|
||||||
|
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
|
||||||
|
paintListenRow(el);
|
||||||
|
box.appendChild(el);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||||
|
function paintListenRow(el){
|
||||||
|
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
||||||
|
// Zero until the player has sought to where you left off; the saved position stands till then.
|
||||||
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||||
|
// The player's own length first: a feed's can be minutes out.
|
||||||
|
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
|
||||||
|
el.classList.toggle('now',now);
|
||||||
|
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
||||||
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
||||||
|
const rail=$('.rail',el); rail.hidden=!d;
|
||||||
|
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
|
||||||
|
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
|
||||||
|
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
|
||||||
|
}
|
||||||
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
||||||
|
function syncListening(){
|
||||||
|
for(const el of $$('#listening .childrow'))
|
||||||
|
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
|
||||||
|
/// episode with a saved position short of the end, so the position is what has to go.
|
||||||
|
async function forget(e){
|
||||||
|
// Closed without saving first, or the player's next save would put it straight back.
|
||||||
|
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
|
||||||
|
try{
|
||||||
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
|
{method:'POST',body:JSON.stringify({secs:0})});
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||||||
|
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||||||
|
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||||
|
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||||
|
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||||
|
let searchT;
|
||||||
|
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||||||
|
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
|
||||||
|
// Crossing the phone breakpoint moves the files between their pane and the text.
|
||||||
|
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||||||
|
|
||||||
|
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
||||||
|
function toggleGroup(id){
|
||||||
|
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||||||
|
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
||||||
|
renderFeeds();
|
||||||
|
}
|
||||||
|
let globalMax = 3;
|
||||||
|
function due(ts){
|
||||||
|
const d = ts - Date.now()/1000;
|
||||||
|
if(d <= 0) return 'due now';
|
||||||
|
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
||||||
|
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
|
||||||
|
return 'in '+Math.round(d/86400)+'d';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
||||||
|
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
||||||
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||||
|
async function prefsModal(){
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
const admin = !!(S.me&&S.me.admin);
|
||||||
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
|
<div class="field"><label>Theme</label>
|
||||||
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
|
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||||
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||||
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||||
|
<div class="field"><label>Subscriptions</label>
|
||||||
|
<div class="inline">
|
||||||
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||||||
|
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
|
||||||
|
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
|
subscribes you to every feed in one.</span></div>
|
||||||
|
<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.
|
||||||
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||||
|
<div class="field"><label>Download folder</label>
|
||||||
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
||||||
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
|
$('#gopml').onclick=opmlModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsModal(f, newUrl?: string){
|
||||||
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
|
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
||||||
|
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
||||||
|
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
||||||
|
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
||||||
|
config.toml, and it stops following the subscription's settings.</p>`:''}
|
||||||
|
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
||||||
|
Everyone else keeps their own.</p>
|
||||||
|
<div class="field"><label>Keywords</label>
|
||||||
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||||
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||||
|
<div class="field"><label>Max new downloads per scan</label>
|
||||||
|
<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
|
||||||
|
the next scan.</span></div>
|
||||||
|
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
||||||
|
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
||||||
|
<div class="field"><label>Feed URL</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||||
|
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">${S.me&&S.me.admin
|
||||||
|
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
||||||
|
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
||||||
|
on the next scan.`
|
||||||
|
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
||||||
|
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
||||||
|
<span class="hint">Where the files land. There is one copy however many people
|
||||||
|
subscribe, so this is the same for everyone.</span></div>`:''}
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
||||||
|
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
||||||
|
<datalist id="scats"></datalist>
|
||||||
|
<span class="hint">${f.feed_category
|
||||||
|
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
||||||
|
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||||
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||||
|
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
||||||
|
// than starting a second chip beside it.
|
||||||
|
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
||||||
|
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||||||
|
$('#ssave').onclick=async()=>{
|
||||||
|
const max=$('#smax').value;
|
||||||
|
try{
|
||||||
|
const patch: Record<string, unknown>={
|
||||||
|
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||||||
|
max_new_per_check:max===''?null:Number(max),
|
||||||
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||||
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||||
|
if(S.me&&S.me.admin){
|
||||||
|
patch.url=$('#surl').value.trim();
|
||||||
|
patch.folder=$('#sfolder').value.trim()||null;
|
||||||
|
patch.category=$('#scat').value.trim()||null;
|
||||||
|
}
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
|
closeModal(); toast('Saved — applies on the next scan');
|
||||||
|
await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadLatestModal(f){
|
||||||
|
openModal(`<h3>Download latest items</h3>
|
||||||
|
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||||
|
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||||
|
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
||||||
|
$('#dgo').onclick=async()=>{
|
||||||
|
const n=Number($('#dcount').value)||5;
|
||||||
|
try{
|
||||||
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
||||||
|
{method:'POST',body:JSON.stringify({count:n})});
|
||||||
|
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeed(f){
|
||||||
|
openModal(`<h3>Unsubscribe?</h3>
|
||||||
|
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
|
||||||
|
reading it keeps it, along with their own read state.
|
||||||
|
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||||
|
$('#rgo').onclick=async()=>{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||||
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||||
|
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function opmlModal(){
|
||||||
|
openModal(`<h3>OPML</h3>
|
||||||
|
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
|
||||||
|
<div class="field"><label>Export: save your subscriptions as OPML</label>
|
||||||
|
<div class="inline">
|
||||||
|
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
||||||
|
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
||||||
|
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||||
|
$('#oimp').onclick=async()=>{
|
||||||
|
// A chosen file is read here and sent as text, so the server never stores it. Clearing
|
||||||
|
// the picker lets go of it on this side too, whether it was refused or imported.
|
||||||
|
const pick=$('#opmlFile'), file=pick.files[0];
|
||||||
|
const xml=file ? await file.text() : $('#opmlText').value;
|
||||||
|
const letGo=()=>{ pick.value=''; };
|
||||||
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
||||||
|
if(!/<opml[\s>]/i.test(xml)){
|
||||||
|
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
||||||
|
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
|
||||||
|
}catch(e){ letGo(); toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
|
$('#scanAll').onclick=scanAll;
|
||||||
|
$('#prefs').onclick=prefsModal;
|
||||||
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
// the proxy still vouches for them. /api/me says where, when that is the case.
|
||||||
|
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||||||
|
api('/api/me').then(u=>{
|
||||||
|
S.me=u;
|
||||||
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
|
}).catch(()=>{});
|
||||||
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||||
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||||
|
$('#feedlist').onkeydown=ev=>{
|
||||||
|
const row=ev.target.closest('[data-id]'); if(!row) return;
|
||||||
|
const id=row.dataset.id;
|
||||||
|
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||||
|
else if(row.classList.contains('group')&&
|
||||||
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
|
else return;
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
};
|
||||||
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
|
$('#scrim').onclick=()=>nav(false);
|
||||||
|
|
||||||
48
web/src/events.ts
Normal file
48
web/src/events.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* ---------------- live events ---------------- */
|
||||||
|
let sse;
|
||||||
|
function connect(){
|
||||||
|
sse=new EventSource('/api/events');
|
||||||
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
|
const refreshFeeds=soon(()=>loadFeeds(true));
|
||||||
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
|
sse.onmessage=m=>{
|
||||||
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
|
if(ev.ev==='progress'){
|
||||||
|
const pct=ev.total?ev.done/ev.total*100:0;
|
||||||
|
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||||
|
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
||||||
|
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
||||||
|
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
|
||||||
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||||
|
}
|
||||||
|
else if(ev.ev==='download_done'){
|
||||||
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries(); refreshFeeds();
|
||||||
|
}
|
||||||
|
else if(ev.ev==='download_error'){
|
||||||
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
|
if(bar) bar.classList.remove('live');
|
||||||
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
|
}
|
||||||
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
|
else if(ev.ev==='feed_done'){
|
||||||
|
setScanning(ev.feed,false);
|
||||||
|
if(!mine(ev.feed)) return;
|
||||||
|
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
|
}
|
||||||
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
|
// red ! marks the feed instead, and its page says why.
|
||||||
|
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
||||||
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
||||||
|
};
|
||||||
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
loadFeeds();
|
||||||
185
web/src/feedpage.ts
Normal file
185
web/src/feedpage.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/* ---------------- feed page ---------------- */
|
||||||
|
function renderFeed(){
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.remove('plain');
|
||||||
|
const v=VIEWS[S.feed];
|
||||||
|
if(v&&v.url) return renderListed(v);
|
||||||
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
|
$('#tbRemove').disabled=!f;
|
||||||
|
syncTools(null);
|
||||||
|
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||||
|
const name=f?(f.title||f.id):v.title;
|
||||||
|
$('#epSearch').placeholder=`Search ${name}…`;
|
||||||
|
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||||
|
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
|
||||||
|
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
|
box.innerHTML = (f ? `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${artHTML(f.image,name)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(name)}</h2>
|
||||||
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||||
|
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||||
|
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
||||||
|
${failBannerHTML(f)}
|
||||||
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||||
|
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||||
|
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</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="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="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>
|
||||||
|
</div>
|
||||||
|
</div>` : `
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${v.icon}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${v.title}</h2>
|
||||||
|
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
|
||||||
|
subscribe to, newest first · ${unreadAll} unread</div>
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
||||||
|
</div>
|
||||||
|
</div>`) + `
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="tabs">
|
||||||
|
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
|
||||||
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const pane=document.createElement('div');
|
||||||
|
pane.id='split';
|
||||||
|
if(f) pane.className='one';
|
||||||
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||||
|
box.appendChild(pane);
|
||||||
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||||
|
dragSplit(pane);
|
||||||
|
// Nothing is open any more, so nothing is kept on the Unread tab for being open.
|
||||||
|
S.sel=null;
|
||||||
|
showDetail(null);
|
||||||
|
|
||||||
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||||
|
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||||
|
S.filter=b.dataset.f; S.offset=0;
|
||||||
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||||
|
renderFeed(); loadEntries();
|
||||||
|
});
|
||||||
|
if(f) wireFailBanner(box,f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item table's headings, each a button that sorts by its column. The first click goes the
|
||||||
|
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
|
||||||
|
const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
|
||||||
|
function sortHead(){
|
||||||
|
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
||||||
|
const on=S.sort.col===k;
|
||||||
|
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}${icon?' h-ic':''}" data-sort="${k}"`+
|
||||||
|
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
|
||||||
|
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||||
|
}).join('')+'<span></span></div>';
|
||||||
|
}
|
||||||
|
function sortBy(col){
|
||||||
|
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||||||
|
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
||||||
|
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||||||
|
S.offset=0; renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||||
|
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||||
|
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||||
|
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
|
||||||
|
|
||||||
|
function renderGroup(f,kids){
|
||||||
|
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||||
|
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||||
|
const gone=kids.filter(c=>c.orphaned).length;
|
||||||
|
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
||||||
|
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||||||
|
$('#content').innerHTML = `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${folderArt(f,kids)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||||
|
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||||
|
${failBannerHTML(f)}
|
||||||
|
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||||
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<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="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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
||||||
|
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="childlist" id="kidlist"></div>`;
|
||||||
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||||
|
wireFailBanner($('#content'),f);
|
||||||
|
const draw=()=>{
|
||||||
|
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||||
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
|
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||||
|
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||||
|
for(const c of rows){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||||
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||||
|
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||||
|
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||||||
|
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||||
|
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||||
|
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
||||||
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||||
|
el.onclick=()=>selectFeed(c.id);
|
||||||
|
box.appendChild(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$('#kidSearch').oninput=draw;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function feedAction(a,f){
|
||||||
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
|
if(a==='rm') removeFeed(f);
|
||||||
|
if(a==='pin'){
|
||||||
|
try{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
||||||
|
await loadFeeds(true); renderFeed();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
|
if(a==='settings') settingsModal(f);
|
||||||
|
if(a==='dl') downloadLatestModal(f);
|
||||||
|
}
|
||||||
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||||
|
async function allAction(a){
|
||||||
|
if(a==='scanall') return scanAll();
|
||||||
|
if(a==='readall'){
|
||||||
|
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
||||||
|
if(!n){ toast('Nothing unread'); return; }
|
||||||
|
// One click across every feed is a lot to take back, so this one asks first.
|
||||||
|
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/read-all',{method:'POST'});
|
||||||
|
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
146
web/src/feeds.ts
Normal file
146
web/src/feeds.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/* ---------------- feeds ---------------- */
|
||||||
|
async function loadFeeds(keepSel?: boolean){
|
||||||
|
S.feeds = await api('/api/feeds');
|
||||||
|
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||||
|
renderFeeds();
|
||||||
|
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||||||
|
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||||
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
|
if(!keepSel && S.feeds.length){
|
||||||
|
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||||
|
selectFeed(known ? S.feed : ':all');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||||
|
// server's alphabetical order still holds within each half.
|
||||||
|
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
|
||||||
|
// The original's source list opened with these, above the feeds. They are places, not feeds:
|
||||||
|
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||||
|
const VIEWS={
|
||||||
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
||||||
|
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
||||||
|
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
||||||
|
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
||||||
|
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
||||||
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeeds(){
|
||||||
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
|
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||||||
|
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
|
||||||
|
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||||
|
const done=()=>{
|
||||||
|
list.scrollTop=top;
|
||||||
|
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
|
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||||
|
};
|
||||||
|
list.innerHTML='';
|
||||||
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
|
const places=document.createElement('div');
|
||||||
|
places.className='places';
|
||||||
|
for(const [id,v] of Object.entries(VIEWS)){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='place'+(S.feed===id?' sel':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=id;
|
||||||
|
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||||
|
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||||
|
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||||
|
places.appendChild(el);
|
||||||
|
}
|
||||||
|
list.appendChild(places);
|
||||||
|
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||||
|
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||||
|
|
||||||
|
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
|
||||||
|
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
|
||||||
|
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||||
|
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||||
|
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
||||||
|
const order=[];
|
||||||
|
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
||||||
|
order.push([f,0]);
|
||||||
|
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||||
|
// opens them all, or matches inside a closed folder would be invisible.
|
||||||
|
if(expanded.has(f.id) || q)
|
||||||
|
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
|
||||||
|
}
|
||||||
|
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
|
||||||
|
// it is open.
|
||||||
|
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
|
||||||
|
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
|
||||||
|
for(const [f,depth] of order){
|
||||||
|
const kids=inside(f).length;
|
||||||
|
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
||||||
|
// taken from every feed it holds, not just the ones a filter left showing.
|
||||||
|
const mine=S.feeds.filter(c=>c.group===f.id);
|
||||||
|
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
||||||
|
const [unread,eps,saved]=mine.length
|
||||||
|
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||||
|
: [f.unread,f.entries,f.downloaded];
|
||||||
|
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||||
|
// reads fine; it is failing when any feed inside it is. The feed's own page says what
|
||||||
|
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||||
|
const bad=c=>c.failing?.reason||c.last_error;
|
||||||
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
||||||
|
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
|
const open = !!(kids && (expanded.has(f.id) || q));
|
||||||
|
el.innerHTML =
|
||||||
|
// The error mark hangs in the margin where a folder's triangle does. A folder already has
|
||||||
|
// its triangle there, so that turns red instead, and the feed inside shows the mark.
|
||||||
|
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`
|
||||||
|
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
||||||
|
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
||||||
|
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+
|
||||||
|
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||||
|
`</small></div>`+
|
||||||
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
|
list.appendChild(el);
|
||||||
|
}
|
||||||
|
paintScanning();
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
function selectFeed(id){
|
||||||
|
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||||
|
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||||
|
renderFeeds(); renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||||||
|
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||||
|
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||||
|
function failBannerHTML(f){
|
||||||
|
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||||
|
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||||
|
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||||
|
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function wireFailBanner(box,f){
|
||||||
|
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||||
|
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||||
|
}
|
||||||
|
|
||||||
82
web/src/gestures.ts
Normal file
82
web/src/gestures.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/* ---------------- touch gestures ---------------- */
|
||||||
|
// On a touch screen: pull the item list down from its top to check the feed for new items, and
|
||||||
|
// swipe the item you are reading left for the next one, right for the one before, or back to
|
||||||
|
// the list from the first. Touch events only, so a mouse never sets these off. Listened for on
|
||||||
|
// 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 SWIPE = 60; // px across that counts as a swipe
|
||||||
|
|
||||||
|
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
||||||
|
|
||||||
|
/// Something that scrolls sideways, or takes typing, keeps its own gestures: a wide code block
|
||||||
|
/// or table in a post, the player's seek bar, a text box.
|
||||||
|
function ownsSwipe(el: Element | null){
|
||||||
|
for(let n = el; n && n.id !== 'detail'; n = n.parentElement){
|
||||||
|
if(/^(INPUT|TEXTAREA|SELECT|AUDIO|VIDEO)$/.test(n.tagName)) return true;
|
||||||
|
if(n.scrollWidth > n.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the list has been pulled: a note at its top, growing with the pull and pushing the
|
||||||
|
/// items down, that says what letting go will do.
|
||||||
|
function pullShow(dy: number){
|
||||||
|
const list = $('#list'); if(!list) return;
|
||||||
|
const d = Math.round(Math.min(dy, PULL * 1.6) / 2);
|
||||||
|
let tip = $('#pulltip');
|
||||||
|
if(!d){ tip?.remove(); return; }
|
||||||
|
if(!tip){ tip = document.createElement('div'); tip.id = 'pulltip'; list.prepend(tip); }
|
||||||
|
tip.style.height = d + 'px';
|
||||||
|
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFeed(){
|
||||||
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
|
if(!f && S.feed !== ':all') return;
|
||||||
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
|
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
|
.catch(e => toast(e.message, true));
|
||||||
|
loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', ev => {
|
||||||
|
touch = null;
|
||||||
|
if(ev.touches.length !== 1 || $('#modal')?.classList.contains('on')) return;
|
||||||
|
const t = ev.touches[0], target = ev.target as Element;
|
||||||
|
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.
|
||||||
|
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', 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});
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', ev => {
|
||||||
|
if(!touch) return;
|
||||||
|
const t = ev.touches[0];
|
||||||
|
touch.dx = t.clientX - touch.x; touch.dy = t.clientY - touch.y;
|
||||||
|
if(touch.pane === 'list'){
|
||||||
|
// Only a pull that starts at the top and goes down; anything else is an ordinary scroll.
|
||||||
|
if(touch.dy < 0 || $('#list').scrollTop > 0){ pullShow(0); touch = null; return; }
|
||||||
|
pullShow(touch.dy);
|
||||||
|
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)){
|
||||||
|
touch = null; // scrolling the text, not swiping; the first few px
|
||||||
|
// decide nothing, being mostly jitter
|
||||||
|
}
|
||||||
|
}, {passive: false});
|
||||||
|
|
||||||
|
document.addEventListener('touchend', () => {
|
||||||
|
const g = touch; touch = null;
|
||||||
|
if(!g) return;
|
||||||
|
if(g.pane === 'list'){
|
||||||
|
pullShow(0);
|
||||||
|
if(g.dy >= PULL) refreshFeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; }
|
||||||
|
if(i > 0) stepEntry(-1); else showDetail(null);
|
||||||
|
});
|
||||||
|
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });
|
||||||
344
web/src/items.ts
Normal file
344
web/src/items.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
/* ---------------- items ---------------- */
|
||||||
|
let loadSeq=0;
|
||||||
|
async function loadEntries(append?: boolean){
|
||||||
|
// Directory and Popular list feeds, not items.
|
||||||
|
if(!S.feed || VIEWS[S.feed]?.url) return;
|
||||||
|
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
||||||
|
if(S.q) p.set('q',S.q);
|
||||||
|
const asked=performance.now(), seq=++loadSeq;
|
||||||
|
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||||
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||||
|
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
||||||
|
// Unread straight after could have that answer land last and replace the Unread one.
|
||||||
|
if(seq!==loadSeq) return;
|
||||||
|
S.total=r.total;
|
||||||
|
for(const e of r.entries){
|
||||||
|
const w=readWrites.get(readKey(e));
|
||||||
|
if(w && !(w.done<asked)) e.read=w.read;
|
||||||
|
}
|
||||||
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||||
|
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||||
|
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||||
|
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||||
|
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
|
||||||
|
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
||||||
|
const open=S.entries.find(e=>e.guid===S.sel);
|
||||||
|
if(open) entries=[open,...entries];
|
||||||
|
}
|
||||||
|
S.entries = entries;
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
function renderEntries(){
|
||||||
|
const box=$('#eps'); if(!box) return;
|
||||||
|
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
||||||
|
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
|
$('#count').textContent=
|
||||||
|
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||||
|
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||||
|
box.innerHTML='';
|
||||||
|
if(!S.entries.length){
|
||||||
|
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(const e of S.entries) box.appendChild(epEl(e));
|
||||||
|
syncPlayButtons();
|
||||||
|
if(pane) pane.scrollTop=top;
|
||||||
|
if(S.entries.length < S.total){
|
||||||
|
const b=document.createElement('button');
|
||||||
|
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
|
||||||
|
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
|
||||||
|
box.appendChild(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function epEl(e){
|
||||||
|
// An item may carry several files. The row summarises the one you would act on --
|
||||||
|
// the playable one, else anything already downloaded, else the first -- and says how
|
||||||
|
// many others there are; the pane below lists them all.
|
||||||
|
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
|
||||||
|
const has=!!(enc&&enc.path);
|
||||||
|
const playable=isPlayable(enc);
|
||||||
|
const others=e.enclosures.length-1;
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
|
||||||
|
el.dataset.guid=e.guid;
|
||||||
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
|
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||||
|
el.innerHTML=`
|
||||||
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||||
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||||
|
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
|
||||||
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<div class="body">
|
||||||
|
<span class="t">${esc(e.title||'(untitled)')}</span>
|
||||||
|
<div class="line">${[
|
||||||
|
num&&`<span>${num}</span>`,
|
||||||
|
left&&`<span>${left}</span>`,
|
||||||
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||||
|
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||||
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
||||||
|
<div class="file">
|
||||||
|
${enc?kindIcon(enc):''}
|
||||||
|
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||||||
|
</div>
|
||||||
|
<span class="size">${enc?mb(enc.length):''}</span>
|
||||||
|
<span class="date">${dateOf(e.published)}</span>
|
||||||
|
<div class="rowacts">
|
||||||
|
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
||||||
|
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
|
||||||
|
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
|
||||||
|
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||||
|
</div>`;
|
||||||
|
el.onclick=()=>selectEntry(e);
|
||||||
|
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
/// Whether the browser can play it. Having a file is not the same as being playable:
|
||||||
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
|
/// is just a broken player.
|
||||||
|
function isPlayable(x){
|
||||||
|
if(!x || !x.path) return false;
|
||||||
|
const m=(x.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||||
|
if(m) return false;
|
||||||
|
// No declared type: fall back to the file's extension.
|
||||||
|
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
|
||||||
|
.test((x.path||x.url||'').split('?')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
||||||
|
function kindOf(enc){
|
||||||
|
const m=(enc.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('image/')) return 'image';
|
||||||
|
if(m.startsWith('video/')) return 'video';
|
||||||
|
if(m.startsWith('audio/')) return 'audio';
|
||||||
|
if(m.includes('pdf')) return 'pdf';
|
||||||
|
if(m.includes('torrent')) return 'torrent';
|
||||||
|
const ext=(enc.url||'').split('?')[0].split('.').pop();
|
||||||
|
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
||||||
|
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
|
||||||
|
/// either way keeps the column lined up; the words are in its tooltip.
|
||||||
|
function kindIcon(enc){
|
||||||
|
const k=kindOf(enc);
|
||||||
|
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
|
||||||
|
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
||||||
|
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
||||||
|
: ['',k];
|
||||||
|
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||||
|
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||||
|
|
||||||
|
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||||
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||||
|
function swapRow(e){
|
||||||
|
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||||
|
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
||||||
|
/// before then answers with the old state: it put the dot back on an item just read, until
|
||||||
|
/// the next refresh took it off again (loadEntries).
|
||||||
|
const readWrites=new Map();
|
||||||
|
const readKey=e=>e.feed_id+'\n'+e.guid;
|
||||||
|
function setRead(e,read){
|
||||||
|
e.read=read;
|
||||||
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||||
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||||
|
{method:'POST',body:JSON.stringify({read})})
|
||||||
|
.then(()=>{ w.done=performance.now(); loadFeeds(true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||||
|
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||||
|
function markRead(e){
|
||||||
|
if(e.read) return;
|
||||||
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEntry(e){
|
||||||
|
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
||||||
|
// next happens to come along, which left a few read ones in the list for a while.
|
||||||
|
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
|
||||||
|
if(prev&&prev.read){
|
||||||
|
S.entries=S.entries.filter(x=>x!==prev); S.total--;
|
||||||
|
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
|
||||||
|
}
|
||||||
|
S.sel=e.guid;
|
||||||
|
markRead(e);
|
||||||
|
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
||||||
|
swapRow(e);
|
||||||
|
showDetail(e);
|
||||||
|
const d=$('#detail'); if(d) d.scrollTop=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drag the divider between the item list and the item text.
|
||||||
|
function dragSplit(pane){
|
||||||
|
const grab=$('#grab',pane);
|
||||||
|
if(!grab) return;
|
||||||
|
const move=ev=>{
|
||||||
|
const box=pane.getBoundingClientRect();
|
||||||
|
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||||
|
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||||
|
};
|
||||||
|
const stop=()=>{
|
||||||
|
document.removeEventListener('mousemove',move);
|
||||||
|
document.removeEventListener('mouseup',stop);
|
||||||
|
document.body.style.userSelect='';
|
||||||
|
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||||
|
};
|
||||||
|
grab.onmousedown=ev=>{
|
||||||
|
ev.preventDefault();
|
||||||
|
document.body.style.userSelect='none';
|
||||||
|
document.addEventListener('mousemove',move);
|
||||||
|
document.addEventListener('mouseup',stop);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which item the toolbar's play, read and keep buttons act on.
|
||||||
|
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
||||||
|
// the first. Key rows by feed as well if that ever happens.
|
||||||
|
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||||
|
function syncTools(e){
|
||||||
|
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||||
|
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||||
|
// The same icons as the item's own buttons beside its title, so the two never disagree.
|
||||||
|
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
|
||||||
|
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||||
|
if(e){
|
||||||
|
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||||
|
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||||
|
function showDetail(e){
|
||||||
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||||
|
// A pane that can only say "No files" gives its width to the list instead.
|
||||||
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||||
|
document.body.classList.toggle('reading',!!e);
|
||||||
|
syncTools(e);
|
||||||
|
if(!e){
|
||||||
|
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
||||||
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
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.
|
||||||
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||||
|
// it looked as if a downloaded file could not be deleted at all (issue #21).
|
||||||
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
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('');
|
||||||
|
box.innerHTML=`
|
||||||
|
<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>
|
||||||
|
<div class="dmeta">
|
||||||
|
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
||||||
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
||||||
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||||
|
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
|
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
|
||||||
|
<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>
|
||||||
|
${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>`:''}
|
||||||
|
</div>
|
||||||
|
${narrow?encs:''}
|
||||||
|
<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.
|
||||||
|
function encBox(x){
|
||||||
|
const size=x.length?mb(x.length):'';
|
||||||
|
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||||
|
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||||
|
const shared=f&&f.subscribers>1;
|
||||||
|
// Icons, with the words in the tooltip and for screen readers.
|
||||||
|
const delLabel=shared
|
||||||
|
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
||||||
|
: '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 saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||||
|
if(x.path && !isPlayable(x)){
|
||||||
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<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>
|
||||||
|
${saveBtn}
|
||||||
|
${delBtn}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if(x.path){
|
||||||
|
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
||||||
|
// started the bar as well, so the same file played twice at once.
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<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>
|
||||||
|
${saveBtn}
|
||||||
|
${delBtn}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
||||||
|
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
||||||
|
// through here, which would make ipx a fetch-anything relay.
|
||||||
|
const viewable = !isPlayable(x) && x.state !== 'pending';
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</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>`:''}
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function epAction(a: string, e, el, encId?: number){
|
||||||
|
// Swap the row in place and refresh the text below when it is the one being read.
|
||||||
|
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
|
||||||
|
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||||
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
|
try{
|
||||||
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||||
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||||
|
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
|
if(a==='get'){
|
||||||
|
if(!enc) return;
|
||||||
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
|
toast('Queued: '+(e.title||'item'));
|
||||||
|
}
|
||||||
|
if(a==='del'){
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
const shared=f&&f.subscribers>1;
|
||||||
|
if(!confirm(shared
|
||||||
|
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
|
||||||
|
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
|
||||||
|
+`and will not be downloaded again automatically.`
|
||||||
|
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
|
||||||
|
try{
|
||||||
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
||||||
|
}catch(err){
|
||||||
|
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
||||||
|
if(!/one copy of this file/.test(err.message)) throw err;
|
||||||
|
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
||||||
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
||||||
|
}
|
||||||
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||||
|
}
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
}
|
||||||
|
|
||||||
15
web/src/login.ts
Normal file
15
web/src/login.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
document.getElementById('f').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
msg.textContent = '';
|
||||||
|
const r = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: (document.getElementById('name') as HTMLInputElement).value,
|
||||||
|
password: (document.getElementById('pw') as HTMLInputElement).value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (r.ok) location.href = '/';
|
||||||
|
else msg.textContent = await r.text() || 'Sign in failed';
|
||||||
|
};
|
||||||
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});
|
||||||
|
}
|
||||||
197
web/src/player.ts
Normal file
197
web/src/player.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/* ---------------- player ---------------- */
|
||||||
|
const audio=$('#audio');
|
||||||
|
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
|
||||||
|
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||||
|
|
||||||
|
// Marked read when an item has actually been listened to -- at the end, or past 90%.
|
||||||
|
// NOT on play: doing that made the item vanish from the Unread list the instant
|
||||||
|
// you pressed play, which looks exactly like it went missing.
|
||||||
|
function markPlayed(){
|
||||||
|
if(!player.guid||player.marked) return;
|
||||||
|
player.marked=true;
|
||||||
|
const e=player.entry;
|
||||||
|
if(!e||e.read) return;
|
||||||
|
setRead(e,true).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||||
|
function play(e,enc=e.enclosures.find(isPlayable)){
|
||||||
|
if(!isPlayable(enc)){
|
||||||
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The same file carries on where it was; another of the item's files starts from its top.
|
||||||
|
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||||
|
// Every play button is a pause button for what is playing, as the player bar's is.
|
||||||
|
if(resuming && !audio.paused){ audio.pause(); return; }
|
||||||
|
if(!resuming){
|
||||||
|
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
|
||||||
|
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||||
|
audio.src=`/media/${enc.id}`;
|
||||||
|
audio.currentTime=0;
|
||||||
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
|
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||||||
|
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
||||||
|
$('#ptitle').textContent=e.title||'(untitled)';
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
$('#pfeed').textContent=f?(f.title||f.id):'';
|
||||||
|
$('#player').classList.add('on');
|
||||||
|
mediaSession(e,f);
|
||||||
|
player.marked=false;
|
||||||
|
}
|
||||||
|
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
function mediaSession(e,f){
|
||||||
|
if(!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
|
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||||
|
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
|
||||||
|
});
|
||||||
|
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
|
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||||
|
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
|
||||||
|
}
|
||||||
|
audio.addEventListener('timeupdate',()=>{
|
||||||
|
const d=audio.duration||player.entry?.duration||0;
|
||||||
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
|
$('#pdur').textContent=clock(d);
|
||||||
|
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||||
|
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||||
|
// that would write back whatever the list said, however old.
|
||||||
|
if(!audio.paused) player.moved=true;
|
||||||
|
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
|
||||||
|
// to wait for the next pause to be saved.
|
||||||
|
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
|
||||||
|
if(d && audio.currentTime/d >= 0.9) markPlayed();
|
||||||
|
});
|
||||||
|
function savePos(){
|
||||||
|
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
|
||||||
|
// a failed load, or a pause before the seek to where you left off -- wiped the position.
|
||||||
|
// Nor from a player nobody has played since it last saved: one left paused in another tab
|
||||||
|
// saved its older place as that tab reloaded, over where you had got to since.
|
||||||
|
if(!player.guid||!audio.readyState||!player.moved) return;
|
||||||
|
player.moved=false;
|
||||||
|
player.saveAt=audio.currentTime;
|
||||||
|
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
|
||||||
|
// The measured length stands in for one the feed left out: without it Currently Listening
|
||||||
|
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
|
||||||
|
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||||
|
navigator.sendBeacon?.(
|
||||||
|
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
|
||||||
|
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||||
|
}
|
||||||
|
audio.addEventListener('pause',savePos);
|
||||||
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||||
|
// body.playing is what sets the EQ bars moving.
|
||||||
|
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
|
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||||
|
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
|
||||||
|
|
||||||
|
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
|
||||||
|
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||||
|
function syncPlayButtons(){
|
||||||
|
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||||
|
const paint=(b,now,idle)=>{
|
||||||
|
const label=now?'Pause':idle;
|
||||||
|
if(b.title===label) return;
|
||||||
|
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
||||||
|
};
|
||||||
|
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
|
||||||
|
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
|
||||||
|
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
|
||||||
|
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||||
|
}
|
||||||
|
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||||
|
window.addEventListener('beforeunload',savePos);
|
||||||
|
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
|
$('#pback').onclick=()=>audio.currentTime-=15;
|
||||||
|
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||||
|
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
||||||
|
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||||
|
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||||
|
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||||
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
|
syncListening()};
|
||||||
|
(function restore(){
|
||||||
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
|
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||||
|
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('keydown',ev=>{
|
||||||
|
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
|
||||||
|
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
|
||||||
|
if(ev.key==='Escape'){closeModal();nav(false);return}
|
||||||
|
// The rest are single keys that would otherwise eat what you type.
|
||||||
|
if(/^(INPUT|TEXTAREA|SELECT)$/.test((ev.target as Element).tagName)) return;
|
||||||
|
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
|
||||||
|
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
|
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||||
|
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
||||||
|
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
|
||||||
|
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
|
||||||
|
// own shortcuts still work, or while a dialog is open.
|
||||||
|
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
|
||||||
|
let gAt=0;
|
||||||
|
function stepEntry(by){
|
||||||
|
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||||||
|
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||||||
|
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||||
|
selectEntry(e);
|
||||||
|
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
function stepFeed(by){
|
||||||
|
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
||||||
|
if(!id) return;
|
||||||
|
selectFeed(id);
|
||||||
|
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
const KEYS={
|
||||||
|
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
||||||
|
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||||
|
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||||
|
// they are disabled.
|
||||||
|
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
||||||
|
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||||||
|
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||||
|
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||||
|
'[':()=>matchMedia('(max-width:820px)').matches
|
||||||
|
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
|
||||||
|
'?':()=>keysModal(),
|
||||||
|
g:()=>{ gAt=Date.now(); },
|
||||||
|
};
|
||||||
|
function typed(ev){
|
||||||
|
// The second key of a g pair counts only if it follows within a second and a half.
|
||||||
|
const pair=Date.now()-gAt<1500; gAt=0;
|
||||||
|
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
||||||
|
if(!fn) return;
|
||||||
|
ev.preventDefault(); fn();
|
||||||
|
}
|
||||||
|
/// What ? shows: every key, grouped as Feedly's own list is.
|
||||||
|
function keysModal(){
|
||||||
|
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
|
||||||
|
const rows=[
|
||||||
|
['Go to'],
|
||||||
|
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
|
||||||
|
[g('l'),'Currently Listening'],[g('s'),'Settings'],
|
||||||
|
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
|
||||||
|
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
|
||||||
|
['Items'],
|
||||||
|
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
|
||||||
|
[k('Shift')+' '+k('A'),'Mark all read'],
|
||||||
|
['The selected item'],
|
||||||
|
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
|
||||||
|
[k('v'),'Open the original in a new tab'],
|
||||||
|
['The player'],
|
||||||
|
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
|
||||||
|
['Anywhere'],
|
||||||
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
|
];
|
||||||
|
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>`);
|
||||||
|
}
|
||||||
|
|
||||||
63
web/src/theme.ts
Normal file
63
web/src/theme.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/* ---------------- theme ---------------- */
|
||||||
|
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on
|
||||||
|
// the account, so it follows you to another browser or computer. The server writes it onto the
|
||||||
|
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The
|
||||||
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
|
// the system, so no palette is written twice.
|
||||||
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
|
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},
|
||||||
|
glass: {name: 'Glass', modes: true},
|
||||||
|
gruvbox: {name: 'Gruvbox', modes: true},
|
||||||
|
contrast: {name: 'High contrast', modes: true},
|
||||||
|
material: {name: 'Material', modes: true},
|
||||||
|
modern: {name: 'Modern', modes: true},
|
||||||
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
solarized: {name: 'Solarized', modes: true},
|
||||||
|
};
|
||||||
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
|
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
|
||||||
|
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
const theme = {name: 'modern', mode: 'dark'};
|
||||||
|
|
||||||
|
/// `save` for a choice made in Settings, which goes to the account; not for applying one.
|
||||||
|
function setTheme(name = theme.name, mode = theme.mode, save = false){
|
||||||
|
theme.name = THEMES[name] ? name : 'modern';
|
||||||
|
theme.mode = MODES[mode] ? mode : 'dark';
|
||||||
|
const both = THEMES[theme.name].modes;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = theme.name;
|
||||||
|
// A theme with one palette has it whatever the mode; both of those are light.
|
||||||
|
root.dataset.mode = !both ? 'light'
|
||||||
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
|
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||||
|
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||||
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
|
if(save) saveTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came,
|
||||||
|
/// several at once, a quick run through the list could reach the server out of order and
|
||||||
|
/// leave the account on a theme passed on the way.
|
||||||
|
let themeSaving = Promise.resolve();
|
||||||
|
function saveTheme(){
|
||||||
|
themeSaving = themeSaving
|
||||||
|
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
|
||||||
|
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
|
||||||
|
}
|
||||||
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
|
(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
|
||||||
|
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the
|
||||||
|
// account, goes up to it once, so nobody has to choose again.
|
||||||
|
let name: string | null = null, mode: string | null = null;
|
||||||
|
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||||
|
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||||
|
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||||
|
})();
|
||||||
176
web/src/util.ts
Normal file
176
web/src/util.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
||||||
|
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||||
|
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
||||||
|
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
|
||||||
|
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||||
|
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||||
|
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
||||||
|
// 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 ICON={
|
||||||
|
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
|
||||||
|
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
|
||||||
|
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
|
||||||
|
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
|
||||||
|
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
|
||||||
|
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
|
||||||
|
// for the outline's stroke, so the two draw the same size.
|
||||||
|
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
||||||
|
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||||||
|
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
||||||
|
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
||||||
|
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
||||||
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
|
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
|
||||||
|
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
|
||||||
|
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
|
||||||
|
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
|
||||||
|
video:fa('0 0 576 512','<path fill="currentColor" d="M96 64c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L96 64zM464 336l73.5 58.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2L464 176 464 336z"/>'), // solid/video
|
||||||
|
image:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm64 80a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM272 224c8.4 0 16.1 4.4 20.5 11.5l88 144c4.5 7.4 4.7 16.7 .5 24.3S368.7 416 360 416L88 416c-8.9 0-17.2-5-21.3-12.9s-3.5-17.5 1.6-24.8l56-80c4.5-6.4 11.8-10.2 19.7-10.2s15.2 3.8 19.7 10.2l26.4 37.8 61.4-100.5c4.4-7.1 12.1-11.5 20.5-11.5z"/>'), // solid/image
|
||||||
|
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
|
||||||
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
|
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
|
||||||
|
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
|
||||||
|
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
|
||||||
|
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
||||||
|
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
||||||
|
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||||||
|
};
|
||||||
|
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
||||||
|
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||||
|
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||||
|
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||||
|
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||||
|
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
||||||
|
|
||||||
|
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||||
|
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
|
||||||
|
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||||
|
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
|
||||||
|
return r.status===204?null:r.json().catch(()=>null);
|
||||||
|
}
|
||||||
|
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||||
|
// address it is undefined, so fall back to the old selection-based copy.
|
||||||
|
async function copyText(text,btn){
|
||||||
|
const flash=ok=>{
|
||||||
|
if(!btn) return;
|
||||||
|
// The button is an icon, so it is the markup that has to come back, not just its text.
|
||||||
|
const was=btn.innerHTML;
|
||||||
|
btn.textContent=ok?'Copied':'Failed';
|
||||||
|
setTimeout(()=>btn.innerHTML=was,1300);
|
||||||
|
};
|
||||||
|
try{
|
||||||
|
if(navigator.clipboard&&window.isSecureContext){
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}else{
|
||||||
|
const ta=document.createElement('textarea');
|
||||||
|
ta.value=text; ta.setAttribute('readonly','');
|
||||||
|
ta.style.cssText='position:fixed;top:-1000px;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select(); ta.setSelectionRange(0,ta.value.length);
|
||||||
|
const ok=document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
if(!ok) throw new Error('copy rejected');
|
||||||
|
}
|
||||||
|
flash(true);
|
||||||
|
}catch(e){
|
||||||
|
flash(false);
|
||||||
|
toast('Could not copy automatically — select the URL and copy it manually',true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg: string, bad?: boolean){
|
||||||
|
const t=document.createElement('div');
|
||||||
|
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||||
|
$('#toasts').appendChild(t);
|
||||||
|
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||||
|
}
|
||||||
|
const clock = s => {
|
||||||
|
s=Math.max(0,Math.floor(s||0));
|
||||||
|
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
|
||||||
|
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
const ago = t => {
|
||||||
|
if(!t) return 'never';
|
||||||
|
const d=(Date.now()/1000)-t;
|
||||||
|
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
|
||||||
|
if(d<86400) return Math.round(d/3600)+'h ago';
|
||||||
|
if(d<2592000) return Math.round(d/86400)+'d ago';
|
||||||
|
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
||||||
|
};
|
||||||
|
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
||||||
|
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
|
||||||
|
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||||
|
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||||
|
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||||
|
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
|
||||||
|
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
||||||
|
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
|
||||||
|
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||||
|
function tileHTML(name,cls){
|
||||||
|
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||||
|
}
|
||||||
|
function artHTML(url: string | null, name: string, cls?: string){
|
||||||
|
return url
|
||||||
|
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
|
: tileHTML(name,cls);
|
||||||
|
}
|
||||||
|
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||||
|
function folderArt(f,kids){
|
||||||
|
const art=kids.filter(c=>c.image).slice(0,4);
|
||||||
|
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||||
|
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||||
|
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||||
|
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||||
|
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
|
||||||
|
|
||||||
|
/* ---------------- state ---------------- */
|
||||||
|
const S = {
|
||||||
|
feeds:[],
|
||||||
|
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||||
|
// you were instead of jumping to the first feed alphabetically.
|
||||||
|
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||||
|
entries:[], total:0, offset:0,
|
||||||
|
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||||
|
q:'', sel:null, me:null,
|
||||||
|
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||||
|
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||||
|
||{col:'published',dir:'desc'},
|
||||||
|
};
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
|
||||||
|
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
|
||||||
|
|
||||||
|
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
|
||||||
|
function splitEvery(m){
|
||||||
|
if(!m) return {n:1, u:'h'};
|
||||||
|
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
|
||||||
|
return {n:m, u:'m'};
|
||||||
|
}
|
||||||
|
function unitOptions(sel){
|
||||||
|
return UNITS.map(([v,l]) =>
|
||||||
|
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
|
||||||
|
}
|
||||||
|
function everyText(m){
|
||||||
|
if(!m) return '\u2014';
|
||||||
|
const {n,u} = splitEvery(m);
|
||||||
|
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
|
||||||
|
return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user