User admin in the web UI, admin-only log, unread-first OPML feeds
- Settings > Manage users: add an account (password, or none for proxy
sign-in), toggle admin, remove. Backed by GET/POST /api/users and
PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
the ipodderx service of the Arcane project content; CLAUDE.md and the
README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
non-admin and the unread ordering (new Aardvark Radio fixture); a unit
test for last_admin; the smoke test drives usersModal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -5,6 +5,48 @@ matters, what was wrong before -- the reasoning is the point, not the diff.
|
|||||||
|
|
||||||
See [README.md](README.md) for what the thing is, and [docs/](docs/) for how to run it.
|
See [README.md](README.md) for what the thing is, and [docs/](docs/) for how to run it.
|
||||||
|
|
||||||
|
## 2026-09-11 — The log is admin-only
|
||||||
|
|
||||||
|
`GET /api/logs` now returns `403` to anyone who is not an admin, and the page hides the Log button
|
||||||
|
from them. Before this, every signed-in person could read the whole log. That includes every
|
||||||
|
account's name, every feed anyone subscribes to, and every failed sign-in. `/api/events` stays open
|
||||||
|
to everyone, because it carries the scan progress each person's page shows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 — Managing users from the web
|
||||||
|
|
||||||
|
Settings has a **Manage users…** screen for an admin. From it you can add someone, with a password
|
||||||
|
or with none for someone the proxy signs in, tick or untick Admin, and remove an account. It is
|
||||||
|
backed by `GET/POST /api/users` and `PATCH/DELETE /api/users/{id}`, which return `403` for anyone
|
||||||
|
who is not an admin. The only admin cannot be demoted or removed, because nobody would then be
|
||||||
|
able to manage accounts except from the CLI on the box. Before this, accounts could only be managed
|
||||||
|
with `ipx user`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 — Unread feeds first inside an OPML
|
||||||
|
|
||||||
|
An OPML subscription's feeds, both in the sidebar folder and on its own page, now list the ones with
|
||||||
|
unread items first. They were listed alphabetically, so with dozens of feeds the few with anything
|
||||||
|
new were scattered through the list. Within each half the order is still alphabetical. The browser
|
||||||
|
suite's fixture OPML gained a second feed, Aardvark Radio, which sorts first by name and by position,
|
||||||
|
so the new test only passes if unread wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 — Deploying is a Docker image
|
||||||
|
|
||||||
|
Production moved from a hand-started daemon in code-server to the `iPodderX` container in the Arcane
|
||||||
|
project `content`. `CLAUDE.md` now deploys by pushing to the registry at `192.168.1.130:5000` and
|
||||||
|
recreating that one service with `docker compose`. The old instructions copied a binary over a
|
||||||
|
process nobody supervised, so it did not come back after a reboot. Two known gaps are gone: the
|
||||||
|
image does support accounts, and the entrypoint drops to `99:100`, so downloads are no longer
|
||||||
|
owned by root. The README's Docker section said `docker compose up -d` builds the image and named the
|
||||||
|
service `ipx`; the compose file pulls from the registry and the service is `ipodderx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-11 — Documentation
|
## 2026-09-11 — Documentation
|
||||||
|
|
||||||
`PROGRESS.md` became this changelog; the finished step lists moved to an appendix. The README is now
|
`PROGRESS.md` became this changelog; the finished step lists moved to an appendix. The README is now
|
||||||
|
|||||||
45
CLAUDE.md
45
CLAUDE.md
@@ -6,31 +6,37 @@ made here.
|
|||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
The live install on this machine:
|
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
|
||||||
|
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||||
|
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||||
|
|
||||||
| | |
|
| | Host | In the container |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| Binary | `/config/.cargo/bin/ipx` |
|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
||||||
| Config | `/config/.config/ipx/config.toml` |
|
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
||||||
| Database | `/config/.local/share/ipx/state.db` |
|
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
||||||
| Downloads | `/mnt/user/audio/ipx` |
|
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
||||||
| Web UI | `0.0.0.0:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
|
|
||||||
Deploying a change is: build, stop, copy, start.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --release
|
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
||||||
pkill -x ipx; sleep 2
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
|
||||||
cp target/release/ipx /config/.cargo/bin/ipx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
|
||||||
setsid nohup /config/.cargo/bin/ipx --config /config/.config/ipx/config.toml daemon \
|
docker logs --tail 20 iPodderX
|
||||||
>/tmp/ipx.log 2>&1 </dev/null &
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`pkill -x ipx`, never `pkill -f ipx`.** `-f` matches the shell running the command and kills the
|
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||||
session (exit 144). This has happened more than once.
|
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||||
|
|
||||||
The daemon is not supervised: it will not survive a reboot. `contrib/` has a systemd unit nobody
|
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
||||||
has installed.
|
the worker is alive, not just the web port. The container restarts on its own after a reboot.
|
||||||
|
|
||||||
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
|
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
||||||
|
the shell running the command and kills the session (exit 144). This has happened more than once.
|
||||||
|
|
||||||
## Before you touch the page
|
## Before you touch the page
|
||||||
|
|
||||||
@@ -110,10 +116,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* No user administration in the web UI; `ipx user` on the box only.
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
(documented in [docs/sso.md](docs/sso.md)).
|
||||||
* The Docker image predates multi-user; `docker compose build` before relying on it.
|
|
||||||
* Downloads land root-owned; Unraid shares want `99:100`.
|
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
||||||
Substack-style subtitles.
|
Substack-style subtitles.
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -85,11 +85,15 @@ never left behind with nothing explaining where it came from.
|
|||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d # builds the image and starts it
|
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
||||||
docker compose logs -f ipx # the token is printed on first start
|
docker compose pull ipodderx && docker compose up -d ipodderx
|
||||||
|
docker compose logs -f ipodderx # the first start prints the default admin password
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` mounts `./config`, `./data` and a downloads directory, publishes 8099 for the
|
`docker-compose.yml` runs the image from the registry above rather than building it, so build and
|
||||||
|
push first; change the tag in both places to use another registry. It mounts `/config` (config.toml),
|
||||||
|
`/data` (state.db) and `/downloads` from this install's host paths, which you will want to change for
|
||||||
|
yours. It publishes 8099 for the
|
||||||
UI and 6881 (TCP **and** UDP -- DHT needs the UDP side), and sets `PUID`/`PGID` to `99:100` so files
|
UI and 6881 (TCP **and** UDP -- DHT needs the UDP side), and sets `PUID`/`PGID` to `99:100` so files
|
||||||
land owned the way Unraid shares expect. The healthcheck runs `ipx status` through the control
|
land owned the way Unraid shares expect. The healthcheck runs `ipx status` through the control
|
||||||
socket, so it catches a daemon that is alive but wedged rather than merely one that has died.
|
socket, so it catches a daemon that is alive but wedged rather than merely one that has died.
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ else a `401`.
|
|||||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch`, `GET /api/opml`, `POST /api/opml` | |
|
| `POST /api/fetch`, `GET /api/opml`, `POST /api/opml` | |
|
||||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||||
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
| `GET /api/logs` | the ring buffer, with a sequence cursor |
|
| `GET /api/logs` | admin-only; the ring buffer, with a sequence cursor |
|
||||||
| `GET /media/{id}` | the file, with Range support so seeking works |
|
| `GET /media/{id}` | the file, with Range support so seeking works |
|
||||||
|
|
||||||
Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side
|
Show notes are feed-supplied HTML from an untrusted source, sanitized with `ammonia` server-side
|
||||||
|
|||||||
@@ -70,13 +70,17 @@ list with their own read state. Unsubscribing removes it from their list alone;
|
|||||||
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
|
||||||
re-subscribing does not pull the back catalogue again.
|
re-subscribing does not pull the back catalogue again.
|
||||||
|
|
||||||
Accounts are managed from the command line only; there is no user administration in the web UI yet.
|
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
||||||
|
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
||||||
|
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
||||||
|
removed there, so there is always someone who can manage the rest.
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
The first account is an admin. An admin can change global settings (scanning interval, quota,
|
The first account is an admin. An admin can change global settings (scanning interval, quota,
|
||||||
retention, media types, download folder) and a feed's URL, folder and schedule. Everyone else gets
|
retention, media types, download folder), a feed's URL, folder and schedule, and who has an account
|
||||||
the Settings button hidden and a `403` if they ask anyway.
|
and who else is an admin, and read the log, which names everyone's feeds and sign-ins. Everyone else
|
||||||
|
gets the Settings and Log buttons hidden and a `403` if they ask anyway.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ipx user list # the admin column says who
|
ipx user list # the admin column says who
|
||||||
|
|||||||
137
src/web.rs
137
src/web.rs
@@ -74,6 +74,8 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/fetch", post(fetch_now))
|
.route("/api/fetch", post(fetch_now))
|
||||||
.route("/api/opml", get(export_opml).post(import_opml))
|
.route("/api/opml", get(export_opml).post(import_opml))
|
||||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||||
|
.route("/api/users", get(list_users).post(add_user))
|
||||||
|
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||||
.route("/api/logs", get(logs))
|
.route("/api/logs", get(logs))
|
||||||
.route("/api/events", get(events))
|
.route("/api/events", get(events))
|
||||||
.route("/media/{id}", get(media))
|
.route("/media/{id}", get(media))
|
||||||
@@ -303,6 +305,125 @@ async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
|||||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- accounts: admin only ----
|
||||||
|
|
||||||
|
fn require_admin(user: &crate::db::User) -> Result<(), ApiError> {
|
||||||
|
if user.is_admin {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::forbidden("only an admin manages accounts"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demoting or removing this account would leave nobody able to manage anyone, and the only
|
||||||
|
/// way back would be `ipx user` on the box.
|
||||||
|
fn last_admin(users: &[crate::db::User], id: i64) -> bool {
|
||||||
|
let admins: Vec<i64> = users.iter().filter(|u| u.is_admin).map(|u| u.id).collect();
|
||||||
|
admins == [id]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_users(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let users: Vec<_> = state
|
||||||
|
.ctx
|
||||||
|
.db
|
||||||
|
.users()?
|
||||||
|
.iter()
|
||||||
|
.map(|u| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": u.id, "name": u.name, "admin": u.is_admin, "password": u.pass_hash.is_some(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(serde_json::json!(users)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NewUser {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Json(body): Json<NewUser>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
require_admin(&user)?;
|
||||||
|
// The same rules as a name a proxy vouches for, so either way of signing in finds it.
|
||||||
|
let name = crate::auth::name_from_header(&body.name).ok_or_else(|| {
|
||||||
|
ApiError::bad_request("a name is required, without commas, semicolons or line breaks")
|
||||||
|
})?;
|
||||||
|
if state.ctx.db.user_by_name(&name)?.is_some() {
|
||||||
|
return Err(ApiError::bad_request(format!("{name} already exists")));
|
||||||
|
}
|
||||||
|
// No password is someone the proxy signs in, as with `ipx user add --no-password`.
|
||||||
|
let hash = if body.password.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(crate::auth::hash_password(&body.password).map_err(|e| ApiError::bad_request(format!("{e:#}")))?)
|
||||||
|
};
|
||||||
|
state.ctx.db.create_user(&name, hash.as_deref(), body.admin)?;
|
||||||
|
tracing::info!(by = %user.name, user = %name, admin = body.admin, "account added");
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserPatch {
|
||||||
|
admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_user(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<UserPatch>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let users = state.ctx.db.users()?;
|
||||||
|
let target = users
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.id == id)
|
||||||
|
.ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?;
|
||||||
|
if !body.admin && last_admin(&users, id) {
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"{} is the only admin; make someone else an admin first",
|
||||||
|
target.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
state.ctx.db.set_admin(id, body.admin)?;
|
||||||
|
tracing::info!(by = %user.name, user = %target.name, admin = body.admin, "admin changed");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_user(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let users = state.ctx.db.users()?;
|
||||||
|
let target = users
|
||||||
|
.iter()
|
||||||
|
.find(|u| u.id == id)
|
||||||
|
.ok_or_else(|| ApiError::bad_request(format!("no account with id {id}")))?;
|
||||||
|
if last_admin(&users, id) {
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"{} is the only admin; make someone else an admin first",
|
||||||
|
target.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
state.ctx.db.delete_user(id)?;
|
||||||
|
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
async fn login_page() -> Html<&'static str> {
|
async fn login_page() -> Html<&'static str> {
|
||||||
Html(include_str!("../web/login.html"))
|
Html(include_str!("../web/login.html"))
|
||||||
}
|
}
|
||||||
@@ -473,6 +594,14 @@ impl IntoResponse for ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_last_admin_is_protected() {
|
||||||
|
let u = |id, is_admin| crate::db::User { id, name: format!("u{id}"), pass_hash: None, is_admin };
|
||||||
|
assert!(last_admin(&[u(1, true), u(2, false)], 1));
|
||||||
|
assert!(!last_admin(&[u(1, true), u(2, true)], 1), "another admin remains");
|
||||||
|
assert!(!last_admin(&[u(1, true), u(2, false)], 2), "not an admin at all");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_comparison_rejects_mismatches_and_length_differences() {
|
fn token_comparison_rejects_mismatches_and_length_differences() {
|
||||||
assert!(constant_time_eq("abc123", "abc123"));
|
assert!(constant_time_eq("abc123", "abc123"));
|
||||||
@@ -1161,9 +1290,13 @@ struct LogPage {
|
|||||||
latest: u64,
|
latest: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn logs(Query(q): Query<LogQuery>) -> Json<LogPage> {
|
async fn logs(user: crate::db::User, Query(q): Query<LogQuery>) -> Result<Json<LogPage>, ApiError> {
|
||||||
|
// The log names every account, every feed and every failed sign-in, not just yours.
|
||||||
|
if !user.is_admin {
|
||||||
|
return Err(ApiError::forbidden("only an admin reads the log"));
|
||||||
|
}
|
||||||
let (lines, latest) = crate::logbuf::since(q.after, q.limit.clamp(1, 2000));
|
let (lines, latest) = crate::logbuf::since(q.after, q.limit.clamp(1, 2000));
|
||||||
Json(LogPage { lines, latest })
|
Ok(Json(LogPage { lines, latest }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per HTTP request, so the web side shows up in the same log as the daemon.
|
/// One line per HTTP request, so the web side shows up in the same log as the daemon.
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const ctx = {
|
|||||||
json: () => Promise.resolve(
|
json: () => Promise.resolve(
|
||||||
String(url).includes('/api/settings')
|
String(url).includes('/api/settings')
|
||||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||||
|
: String(url).includes('/api/users')
|
||||||
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||||
: []),
|
: []),
|
||||||
}),
|
}),
|
||||||
EventSource: function () { this.close = () => {}; },
|
EventSource: function () { this.close = () => {}; },
|
||||||
@@ -79,6 +81,7 @@ const drive = [
|
|||||||
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
||||||
['removeFeed', () => ctx.removeFeed(feed)],
|
['removeFeed', () => ctx.removeFeed(feed)],
|
||||||
['prefsModal', () => ctx.prefsModal()],
|
['prefsModal', () => ctx.prefsModal()],
|
||||||
|
['usersModal', () => ctx.usersModal()],
|
||||||
['logsModal', () => ctx.logsModal()],
|
['logsModal', () => ctx.logsModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||||
// of a vm context the way var and function declarations do.
|
// of a vm context the way var and function declarations do.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
test('the page loads and lists the configured feeds', async ({ page }) => {
|
test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||||
// Three top-level feeds in the fixture config; the OPML's child is inside a closed folder.
|
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||||
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
|
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
|
||||||
await expect(page.getByText('Test Show')).toBeVisible();
|
await expect(page.getByText('Test Show')).toBeVisible();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -178,16 +178,50 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
|||||||
const chev = page.locator('.feed.group .chev');
|
const chev = page.locator('.feed.group .chev');
|
||||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
// Closed by default: the child is not listed until the folder is opened.
|
// Closed by default: the children are not listed until the folder is opened.
|
||||||
const before = await page.locator('.feed').count();
|
const before = await page.locator('.feed').count();
|
||||||
await chev.click();
|
await chev.click();
|
||||||
await expect(page.locator('.feed')).toHaveCount(before + 1);
|
await expect(page.locator('.feed')).toHaveCount(before + 2);
|
||||||
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
||||||
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
||||||
|
|
||||||
// The subscription's own page lists what is inside it.
|
// The subscription's own page lists what is inside it.
|
||||||
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||||
await expect(page.locator('.childrow')).toHaveCount(1);
|
await expect(page.locator('.childrow')).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
||||||
|
await page.evaluate(() =>
|
||||||
|
fetch('/api/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
||||||
|
}));
|
||||||
|
const chev = page.locator('.feed.group .chev');
|
||||||
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||||
|
await chev.click();
|
||||||
|
const side = page.locator('#feedlist');
|
||||||
|
await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not.
|
||||||
|
// Opening an item reads it; the toggle in the pane below flips it back.
|
||||||
|
await side.getByText('Aardvark Radio').click();
|
||||||
|
const aa = page.locator('.ep', { hasText: 'Aardvark Ep' });
|
||||||
|
await aa.click();
|
||||||
|
await expect(aa).toHaveClass(/read/);
|
||||||
|
await side.getByText('Grouped Show').click();
|
||||||
|
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
||||||
|
await gs.click();
|
||||||
|
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
||||||
|
await expect(gs).not.toHaveClass(/read/);
|
||||||
|
|
||||||
|
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
||||||
|
// Grouped Show above it. The folder stays open across the reload (localStorage).
|
||||||
|
await page.reload();
|
||||||
|
const want = ['Grouped Show', 'Aardvark Radio'];
|
||||||
|
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
||||||
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
||||||
|
await expect(page.locator('.childrow b')).toHaveText(want);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
||||||
@@ -283,6 +317,10 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||||
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
||||||
|
// Hiding the button is not the guard; the server is.
|
||||||
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||||
|
await expect(page.locator('#logs')).toBeHidden();
|
||||||
|
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||||
|
|
||||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||||
// files, but Sam's own read state.
|
// files, but Sam's own read state.
|
||||||
@@ -333,3 +371,44 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|||||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
||||||
|
const userRow = (page, name) =>
|
||||||
|
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
||||||
|
|
||||||
|
async function openUsers(page) {
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await page.locator('#gusers').click();
|
||||||
|
await expect(userRow(page, 'admin')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => {
|
||||||
|
await openUsers(page);
|
||||||
|
await page.locator('#uname').fill('pat');
|
||||||
|
await page.locator('#upass').fill('patpassword');
|
||||||
|
await page.locator('#uadd').click();
|
||||||
|
const row = userRow(page, 'pat');
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator('[data-a="admin"]')).not.toBeChecked();
|
||||||
|
|
||||||
|
await row.locator('[data-a="admin"]').check();
|
||||||
|
// Not just the box: it has to have reached the database.
|
||||||
|
await expect.poll(async () =>
|
||||||
|
(await (await page.request.get('/api/users')).json()).find(u => u.name === 'pat')?.admin
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
page.once('dialog', d => d.accept());
|
||||||
|
await row.locator('[data-a="rm"]').click();
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the only admin cannot be demoted or removed', async ({ page }) => {
|
||||||
|
await openUsers(page);
|
||||||
|
await userRow(page, 'admin').locator('[data-a="admin"]').click();
|
||||||
|
await expect(page.locator('.toast.bad')).toContainText('only admin');
|
||||||
|
// Redrawn from the server, so the box is back.
|
||||||
|
await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked();
|
||||||
|
|
||||||
|
const me = (await (await page.request.get('/api/users')).json()).find(u => u.name === 'admin');
|
||||||
|
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
|
||||||
|
});
|
||||||
|
|||||||
5
tests/ui/fixtures/aardvark.xml
Normal file
5
tests/ui/fixtures/aardvark.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Aardvark Radio</title><link>http://127.0.0.1:8792/</link>
|
||||||
|
<description>Inside the OPML, and first in it and alphabetically.</description>
|
||||||
|
<item><title>Aardvark Ep</title><guid>aa-1</guid><description>x</description></item>
|
||||||
|
</channel></rss>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<opml version="2.0"><head><title>Test Subscriptions</title></head>
|
<opml version="2.0"><head><title>Test Subscriptions</title></head>
|
||||||
<body><outline text="Folder">
|
<body><outline text="Folder">
|
||||||
|
<outline type="rss" text="Aardvark Radio" xmlUrl="http://127.0.0.1:8792/aardvark.xml"/>
|
||||||
<outline type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
|
<outline type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
|
||||||
</outline></body></opml>
|
</outline></body></opml>
|
||||||
@@ -513,6 +513,9 @@ async function loadFeeds(keepSel){
|
|||||||
renderFeeds();
|
renderFeeds();
|
||||||
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
|
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
|
||||||
}
|
}
|
||||||
|
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||||
|
// server's alphabetical order still holds within each half.
|
||||||
|
const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0);
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
const q=$('#feedFilter').value.trim().toLowerCase();
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
|
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
|
||||||
@@ -527,7 +530,8 @@ function renderFeeds(){
|
|||||||
order.push([f,0]);
|
order.push([f,0]);
|
||||||
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||||
// opens them all, or matches inside a closed folder would be invisible.
|
// opens them all, or matches inside a closed folder would be invisible.
|
||||||
if(expanded.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]);
|
if(expanded.has(f.id) || q)
|
||||||
|
for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]);
|
||||||
}
|
}
|
||||||
for(const [f,depth] of order){
|
for(const [f,depth] of order){
|
||||||
const kids=shown.filter(c=>c.group===f.id).length;
|
const kids=shown.filter(c=>c.group===f.id).length;
|
||||||
@@ -646,7 +650,7 @@ function renderGroup(f,kids){
|
|||||||
const draw=()=>{
|
const draw=()=>{
|
||||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||||
const box=$('#kidlist'); box.innerHTML='';
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q));
|
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||||
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||||
for(const c of rows){
|
for(const c of rows){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
@@ -1227,9 +1231,13 @@ async function prefsModal(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Export hands every subscription to another podcast app. Import adds the
|
<span class="hint">Export hands every subscription to another podcast app. Import adds the
|
||||||
feeds listed in an OPML you paste in.</span></div>
|
feeds listed in an OPML you paste in.</span></div>
|
||||||
|
<div class="field"><label>Users</label>
|
||||||
|
<div class="inline"><button class="btn" id="gusers">Manage users…</button></div>
|
||||||
|
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
|
||||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||||
<button class="btn primary" id="gsave">Save</button></div>`);
|
<button class="btn primary" id="gsave">Save</button></div>`);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#gopml').onclick=opmlModal;
|
||||||
|
$('#gusers').onclick=usersModal;
|
||||||
$('#gsave').onclick=async()=>{
|
$('#gsave').onclick=async()=>{
|
||||||
try{
|
try{
|
||||||
await api('/api/settings',{method:'PATCH',body:JSON.stringify({
|
await api('/api/settings',{method:'PATCH',body:JSON.stringify({
|
||||||
@@ -1244,6 +1252,52 @@ async function prefsModal(){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin only, and the server enforces that: this screen is just the way in.
|
||||||
|
async function usersModal(){
|
||||||
|
const users = await api('/api/users') || [];
|
||||||
|
openModal(`<h3>Users</h3>
|
||||||
|
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
|
||||||
|
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||||
|
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'}
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
|
||||||
|
<button class="btn danger" data-a="rm">Remove</button></div>`).join('')}
|
||||||
|
<div class="field" style="margin-top:16px"><label>Add someone</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
||||||
|
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
||||||
|
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
||||||
|
through the proxy. New people start with no feeds.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
|
||||||
|
<button class="btn primary" id="uadd">Add</button></div>`);
|
||||||
|
const change=async(u,opts)=>{
|
||||||
|
try{
|
||||||
|
await api(`/api/users/${u.id}`,opts);
|
||||||
|
// Demoting yourself takes this screen away; reload so the page stops offering it. Only
|
||||||
|
// on success: reloading after a refusal wiped the toast that said why.
|
||||||
|
if(u.name===S.me?.name){ location.reload(); return; }
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
usersModal(); // on a refusal, this puts the checkbox back where the server left it
|
||||||
|
};
|
||||||
|
$$('#modalCard [data-id]').forEach(row=>{
|
||||||
|
const u=users.find(x=>String(x.id)===row.dataset.id);
|
||||||
|
$('[data-a="admin"]',row).onchange=e=>
|
||||||
|
change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})});
|
||||||
|
$('[data-a="rm"]',row).onclick=()=>{
|
||||||
|
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||||
|
change(u,{method:'DELETE'});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
$('#uadd').onclick=async()=>{
|
||||||
|
try{
|
||||||
|
await api('/api/users',{method:'POST',body:JSON.stringify({
|
||||||
|
name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})});
|
||||||
|
toast('Added'); usersModal();
|
||||||
|
}catch(e){ toast(e.message,true); } // keep what was typed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function settingsModal(f){
|
function settingsModal(f){
|
||||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
@@ -1360,8 +1414,9 @@ on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); loc
|
|||||||
api('/api/me').then(u=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
// Scanning, quotas and the download folder are the operator's business.
|
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
|
||||||
if(!u.admin) $('#prefs').hidden=true;
|
// them too; hiding the buttons just stops offering what would fail.
|
||||||
|
if(!u.admin){ $('#prefs').hidden=true; $('#logs').hidden=true; }
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
on('#logs','onclick',logsModal);
|
on('#logs','onclick',logsModal);
|
||||||
$('#feedFilter').oninput=renderFeeds;
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
|
|||||||
Reference in New Issue
Block a user