Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 | |||
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 | |||
| bc53e0f730 | |||
| bd8f6ab855 | |||
| 611716d8b7 | |||
| a68bfb179b | |||
| 6bf1ad6b31 | |||
| d7f8f2df1d | |||
| 4927677e66 | |||
| 484aaa1849 | |||
| c99e17bd80 | |||
| e312f11bb1 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -5,11 +5,48 @@ 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.1] - 2026-09-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
|
||||||
|
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||||
|
"Downloaded" toast is only for a file on your screen.
|
||||||
|
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||||
|
- The Classic theme is listed in Settings as just "Classic".
|
||||||
|
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
|
||||||
|
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
|
||||||
|
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
|
||||||
|
drawing at desktop width.
|
||||||
|
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
|
||||||
|
- In Nordic's dark half, button and toolbar borders show.
|
||||||
|
- A zero unread count on the selected feed no longer disappears into the selection.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||||
|
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||||
|
across, everything in one go.
|
||||||
|
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||||
|
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||||
|
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||||
|
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||||
|
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||||
|
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||||
|
be opened by a 0.7 release first, which brings its tables up to date.
|
||||||
|
|
||||||
## [0.7.0] - 2026-09-18
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -487,6 +524,8 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
||||||
|
[0.8.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.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
|
||||||
|
|||||||
39
CLAUDE.md
39
CLAUDE.md
@@ -13,8 +13,9 @@ 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 |
|
||||||
@@ -125,10 +126,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
|
||||||
@@ -140,10 +149,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
|
||||||
|
|
||||||
@@ -154,9 +169,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
|
||||||
|
|||||||
875
Cargo.lock
generated
875
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.7.0"
|
version = "0.8.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -18,7 +18,7 @@ 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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -65,14 +66,14 @@ entry_state user_id, feed_id, guid, read, flagged, position
|
|||||||
```
|
```
|
||||||
|
|
||||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -88,8 +105,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 +125,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 +134,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
109
src/config.rs
109
src/config.rs
@@ -261,14 +261,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 +342,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 +435,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 {}
|
||||||
|
}
|
||||||
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));
|
||||||
|
|
||||||
|
|||||||
440
src/main.rs
440
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
213
src/web.rs
213
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,7 +27,6 @@ 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>,
|
||||||
}
|
}
|
||||||
@@ -107,16 +105,16 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
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");
|
||||||
@@ -130,7 +128,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();
|
||||||
@@ -141,7 +139,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,11 +153,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"
|
||||||
@@ -247,8 +245,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)
|
||||||
@@ -267,7 +265,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()
|
||||||
@@ -280,8 +278,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);
|
||||||
@@ -298,7 +296,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 [
|
||||||
@@ -320,7 +318,7 @@ 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);
|
||||||
let (theme, mode) = state.ctx.db.theme(user.id).unwrap_or_default();
|
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||||
}))
|
}))
|
||||||
@@ -343,7 +341,7 @@ async fn patch_me(
|
|||||||
if !theme_ok(&body.theme, &body.mode) {
|
if !theme_ok(&body.theme, &body.mode) {
|
||||||
return Err(ApiError::bad_request("not a theme"));
|
return Err(ApiError::bad_request("not a theme"));
|
||||||
}
|
}
|
||||||
state.ctx.db.set_theme(user.id, &body.theme, &body.mode)?;
|
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +376,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!({
|
||||||
@@ -409,7 +407,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`.
|
||||||
@@ -418,7 +416,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)
|
||||||
}
|
}
|
||||||
@@ -435,7 +433,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)
|
||||||
@@ -446,7 +444,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)
|
||||||
}
|
}
|
||||||
@@ -457,7 +455,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)
|
||||||
@@ -468,7 +466,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)
|
||||||
}
|
}
|
||||||
@@ -510,7 +508,7 @@ async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Res
|
|||||||
if !user.is_admin {
|
if !user.is_admin {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
|
||||||
}
|
}
|
||||||
@@ -584,7 +582,7 @@ const ADMIN_LINK: &str = "<a id=admin ";
|
|||||||
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's
|
/// 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.
|
/// 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 {
|
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse {
|
||||||
let theme = state.ctx.db.theme(user.id).unwrap_or_default();
|
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default();
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,16 +670,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)?;
|
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);
|
||||||
@@ -689,8 +687,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(),
|
||||||
@@ -740,7 +738,7 @@ 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),
|
pinned: pinned.contains(id),
|
||||||
});
|
});
|
||||||
@@ -800,13 +798,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> =
|
||||||
@@ -823,7 +821,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(),
|
||||||
@@ -844,7 +842,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))
|
||||||
}
|
}
|
||||||
@@ -854,7 +852,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))
|
||||||
}
|
}
|
||||||
@@ -870,10 +868,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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,7 +1122,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.
|
||||||
@@ -1133,11 +1131,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>,
|
||||||
@@ -1146,19 +1144,16 @@ 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?;
|
||||||
let mut sanitizer = feed_sanitizer();
|
let mut sanitizer = feed_sanitizer();
|
||||||
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(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
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 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1200,10 +1195,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(())
|
||||||
}
|
}
|
||||||
@@ -1217,14 +1212,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(
|
||||||
@@ -1232,10 +1227,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 })))
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1239,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1288,17 +1282,17 @@ async fn patch_feed(
|
|||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
|
// Pinning is yours alone too, and means nothing for a feed you do not subscribe to.
|
||||||
if let Some(on) = body.pinned
|
if let Some(on) = body.pinned
|
||||||
&& !state.ctx.db.set_pinned(user.id, &id, on)?
|
&& !state.ctx.db.set_pinned(user.id, &id, on).await?
|
||||||
{
|
{
|
||||||
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
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() {
|
||||||
@@ -1318,7 +1312,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1339,13 +1333,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 {
|
||||||
@@ -1381,12 +1375,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)
|
||||||
}
|
}
|
||||||
@@ -1398,14 +1391,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1415,12 +1408,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1438,10 +1430,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)
|
||||||
}
|
}
|
||||||
@@ -1456,12 +1448,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 })
|
||||||
@@ -1485,13 +1477,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,
|
||||||
@@ -1517,7 +1509,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,
|
||||||
@@ -1535,11 +1527,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)
|
||||||
@@ -1571,7 +1573,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 {
|
||||||
@@ -1596,7 +1598,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1608,12 +1610,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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1624,8 +1626,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 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1646,9 +1648,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 })
|
||||||
@@ -1666,7 +1668,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()),
|
||||||
@@ -1674,7 +1676,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;
|
||||||
@@ -1682,7 +1684,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());
|
||||||
@@ -1716,7 +1718,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;
|
||||||
}
|
}
|
||||||
@@ -1790,8 +1792,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await expect(page.locator('#admin')).toHaveCount(0);
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
||||||
// Asking for it anyway goes back to the app, and its script is refused.
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
@@ -1299,3 +1299,45 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|||||||
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge')).toBeVisible();
|
||||||
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
|
await page.locator('#scanAll').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
||||||
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
||||||
|
// Listen as the page does, then ask.
|
||||||
|
await piper.evaluate(() => {
|
||||||
|
window.started = []; window.done = false;
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
||||||
|
if (ev.ev === 'scan_done') window.done = true; };
|
||||||
|
});
|
||||||
|
await piper.waitForTimeout(500);
|
||||||
|
await piper.locator('#scanAll').click();
|
||||||
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
||||||
|
const started = await piper.evaluate(() => window.started);
|
||||||
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
||||||
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|||||||
39
web/app.css
39
web/app.css
@@ -32,12 +32,12 @@
|
|||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
--dim:#c4c4c8;
|
--dim:#c4c4c8;
|
||||||
--faint:#a0a0a7;
|
--faint:#a0a0a7;
|
||||||
--accent:#81d0ff;
|
--accent:#81d0ff;
|
||||||
--accent2:#3584e4;
|
--accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
|
||||||
--ink:#1d1d20;
|
--ink:#1d1d20;
|
||||||
--good:#78e9ab;
|
--good:#78e9ab;
|
||||||
--warn:#ffc252;
|
--warn:#ffc252;
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
--dim:#57575d;
|
--dim:#57575d;
|
||||||
--faint:#66666c;
|
--faint:#66666c;
|
||||||
--accent:#0461be;
|
--accent:#0461be;
|
||||||
--accent2:#3584e4;
|
--accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#00753a;
|
--good:#00753a;
|
||||||
--warn:#905400;
|
--warn:#905400;
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
--dim:#5c616c; /* fg */
|
--dim:#5c616c; /* fg */
|
||||||
--faint:#686d78;
|
--faint:#686d78;
|
||||||
--accent:#0060f0; /* link */
|
--accent:#0060f0; /* link */
|
||||||
--accent2:#fd7d00;
|
--accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#23794f;
|
--good:#23794f;
|
||||||
--warn:#a85400;
|
--warn:#a85400;
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
--dim:#3d3a33;
|
--dim:#3d3a33;
|
||||||
--faint:#5a564c;
|
--faint:#5a564c;
|
||||||
--accent:#1a5fae;
|
--accent:#1a5fae;
|
||||||
--accent2:#b58900;
|
--accent2:#957100; /* taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#216609;
|
--good:#216609;
|
||||||
--warn:#795a00;
|
--warn:#795a00;
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
--panel:#3b4252; /* nord1 */
|
--panel:#3b4252; /* nord1 */
|
||||||
--panel2:#434c5e; /* nord2 */
|
--panel2:#434c5e; /* nord2 */
|
||||||
--raise:#4c566a; /* nord3 */
|
--raise:#4c566a; /* nord3 */
|
||||||
--line:#434c5e;
|
--line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
|
||||||
--fg:#eceff4; /* nord6 */
|
--fg:#eceff4; /* nord6 */
|
||||||
--dim:#d8dee9; /* nord4 */
|
--dim:#d8dee9; /* nord4 */
|
||||||
--faint:#b4bccb;
|
--faint:#b4bccb;
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
--dim:#3b4252; /* nord1 */
|
--dim:#3b4252; /* nord1 */
|
||||||
--faint:#4c566a; /* nord3 */
|
--faint:#4c566a; /* nord3 */
|
||||||
--accent:#3a5e8a;
|
--accent:#3a5e8a;
|
||||||
--accent2:#b0674e;
|
--accent2:#ab644c;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#446632;
|
--good:#446632;
|
||||||
--warn:#7e590e;
|
--warn:#7e590e;
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
--accent2:#2a5db0;
|
--accent2:#2a5db0;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#237a23;
|
--good:#237a23;
|
||||||
--warn:#a15f00;
|
--warn:#9b5b00;
|
||||||
--bad:#c42b1c;
|
--bad:#c42b1c;
|
||||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||||
}
|
}
|
||||||
@@ -368,6 +368,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
}
|
}
|
||||||
.chev:hover{color:var(--fg)}
|
.chev:hover{color:var(--fg)}
|
||||||
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
.chev.bad,.chev.bad:hover{color:var(--bad)}
|
||||||
|
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
|
||||||
|
.feed.scanning .badge{display:none}
|
||||||
|
.feed.scanning::after{
|
||||||
|
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
|
||||||
|
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ipxspin{to{transform:rotate(360deg)}}
|
||||||
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
|
||||||
a child's included, which is why it moves left by the child's indent. */
|
a child's included, which is why it moves left by the child's indent. */
|
||||||
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
|
||||||
@@ -421,9 +428,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||||||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||||||
.tile:hover .txt b{color:var(--accent)}
|
.tile:hover .txt b{color:var(--accent)}
|
||||||
|
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
|
||||||
|
themes, where on the row's own ground they clear it. */
|
||||||
.tag{
|
.tag{
|
||||||
font-size:11px;font-weight:600;
|
font-size:11px;font-weight:600;
|
||||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
|
||||||
}
|
}
|
||||||
.art{
|
.art{
|
||||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||||
@@ -444,6 +453,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||||||
}
|
}
|
||||||
.badge.zero{background:var(--raise);color:var(--faint)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
|
/* A selected row is --raise too, and the zero count's pill vanished into it. */
|
||||||
|
.sel .badge.zero{background:var(--bg)}
|
||||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||||||
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
@@ -690,6 +701,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
|
||||||
.logbar .grow{flex:1;min-width:120px}
|
.logbar .grow{flex:1;min-width:120px}
|
||||||
.card h3{margin:0 0 14px;font-size:17px}
|
.card h3{margin:0 0 14px;font-size:17px}
|
||||||
|
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
|
||||||
|
at the foot of a long card. */
|
||||||
|
.cardx{float:right;margin:-5px -6px 0 8px}
|
||||||
.field{display:grid;gap:4px;margin-bottom:12px}
|
.field{display:grid;gap:4px;margin-bottom:12px}
|
||||||
.field label{font-size:12px;color:var(--dim)}
|
.field label{font-size:12px;color:var(--dim)}
|
||||||
.field .hint{font-size:11.5px;color:var(--faint)}
|
.field .hint{font-size:11.5px;color:var(--faint)}
|
||||||
@@ -838,7 +852,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
:root[data-theme="classic"] .ep.sel .st,
|
:root[data-theme="classic"] .ep.sel .st,
|
||||||
:root[data-theme="classic"] .ep.sel .fl,
|
:root[data-theme="classic"] .ep.sel .fl,
|
||||||
:root[data-theme="classic"] .ep.sel .file,
|
:root[data-theme="classic"] .ep.sel .file,
|
||||||
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
|
:root[data-theme="classic"] .ep.sel .kind,
|
||||||
|
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
|
||||||
/* Green and red stay green and red on the blue, just lighter so they read. */
|
/* Green and red stay green and red on the blue, just lighter so they read. */
|
||||||
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
|
||||||
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
<title>Sign in — iPodderX</title>
|
<title>Sign in — iPodderX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
@@ -22,7 +27,8 @@
|
|||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(6,10,16,.55);
|
||||||
--r:10px;
|
--r:10px;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] {
|
/* Signed out, there is no account to take a theme from, so this follows the system. */
|
||||||
|
@media (prefers-color-scheme:light){:root {
|
||||||
--bg:#f2f4f7;
|
--bg:#f2f4f7;
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
--panel:#ffffff; /* #FFFFFF device body */
|
||||||
--panel2:#e9edf3;
|
--panel2:#e9edf3;
|
||||||
@@ -30,15 +36,15 @@
|
|||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--line:#d6d6d6; /* #D6D6D6 device edge */
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
||||||
--dim:#606060; /* #606060 */
|
--dim:#606060; /* #606060 */
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--faint:#6b6b6b;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
||||||
--accent2:#b06f10;
|
--accent2:#985e0a;
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a;
|
||||||
--bad:#b3402f;
|
--bad:#b3402f;
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||||
}
|
}}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ function due(ts){
|
|||||||
async function prefsModal(){
|
async function prefsModal(){
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const admin = !!(S.me&&S.me.admin);
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<h3>Settings</h3>
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
<div class="field"><label>Theme</label>
|
<div class="field"><label>Theme</label>
|
||||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
@@ -238,8 +238,7 @@ async function prefsModal(){
|
|||||||
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
|
||||||
<div class="field"><label>Download folder</label>
|
<div class="field"><label>Download folder</label>
|
||||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#gopml').onclick=opmlModal;
|
||||||
@@ -371,7 +370,8 @@ function opmlModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#prefs').onclick=prefsModal;
|
||||||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ function connect(){
|
|||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshFeeds=soon(()=>loadFeeds(true));
|
const refreshFeeds=soon(()=>loadFeeds(true));
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
let fresh={};
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const tellNew=soon(()=>{
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
|
|
||||||
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
|
|
||||||
fresh={};
|
|
||||||
},900);
|
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
if(ev.ev==='progress'){
|
if(ev.ev==='progress'){
|
||||||
@@ -23,22 +19,28 @@ function connect(){
|
|||||||
}
|
}
|
||||||
else if(ev.ev==='download_done'){
|
else if(ev.ev==='download_done'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries(); refreshFeeds();
|
||||||
}
|
}
|
||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||||
}
|
}
|
||||||
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
else if(ev.ev==='feed_done'){
|
else if(ev.ev==='feed_done'){
|
||||||
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
|
setScanning(ev.feed,false);
|
||||||
|
if(!mine(ev.feed)) return;
|
||||||
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
}
|
}
|
||||||
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
// red ! marks the feed instead, and its page says why.
|
// red ! marks the feed instead, and its page says why.
|
||||||
else if(ev.ev==='feed_error') refreshFeeds();
|
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
|
||||||
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function renderGroup(f,kids){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function feedAction(a,f){
|
async function feedAction(a,f){
|
||||||
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
if(a==='rm') removeFeed(f);
|
if(a==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ const VIEWS={
|
|||||||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop;
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
@@ -104,6 +120,7 @@ function renderFeeds(){
|
|||||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
}
|
}
|
||||||
|
paintScanning();
|
||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function selectFeed(id){
|
function selectFeed(id){
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ function pullShow(dy: number){
|
|||||||
function refreshFeed(){
|
function refreshFeed(){
|
||||||
const f = S.feeds.find(x => x.id === S.feed);
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
if(!f && S.feed !== ':all') return;
|
if(!f && S.feed !== ':all') return;
|
||||||
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
|
|
||||||
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
.catch(e => toast(e.message, true));
|
.catch(e => toast(e.message, true));
|
||||||
|
|||||||
@@ -191,8 +191,7 @@ function keysModal(){
|
|||||||
['Anywhere'],
|
['Anywhere'],
|
||||||
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
];
|
];
|
||||||
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||||||
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// the system, so no palette is written twice.
|
// the system, so no palette is written twice.
|
||||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
modern: {name: 'Modern', modes: true},
|
modern: {name: 'Modern', modes: true},
|
||||||
classic: {name: 'Classic, the 2004 Mac app', modes: false},
|
classic: {name: 'Classic', modes: false},
|
||||||
dracula: {name: 'Dracula', modes: true},
|
dracula: {name: 'Dracula', modes: true},
|
||||||
material: {name: 'Material', modes: true},
|
material: {name: 'Material', modes: true},
|
||||||
adwaita: {name: 'Adwaita', modes: true},
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const ICON={
|
|||||||
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
|
|
||||||
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
@@ -40,7 +39,6 @@ const ICON={
|
|||||||
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
|
|
||||||
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
|
|||||||
Reference in New Issue
Block a user