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:
2026-09-11 12:43:28 +00:00
parent 6114add4a6
commit 5e95557cbb
11 changed files with 370 additions and 40 deletions

View File

@@ -6,31 +6,37 @@ made here.
## 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.
| | |
|---|---|
| Binary | `/config/.cargo/bin/ipx` |
| Config | `/config/.config/ipx/config.toml` |
| Database | `/config/.local/share/ipx/state.db` |
| Downloads | `/mnt/user/audio/ipx` |
| Web UI | `0.0.0.0:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel |
| | Host | In the container |
|---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
| 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` |
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
cargo build --release
pkill -x ipx; sleep 2
cp target/release/ipx /config/.cargo/bin/ipx
setsid nohup /config/.cargo/bin/ipx --config /config/.config/ipx/config.toml daemon \
>/tmp/ipx.log 2>&1 </dev/null &
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
docker logs --tail 20 iPodderX
```
**`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.
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
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
has installed.
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
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
@@ -110,10 +116,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
## 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`
(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
Substack-style subtitles.