From eb960f1b5722cd8662ddf4ce7537a7cd902fdb71 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Thu, 3 Sep 2026 18:19:08 +0000 Subject: [PATCH] Web dashboard step 7: docs, users page, polish Users page, README and config.example updates, implementation notes, the rollout runbook, site-layout 404/500 pages, human-readable download sizes, dark-mode and narrow-screen polish, and a smoke test over every dashboard route; also removes zdiff3 ancestor markers left by earlier merges. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM --- README.md | 78 ++++++++-- config.example.toml | 17 ++- docs/plans/2026-08-15-implementation-notes.md | 56 +++++++ .../briefs/web-dashboard/handoff-step7.md | 96 ++++++++++++ docs/runbooks/web-dashboard-rollout.md | 137 ++++++++++++++++++ src/web/dashboard/mod.rs | 41 ++++++ src/web/dashboard/users.rs | 74 +++++++++- src/web/issue.rs | 29 +++- src/web/mod.rs | 68 +++++++-- src/web/static/app.css | 24 ++- src/web/static/app.js | 2 +- src/web/templates/_signals_table.html | 4 +- src/web/templates/dashboard/users.html | 15 ++ src/web/templates/issue_full.html | 2 +- src/web/templates/issue_public.html | 2 +- src/web/templates/layout.html | 4 +- 16 files changed, 599 insertions(+), 50 deletions(-) create mode 100644 docs/plans/briefs/web-dashboard/handoff-step7.md create mode 100644 docs/runbooks/web-dashboard-rollout.md create mode 100644 src/web/templates/dashboard/users.html diff --git a/README.md b/README.md index 7e67fa8..a7607e8 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Gemini (or anything OpenAI-compatible) is a config line plus an API key. - Full design: [`docs/plans/2026-08-15-the-daily-epub.md`](docs/plans/2026-08-15-the-daily-epub.md) - Implementation decisions: [`docs/plans/2026-08-15-implementation-notes.md`](docs/plans/2026-08-15-implementation-notes.md) +- Web dashboard rollout: [`docs/runbooks/web-dashboard-rollout.md`](docs/runbooks/web-dashboard-rollout.md) --- @@ -114,7 +115,7 @@ sudo install -m0755 target/release/daily-epub /usr/local/bin/ ``` daily-epub generate [--date YYYY-MM-DD] [--dry-run] [--out DIR] [--max-articles N] [--skip-llm] [--skip-embeddings] [--rescore] -daily-epub serve # rating endpoints + OPDS catalog + downloads +daily-epub serve # public site, private dashboard, OPDS, ratings, downloads daily-epub profile rebuild # regenerate learned profile adjustments daily-epub ratings list [--days 90] [--label loved|good|down|cleared] daily-epub ratings set --article 42 --label loved --note "excellent" @@ -134,6 +135,7 @@ daily-epub users disable USER daily-epub users enable USER daily-epub users list daily-epub users logout USER # revoke all of USER's sessions +daily-epub job run NAME # systemd job-unit entry point; normally not run by hand ``` `--dry-run` does everything except deliver: it still ingests, persists entries and @@ -184,19 +186,58 @@ start with `!`. It exits non-zero only on a validation error — a missing key o file is a warning, since the run degrades rather than fails — and never opens the database or takes the lock, so it is safe to run next to a live `generate`. +## Web site and dashboard + +The server is both the public newspaper index and the private operator UI. An +anonymous visitor sees only titles, authors, sources, metadata and outbound +comment links; generated and scraped text stays private. A signed-in `user` +sees complete issues and article chapters and can download artifacts. An +`admin` can additionally rate articles and use every `/dashboard/*` page, +including settings and jobs. Personalization is shared across accounts for now. + +Accounts are deliberately managed on the host, not in the browser. Usernames +are case-insensitive and passwords must be 12–1024 characters. Bootstrap with +`daily-epub users add --admin`; use `users passwd`, `role`, +`disable`/`enable`, `list`, and `logout` for later administration. Password +changes and disabling a user revoke that user's sessions. `/dashboard/users` +is a read-only view of roles, status, login times, and open sessions. + +The Jobs page starts only the fixed job catalogue as +`daily-epub-job@.service`; the web server never runs the pipeline inside +its own process. Install `systemd/daily-epub-job@.service` and the narrowly +scoped `systemd/50-daily-epub.rules` polkit rule, and put the server user in +`systemd-journal` so the page can show status and its configured journal tail. +Set `server.jobs_enabled = false` to make starts unavailable. + +The Settings page derives its fields from `Config`, rewrites `config.toml` in +place with `toml_edit`, preserves comments/order and file permissions, validates +before an atomic rename, and records attributed history. It re-reads hand edits +on the next view. `DAILY_EPUB_*` overrides appear locked, and secrets are shown +only as present or absent. Shipped providers (`deepseek`, `anthropic`, +`gemini`) cannot be removed because defaults would restore them; leave one +unreferenced or edit it. Custom providers can be removed after no `[llm]` role +references them. The service needs `/etc/daily-epub` in `ReadWritePaths` for +these writes. + ### Web routes | Route | Access | Purpose | |---|---|---| -| `GET /` | Public | Latest issue as a source-link-only index. | -| `GET /issues` | Public | Issue archive. | -| `GET /issues/{date}` | Public | One source-link-only issue index. | -| `GET /feed.xml` | Public | Atom feed carrying the same public issue content. | -| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded site assets. | -| `GET/POST /login`, `POST /logout`, `GET /account` | Session | Sign in, sign out, and account management. | -| `GET /dashboard` | Admin | Private operator dashboard. | -| `GET /files/epub/{name}`, `/files/xtc/{name}` | Session or Basic auth | Published downloads; existing OPDS clients continue to use Basic auth. | -| `GET /opds/daily.xml`, `/healthz`, `/issues.json`, `/r/...` | Existing policy | OPDS, health, reports, and signed rating links. | +| `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. | +| `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. | +| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. | +| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. | +| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. | +| `POST /rate` | Admin | Append an attributed dashboard rating event. | +| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. | +| `GET /dashboard/runs[/{id}]`, `/articles[/{id}]`, `/ratings`, `/stats` | Admin | Pipeline history, article explanations, rating contributions/history, and evaluation stats. | +| `GET/POST /dashboard/profile`, `POST /dashboard/profile/restore` | Admin | Edit `profile.md`, inspect prompts/adjustments, and restore a version. | +| `GET/POST /dashboard/settings`, `POST /dashboard/settings/providers`, `GET /dashboard/settings/history` | Admin | Edit validated configuration and inspect its audit log. | +| `GET /dashboard/jobs`, `GET /dashboard/jobs/{id}`, `POST /dashboard/jobs/{name}` | Admin | Start fixed systemd jobs and inspect status and logs. | +| `GET /dashboard/users` | Admin | Read-only users and open-session list; edits use the CLI. | +| `GET /files/epub/{name}`, `/files/xtc/{name}` | Public if Basic auth is unset; otherwise session or Basic auth | Published downloads. Keeping them public when Basic auth is absent preserves existing OPDS acquisition links. | +| `GET /opds`, `/opds/`, `/opds/daily.xml` | Existing optional Basic auth | OPDS acquisition feed. | +| `GET /r/...`, `/healthz`, `/issues.json` | Existing policy | HMAC rating links, health, and issue reports. | --- @@ -387,15 +428,21 @@ sudo setfacl -m u:daily-epub:rwx /srv/bookorbit/libraries/daily-epub # or chow # units sudo install -m0644 systemd/daily-epub.service systemd/daily-epub-generate.service \ - systemd/daily-epub-generate.timer /etc/systemd/system/ + systemd/daily-epub-generate.timer systemd/daily-epub-job@.service \ + /etc/systemd/system/ +sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/ sudo systemctl daemon-reload sudo systemctl enable --now daily-epub.service daily-epub-generate.timer ``` -> **Keep `ReadWritePaths` in sync.** Both units run under `ProtectSystem=strict` -> and list the publish directories explicitly: +> **Keep `ReadWritePaths` in sync.** The units run under `ProtectSystem=strict` +> and list the publish directories explicitly; the server additionally lists +> `/etc/daily-epub` so Settings can replace `config.toml`: > ``` +> # generate and job units > ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc +> # server unit +> ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub > ``` > If you change `publish.epub_dir` or `publish.xtc_dir` in the config, change > these lines too and `systemctl daemon-reload`, or publishing fails with @@ -727,5 +774,6 @@ From spec §7, plus what implementation turned up: `triage:` / `assess:` log lines (`… 3 rejected (2 recovered on gemini)`), the ` · N rejected` suffix on the `curation:` line, or `sqlite3 /var/lib/daily-epub/daily-epub.db "select stage, count(*) from article_assessments where kind = 'provider_rejected' group by stage"`. -- **One reader, one issue per day.** There is no multi-user support and no - weekly/retrospective edition (spec §6). +- **One shared reader profile, one issue per day.** Multiple login accounts and + `user`/`admin` roles are supported, but they share one personalization model; + there is no per-user issue or weekly/retrospective edition (spec §6). diff --git a/config.example.toml b/config.example.toml index 5867bbd..e9c372f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -208,14 +208,15 @@ format = "xtch" # xtc (1-bit) | xtch (grayscale) settings = "/etc/daily-epub/xtc-settings.json" [server] -bind = "127.0.0.1:3499" -public_url = "https://daily.hallada.net" -session_days = 30 -login_attempts = 10 -login_window_minutes = 15 -jobs_enabled = true -journal_lines = 300 +bind = "127.0.0.1:3499" # listen address; keep loopback when trusting proxy IP headers +public_url = "https://daily.hallada.net" # base URL for EPUB rating links and same-origin checks +session_days = 30 # sliding lifetime for web login sessions +login_attempts = 10 # login POSTs allowed per client IP in one throttle window +login_window_minutes = 15 # length of the login throttle window +jobs_enabled = true # let the dashboard start daily-epub-job@.service +journal_lines = 300 # job-page journal tail; valid range 10..=5000 # hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes) -# Optional Basic auth for the XTC OPDS feed and file downloads: +# Optional Basic auth for /opds/* and /files/*; without it those existing +# routes remain public. A signed-in web user can download without Basic auth. # basic_auth_user = "daily" # basic_auth_pass = "..." diff --git a/docs/plans/2026-08-15-implementation-notes.md b/docs/plans/2026-08-15-implementation-notes.md index 691b67e..61b5bdf 100644 --- a/docs/plans/2026-08-15-implementation-notes.md +++ b/docs/plans/2026-08-15-implementation-notes.md @@ -221,3 +221,59 @@ implementer needs that are easy to get wrong: `assessment_reuse_days`, `--rescore` ignores it, and `admit::hygiene` never treats it as a low score. Because a recovered article's row carries the editor's model, reuse accepts rows whose `model` is either configured model (`triage::reusable_models`). + +## Web dashboard (2026-09-03) + +Verified on the production host and against the implemented dependency graph on 2026-09-03: + +- **Host authorization and units:** polkit 124 supports JavaScript rules. The installed rule must + grant user `daily-epub` only `org.freedesktop.systemd1.manage-units`, verb `start`, for units + matching `^daily-epub-job@[a-z0-9-]+\.service$`. `daily-epub.service` now needs + `SupplementaryGroups=systemd-journal` to read job logs and `/etc/daily-epub` in + `ReadWritePaths` to atomically replace `config.toml`. The job template deliberately omits + `MemoryDenyWriteExecute` because generate jobs can launch Node's JIT; the server retains it. +- **Settings writes:** direct `toml_edit 0.25` performs typed, comment/order-preserving updates. + A candidate file is loaded through `Config::load` before its permissions are copied and it is + renamed over the original. The process therefore needs write access to the containing + directory, not only the file. Every changed dotted key is written to `config_changes`. +- **Authentication stack:** `axum-login` is pinned to git revision + `151c72d7a1b4646830f86b4332e6bd6e34d719a7`, whose graph contains `tower-sessions 0.15`. + The local `SqliteSessionStore` uses this crate's existing sqlx 0.9 pool because the published + tower SQLx store is incompatible. `password-auth 1.0` supplies Argon2id PHC hashes; + `tower_governor 0.8` throttles login POSTs. Its smart IP extractor trusts forwarded headers, + so production must bind to loopback and accept traffic only from the configured reverse proxy. +- **Test environment:** router tests use `tower::ServiceExt::oneshot`, temp SQLite databases and + `MockRunner`; they do not bind or invoke systemd. This sandbox forbids loopback listeners, so + the four `curate::llm::tests::anthropic_*` tests, the three OpenAI tests using the same fake + listener, `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`, the five + listener-based `server::tests::*`, and `tests/m7_server.rs` are filtered only for sandbox runs. + The complete suite is expected to run outside the sandbox. + +Implementation-time decisions recorded while landing dashboard steps 1–7: + +- `/files/*` remains public when Basic auth is not configured, preserving existing OPDS + acquisition behavior. With Basic auth configured, either valid Basic credentials or any valid + web session authorizes a download. This intentionally resolves the plan's conflicting request + to redirect unauthenticated downloads in favor of its compatibility acceptance criterion. +- Final reports are attached to an issue after `finish_run`, when publish timing and status are + complete. Issue snapshots omit article bodies and rehydrate them from `articles`; old rows use + the reduced fallback renderer. The CLI password prompt uses `rpassword` after it became + available to the orchestrator. +- Flash handlers extract the exact tower session installed by the auth layer through + `Extension`. Dashboard `down` forms persist the established `not_for_me` label. + Ratings-page feed credit includes rating decay because that is what `signals::feed_rates` + actually uses; the stored prompt verdict count is inferred from the prompt text because + `TasteProfile.verdicts` is not persisted. +- Dynamic list SQL is assembled only from fixed fragments and allow-listed sort/filter names, + wrapped in sqlx 0.9's `AssertSqlSafe`; all user values remain bound parameters. Funnel bars + count rows that reached each stage because a row stores the stage where it stopped. SVG/meter + attributes replace inline styles under the CSP. Article pages omit the nominal extract method + because database reconstruction currently hard-codes it and would display misleading data. +- Shipped providers cannot be removed: deleting one would cause `Config::default()` to restore + it. They remain editable and may be unreferenced; custom providers are removable. An absent + setting already equal to its submitted default stays absent. Settings that are captured while + building the server, session, or throttle layers carry restart notices. +- A job start reloads a hand-edited config before inserting and starting the unit, retaining the + last-good config if reload fails. The offline lifecycle test uses `features-prune`; generate + run-id linkage is covered separately. CLI duration statistics retain finished dry runs for + byte-identical output, while dashboard run series and overview sparklines exclude them. diff --git a/docs/plans/briefs/web-dashboard/handoff-step7.md b/docs/plans/briefs/web-dashboard/handoff-step7.md new file mode 100644 index 0000000..604c33e --- /dev/null +++ b/docs/plans/briefs/web-dashboard/handoff-step7.md @@ -0,0 +1,96 @@ +# Step 7 handoff — docs, users page, and polish + +## Landed + +- Added the admin-only, read-only `/dashboard/users` page with username, role, enabled/disabled + status, created time, last login, and unexpired session count. The page directs edits to the + existing `daily-epub users` CLI. +- Expanded the README with the public/user/admin boundary, user lifecycle commands, complete + route table, systemd+polkit Jobs setup, comment-preserving Settings behavior, environment + locks, shipped-provider removal limitation, public `/files/*` behavior without Basic auth, + and the `X-Forwarded-For`/loopback trust boundary. Updated `[server]` comments in + `config.example.toml` to match. +- Added the dated Web dashboard section to the implementation notes: polkit 124 and its narrow + rule, service-unit changes, the `toml_edit` write path, the pinned auth/session/password/ + throttle stack, sandbox test caveats, and the implementation-time decisions from steps 1–6. +- Added `docs/runbooks/web-dashboard-rollout.md`, with the plan's nine production rollout steps, + commands, permissions, public-files compatibility note, and smoke checks. +- Completed layout polish: Atom discovery and favicon remain in ``, Users remains in the + admin nav, both navs now wrap cleanly, narrow cards/forms/rating controls collapse, dark-mode + form controls use the site colors, focus states are visible, and all wide tables (including + the nested signals table and new users table) sit in `.scroll-x`. +- Unknown routes and template-render failures now use the site-layout 404/500 pages. Issue + download buttons show human-readable B/KB/MB/GB sizes. +- Removed stale diff3 ancestor markers from the merged CSS/JavaScript and restored the missing + table-filter closure; `node --check src/web/static/app.js` passes. +- Added the users guard/content test, file-size tests, site-layout error test, and one + fixture-backed router smoke test that renders every dashboard route template. + +## Acceptance criteria walk-through + +1. **Public issue and Atom boundary — met.** `PublicIssue` cannot carry generated/body fields; + existing fixture tests assert titles/authors/sources/comment links/metadata are present and + private Brief/summary/why/body/comment/World text is absent. The feed parses as XML and uses + the same public view. +2. **Complete signed-in issue — met.** Existing router tests cover Brief, summaries, why lines, + article bodies/discussion, World and Behind pages, reduced pre-snapshot fallback, and + existence-gated downloads. All three artifact slots share the same loader; sizes are now + human-readable. +3. **Admin-only ratings — met.** Whole-router role guards and rating tests cover anonymous, + user, and admin outcomes, attributed `source = 'dashboard'` events, clear events, and the + unchanged HMAC path. +4. **Article history/explainability — met.** The articles list/detail routes expose the latest + stage plus full run history, reasons, signals, assessments/facets, utility/cluster/admission, + editor why, issue appearances, neighbours, embedding metadata, ratings, and text explain; + allow-listed query tests and fixture rendering pass. +5. **Run history — met.** Run list/detail renders the cumulative funnel and reasons, admission, + preference state, timings, provider costs, warnings, feeds, near misses, config diff, and + candidate table; seeded counts, sorts, filters, links, and sparklines are tested. +6. **Ratings contributions/history — met.** Current and events tabs cover decay, decayed feed + credit, neighbour weight, prompt/rebuild membership, last-run use, append-only edits/clear, + annotations, filters, and superseded history with hand-checked tests. +7. **Settings — met.** The schema/default/help/env/secret tests cover every config leaf. Writer + tests cover typed changes, comments/order/mode preservation, validation before rename, + history, providers, and reload-on-mtime. The README records why shipped providers cannot be + removed. +8. **Profile — met.** Save/restore/version tests cover the atomic editor and parsed preview; + the page shows OPML interests, prompt, learned adjustments/staleness, and the rebuild job. +9. **Jobs — met.** Fixed-name parsing, polkit/unit files, MockRunner starts/status/logs, duplicate + refusal, failure persistence, lifecycle/run linkage, journal tail, reload-before-start, and + overview links are present and tested. Pipeline work remains in the separate job unit. +10. **Session/security stack — met.** The pinned axum-login/tower-sessions store, + password-auth hashes, cookie properties, session invalidation, origin checks, login throttle, + CSP/security headers, safe redirect checks, and secret redaction have focused tests. +11. **Existing routes — met.** HMAC, OPDS, files, health, and reports remain wired; issue reports + are populated. The reviewed compatibility decision keeps files public only when Basic auth + is absent, while configured Basic auth or a valid session still gates them. +12. **Test coverage — met, with sandbox execution caveat.** All new router tests use `oneshot`; + no new test binds or invokes systemd. The filtered full run passed every runnable target. + The orchestrator should run the complete suite outside this listener-restricted sandbox. + +## Prior handoff follow-ups + +- Step 1's `rpassword` and direct `toml_edit 0.25` follow-ups were already resolved by the + orchestrator; its public-files deviation is now documented. +- Step 2's raw download sizes are resolved here. +- Step 3's Jobs/ratings targets and overview sparklines, Step 4's settings anchors and profile + rebuild action, and Steps 5–6's config reload plus combined unit changes are all present after + the merged steps and were included in the smoke/audit pass. +- Nothing remains open against §20. Production deployment itself is intentionally left to the + operator following the new runbook. Plan §21's product deferrals remain intentional: per-user + personalization, OPML editing, body FTS, SSE logs, passkeys, public why lines, old snapshot + backfill, and charts beyond the current sparklines. + +## Verification + +- `cargo fmt`: pass. +- `cargo clippy --all-targets -- -D warnings`: pass. +- `node --check src/web/static/app.js`: pass. +- Focused new router/size/error tests: pass. +- Raw `cargo test`: **426 passed, 13 failed** before Cargo stopped at the library target; every + failure was `PermissionDenied` while binding a loopback listener (the four documented + Anthropic tests, three OpenAI tests using the same helper, the extractor test, and five server + tests). +- `cargo test` with those 13 listener tests plus the two `tests/m7_server.rs` TCP tests filtered: + **460 passed, 0 failed, 15 filtered out** across library, binary, integration, and doc targets. + diff --git a/docs/runbooks/web-dashboard-rollout.md b/docs/runbooks/web-dashboard-rollout.md new file mode 100644 index 0000000..00efe03 --- /dev/null +++ b/docs/runbooks/web-dashboard-rollout.md @@ -0,0 +1,137 @@ +# Runbook — web dashboard rollout + +**Written:** 2026-09-03 for the production host. Run these commands from the checked-out +`web-dashboard` tree as an operator with `sudo`. The existing curation-v2 migration should +already be complete. + +## 1. Build and install + +```sh +cargo test +cargo build --release +sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub +daily-epub --help +``` + +Confirm the help includes both `users` and `job`. Keep the previous binary available until the +smoke tests below pass. + +## 2. Install the job unit and polkit rule + +```sh +sudo install -m0644 systemd/daily-epub-job@.service /etc/systemd/system/ +sudo install -m0644 systemd/50-daily-epub.rules /etc/polkit-1/rules.d/ +sudo install -m0644 systemd/daily-epub.service /etc/systemd/system/ +sudo systemctl daemon-reload +``` + +The server unit must contain both: + +```ini +SupplementaryGroups=systemd-journal +ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub +``` + +Keep the two publish paths aligned with `[publish]`. The polkit rule permits user `daily-epub` +to start only `daily-epub-job@[a-z0-9-]+.service`; it does not grant stop, restart, or arbitrary +unit management. + +## 3. Verify writable paths and ownership + +Settings saves create a temporary file and rename it into `/etc/daily-epub`, so the directory +and config file must both belong to the service account: + +```sh +sudo chown daily-epub:daily-epub /etc/daily-epub /etc/daily-epub/config.toml +sudo chmod 0750 /etc/daily-epub +sudo chmod 0640 /etc/daily-epub/config.toml +sudo install -d -m0750 -o daily-epub -g daily-epub /var/lib/daily-epub/data +sudo test -f /var/lib/daily-epub/data/profile.md +sudo -u daily-epub test -w /var/lib/daily-epub/data/profile.md +``` + +Set `profile_path = "/var/lib/daily-epub/data/profile.md"`; `StateDirectory=daily-epub` makes +that location writable. Also re-check the configured publish directories as `daily-epub`. + +## 4. Review the new server settings + +All keys have defaults, so additions are optional unless production needs overrides: + +```toml +[server] +bind = "127.0.0.1:3499" +public_url = "https://daily.hallada.net" +session_days = 30 +login_attempts = 10 +login_window_minutes = 15 +jobs_enabled = true +journal_lines = 300 +``` + +Keep the bind address on loopback: the login throttle trusts `X-Forwarded-For`, so only the +reverse proxy should be able to reach the listener. Keep secrets in `/etc/daily-epub/env`, not +TOML. If `server.basic_auth_user` and `server.basic_auth_pass` are omitted, the existing +`/opds/*` and `/files/*` routes remain public; this is intentional so OPDS acquisition links +continue to work. When Basic auth is configured, signed-in users can still download files with +their session. + +## 5. Restart, migrate, and verify public pages + +```sh +sudo systemctl restart daily-epub.service +sudo systemctl --no-pager --full status daily-epub.service +curl -fsS https://daily.hallada.net/ | grep -F 'The Daily EPUB' +curl -fsS https://daily.hallada.net/feed.xml >/tmp/daily-epub-feed.xml +python3 -c 'import xml.etree.ElementTree as E; E.parse("/tmp/daily-epub-feed.xml")' +``` + +Migration `0004_web.sql` applies automatically at start. Anonymous issue pages and the Atom +feed must show the source-link index without summaries, article bodies, comments, the Brief, or +World Briefing text. + +## 6. Bootstrap the administrator + +```sh +sudo -u daily-epub /usr/local/bin/daily-epub \ + --config /etc/daily-epub/config.toml users add tyler --admin +sudo -u daily-epub /usr/local/bin/daily-epub \ + --config /etc/daily-epub/config.toml users list +``` + +The first command prompts twice without echo. Log in at `https://daily.hallada.net/login`, open +`/dashboard`, and confirm `/dashboard/users` shows the admin and an open session. A non-admin +account should receive the site's 403 page for every dashboard route. + +## 7. Smoke test a job + +Start `features-prune` from `/dashboard/jobs`, open its job detail, and watch the status and log +tail. On the host, confirm the same unit: + +```sh +systemctl status daily-epub-job@features-prune.service +journalctl -u daily-epub-job@features-prune.service -n 100 --no-pager +``` + +If the page reports a permission failure, inspect `journalctl -u polkit` and verify the installed +rule and exact unit name. Do not broaden the rule to arbitrary units. + +## 8. Smoke test an in-place settings write + +In `/dashboard/settings`, change `curation.ranking.utility_protected` by one and save. Confirm +the next view shows the new value, comments and ordering remain in the file, and the attributed +change appears at `/dashboard/settings/history`: + +```sh +sudo -u daily-epub grep -n 'utility_protected' /etc/daily-epub/config.toml +``` + +Change the value back through the page and confirm the second history row. Environment-overridden +fields should be locked, and no API key, HMAC secret, or Basic-auth password should be displayed. + +## 9. Choose the history window + +Candidate and assessment history is pruned after +`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard +should retain a longer article/run history, then reload `/dashboard/settings` and verify the +effective value. This changes future pruning only; it cannot restore rows already deleted. + diff --git a/src/web/dashboard/mod.rs b/src/web/dashboard/mod.rs index 77fb56d..aa483d2 100644 --- a/src/web/dashboard/mod.rs +++ b/src/web/dashboard/mod.rs @@ -1185,4 +1185,45 @@ pub(crate) mod tests { assert!(body.contains("2 runs · max $0.11"), "{body}"); assert!(!body.contains("style=\""), "no inline styles under the CSP"); } + + #[tokio::test] + async fn every_dashboard_route_template_renders_with_fixture_data() { + let seed = seed().await; + sqlx::query( + "INSERT INTO jobs + (id, name, unit, requested_at, started_at, finished_at, status, message, run_id) + VALUES (99, 'features-prune', 'daily-epub-job@features-prune.service', + '2026-09-02T06:00:00Z', '2026-09-02T06:00:01Z', + '2026-09-02T06:00:02Z', 'ok', 'pruned fixture rows', ?)", + ) + .bind(seed.run_id) + .execute(seed.db.pool()) + .await + .unwrap(); + let app = app_with_users(&seed.db).await; + let admin = login_cookie(&app, "admin", "correct horse battery").await; + let routes = vec![ + "/dashboard".to_string(), + "/dashboard/runs".to_string(), + format!("/dashboard/runs/{}", seed.run_id), + "/dashboard/articles".to_string(), + "/dashboard/articles/1".to_string(), + "/dashboard/ratings".to_string(), + "/dashboard/ratings?tab=events".to_string(), + "/dashboard/profile".to_string(), + "/dashboard/stats?days=14".to_string(), + "/dashboard/settings".to_string(), + "/dashboard/settings/history".to_string(), + "/dashboard/jobs".to_string(), + "/dashboard/jobs/99".to_string(), + "/dashboard/users".to_string(), + ]; + for uri in routes { + let response = get(&app, &uri, Some(&admin)).await; + assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let body = response_text(response).await; + assert!(body.contains(""), "{uri}: {body}"); + assert!(body.contains("The Daily EPUB"), "{uri}: {body}"); + } + } } diff --git a/src/web/dashboard/users.rs b/src/web/dashboard/users.rs index 19e5ccb..83724bb 100644 --- a/src/web/dashboard/users.rs +++ b/src/web/dashboard/users.rs @@ -1,10 +1,80 @@ -//! Dashboard: users pages. Filled in by web dashboard plan step 7. +//! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1). +use askama::Template; use axum::Router; +use axum::extract::{Extension, State}; +use axum::routing::get; +use axum_login::tower_sessions::Session; use crate::server::AppState; +use crate::web::session::{AuthSession, Viewer}; +use crate::web::{Html, Page, WebError, format_time, take_flash}; + +#[derive(Debug)] +struct UserLine { + username: String, + role: String, + disabled: bool, + created: String, + last_login: String, + open_sessions: i64, +} + +#[derive(Template)] +#[template(path = "dashboard/users.html")] +struct UsersTemplate { + page: Page, + users: Vec, +} /// Routes contributed by this page group (merged by `dashboard::router`). pub fn routes() -> Router { - Router::new() + Router::new().route("/dashboard/users", get(index)) +} + +async fn index( + State(state): State, + auth: AuthSession, + Extension(session): Extension, +) -> Result, WebError> { + let viewer = auth.user().await.map(Viewer::from); + let config = state.config(); + let users = crate::web::users::list(&state.db) + .await + .map_err(WebError::Internal)? + .into_iter() + .map(|row| UserLine { + username: row.user.username, + role: row.user.role.to_string(), + disabled: row.user.disabled, + created: format_time(row.user.created_at, &config), + last_login: row + .user + .last_login_at + .map(|at| format_time(at, &config)) + .unwrap_or_else(|| "never".into()), + open_sessions: row.open_sessions, + }) + .collect(); + let mut page = Page::new("Users", viewer, "dashboard"); + page.flash = take_flash(&session).await?; + Ok(Html(UsersTemplate { page, users })) +} + +#[cfg(test)] +mod tests { + use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed}; + + #[tokio::test] + async fn users_page_is_admin_only_and_lists_accounts_and_sessions() { + let seed = seed().await; + let app = app_with_users(&seed.db).await; + let body = assert_admin_only(&app, "/dashboard/users").await; + + assert!(body.contains("

Users

"), "{body}"); + assert!(body.contains("reader"), "{body}"); + assert!(body.contains("admin"), "{body}"); + assert!(body.contains("Open sessions"), "{body}"); + assert!(body.contains("daily-epub users"), "{body}"); + } } diff --git a/src/web/issue.rs b/src/web/issue.rs index 0437df3..0efa303 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -25,6 +25,7 @@ pub struct Download { pub label: String, pub href: String, pub size_bytes: u64, + pub size: String, } #[derive(Debug, Clone)] @@ -192,9 +193,26 @@ fn download( label: label.to_string(), href: format!("/files/{kind}/{}", crate::web::encode_component(name)), size_bytes: metadata.len(), + size: format_file_size(metadata.len()), }) } +fn format_file_size(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let bytes_float = bytes as f64; + if bytes_float >= GB { + format!("{:.1} GB", bytes_float / GB) + } else if bytes_float >= MB { + format!("{:.1} MB", bytes_float / MB) + } else if bytes_float >= KB { + format!("{:.1} KB", bytes_float / KB) + } else { + format!("{bytes} B") + } +} + #[derive(Debug)] struct FullEntry { title: String, @@ -1072,7 +1090,7 @@ mod tests { Edition::Standard, "epub", )); - std::fs::write(&standard, b"epub").unwrap(); + std::fs::write(&standard, vec![0; 2 * 1024]).unwrap(); let mut config = crate::config::Config::default(); config.publish.epub_dir = epub_dir; let app = crate::server::router(crate::server::AppState::new(db, config, None)); @@ -1089,10 +1107,19 @@ mod tests { .unwrap(); let issue = response_text(issue).await; assert!(issue.contains("Download EPUB")); + assert!(issue.contains("2.0 KB")); + assert!(!issue.contains("2048 bytes")); assert!(!issue.contains("Download X4 EPUB")); assert!(!issue.contains("Download XTC")); } + #[test] + fn file_sizes_are_human_readable() { + assert_eq!(format_file_size(42), "42 B"); + assert_eq!(format_file_size(1536), "1.5 KB"); + assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB"); + } + #[tokio::test] async fn rating_post_supports_json_forms_attribution_fallback_and_clear() { let (_dir, db, source) = seeded_issue(true).await; diff --git a/src/web/mod.rs b/src/web/mod.rs index 80ad3a8..c0c5dd9 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -244,7 +244,11 @@ impl IntoResponse for Html { .into_response(), Err(error) => { tracing::error!(%error, "rendering web template failed"); - (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + error_page_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Server error", + "The request could not be completed.", + ) } } } @@ -276,6 +280,22 @@ struct ErrorTemplate { message: String, } +fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response { + let rendered = ErrorTemplate { + page: Page::new(heading, None, ""), + heading: heading.to_string(), + message: message.to_string(), + } + .render() + .unwrap_or_else(|_| message.to_string()); + ( + status, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + rendered, + ) + .into_response() +} + impl IntoResponse for WebError { fn into_response(self) -> Response { if let Self::Unauthenticated { next } = self { @@ -317,19 +337,7 @@ impl IntoResponse for WebError { } Self::Unauthenticated { .. } => unreachable!(), }; - let rendered = ErrorTemplate { - page: Page::new(heading, None, ""), - heading: heading.to_string(), - message: message.to_string(), - } - .render() - .unwrap_or_else(|_| message.to_string()); - ( - status, - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - rendered, - ) - .into_response() + error_page_response(status, heading, message) } } @@ -475,6 +483,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router Response { @@ -1000,4 +1009,35 @@ mod tests { .unwrap(); assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); } + + #[tokio::test] + async fn not_found_and_server_error_pages_use_the_site_layout() { + let (_dir, state) = test_state(Config::default()).await; + let app = router(state); + let missing = app + .oneshot( + Request::builder() + .uri("/no-such-page") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + let missing = response_text(missing).await; + assert!(missing.contains(""), "{missing}"); + assert!(missing.contains("The Daily EPUB"), "{missing}"); + assert!(missing.contains("That page does not exist"), "{missing}"); + + let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response(); + assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR); + let failed = response_text(failed).await; + assert!(failed.contains(""), "{failed}"); + assert!(failed.contains("The Daily EPUB"), "{failed}"); + assert!( + failed.contains("The request could not be completed"), + "{failed}" + ); + assert!(!failed.contains("fixture failure"), "{failed}"); + } } diff --git a/src/web/static/app.css b/src/web/static/app.css index fad3927..88ac7dd 100644 --- a/src/web/static/app.css +++ b/src/web/static/app.css @@ -39,7 +39,7 @@ label { display:grid; gap:.25rem; } input,textarea,button { font:inherit; padding:.45rem; } .error { color:var(--down); } .dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; } -.scroll-x { overflow-x:auto; } +.scroll-x { max-width:100%; overflow-x:auto; overscroll-behavior-inline:contain; } table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; } th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; } thead { position:sticky; top:0; background:var(--bg); } @@ -106,7 +106,6 @@ details.explain { margin:1rem 0; } .picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; } .budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; } .table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; } -||||||| 849231e /* step 5: settings */ .settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; } .settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; } @@ -124,7 +123,6 @@ details.explain { margin:1rem 0; } .settings .add-provider { max-width:36rem; } .history pre { margin:0; white-space:pre-wrap; font-size:.8rem; } @media (max-width:40rem) { .setting { display:block; } } -||||||| d36f203 /* step 6: jobs and stats */ .sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; } .spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; } @@ -137,3 +135,23 @@ details.explain { margin:1rem 0; } .job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; } td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; } pre.journal { max-height:40rem; } +/* step 7: dark-mode and narrow-screen polish */ +.primary,.admin { display:flex; flex-wrap:wrap; justify-content:center; gap:.35rem 1rem; } +.masthead { line-height:1.15; overflow-wrap:anywhere; } +.scroll-x > table { min-width:max-content; } +input,textarea,select,button { max-width:100%; border-color:var(--rule); background:var(--bg); color:var(--fg); } +button { border-style:solid; } +input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } +code,pre,.message { overflow-wrap:anywhere; } +.prev-next a { overflow-wrap:anywhere; } +@media (max-width:40rem) { + body { padding-inline:.75rem; } + .masthead { margin-top:.75rem; padding-inline:.25rem; } + .primary,.admin { gap:.25rem .75rem; line-height:1.35; } + .dashboard,.reading { margin-block:1.25rem; } + .cards { grid-template-columns:minmax(0,1fr); } + .card { padding:.65rem .75rem; } + .prev-next { display:flex; flex-wrap:wrap; justify-content:space-between; } + .rating { align-items:stretch; } + .rating-prompt,.rating-note { flex-basis:100%; } +} diff --git a/src/web/static/app.js b/src/web/static/app.js index 92949f8..dfebedc 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -56,7 +56,7 @@ document.querySelectorAll("table[data-filter]").forEach((table) => { row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle); }); }); -||||||| 849231e +}); /* step 5: settings — "reset to default" fills the field with its default */ document.addEventListener("click", (event) => { const button = event.target.closest("button[data-reset]"); diff --git a/src/web/templates/_signals_table.html b/src/web/templates/_signals_table.html index b422869..0f215d0 100644 --- a/src/web/templates/_signals_table.html +++ b/src/web/templates/_signals_table.html @@ -1,6 +1,6 @@ {% if signals.empty %}

No signals recorded for this row (hygiene exclusion or thin telemetry).

{% else %}
- -{% for line in signals.lines %}{% endfor %}
signalrawnormweightpresent
{{ line.name }}{{ line.raw }}{{ line.norm }}{{ line.weight }}{% if line.present %}yes{% else %}absent{% endif %}
+
+{% for line in signals.lines %}{% endfor %}
signalrawnormweightpresent
{{ line.name }}{{ line.raw }}{{ line.norm }}{{ line.weight }}{% if line.present %}yes{% else %}absent{% endif %}

Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · exploration{% endif %}{% if signals.auto_include %} · auto-include{% endif %}

{% if !signals.top_interests.is_empty() %}

Top interests

    {% for interest in signals.top_interests %}
  • {{ interest.name }} · z {{ interest.z }} · cos {{ interest.cos }}
  • {% endfor %}
{% endif %} {% if !signals.neighbours.is_empty() %}

Nearest rated neighbours

    {% for neighbour in signals.neighbours %}
  • {{ neighbour.label }} {{ neighbour.title }} · cos {{ neighbour.cos }}
  • {% endfor %}
{% endif %} diff --git a/src/web/templates/dashboard/users.html b/src/web/templates/dashboard/users.html new file mode 100644 index 0000000..8ca09b6 --- /dev/null +++ b/src/web/templates/dashboard/users.html @@ -0,0 +1,15 @@ +{% extends "layout.html" %}{% block content %}
+

Users

+

Accounts are read-only here. Create, change, disable, enable, or sign out users with the daily-epub users CLI on the server.

+
+ +{% for user in users %} + + + + + + +{% endfor %}{% if users.is_empty() %}{% endif %} +
UsernameRoleStatusCreatedLast loginOpen sessions
{{ user.username }}{{ user.role }}{% if user.disabled %}disabled{% else %}enabled{% endif %}{{ user.created }}{{ user.last_login }}{{ user.open_sessions }}
No users. Bootstrap an admin with daily-epub users add <username> --admin.
+
{% endblock %} diff --git a/src/web/templates/issue_full.html b/src/web/templates/issue_full.html index dc06407..1bd3124 100644 --- a/src/web/templates/issue_full.html +++ b/src/web/templates/issue_full.html @@ -4,7 +4,7 @@

The Brief

{{ front_page_html|safe }}
-{% if !downloads.is_empty() %}

{% for download in downloads %}Download {{ download.label }} ({{ download.size_bytes }} bytes){% endfor %}

{% endif %} +{% if !downloads.is_empty() %}

{% for download in downloads %}Download {{ download.label }} ({{ download.size }}){% endfor %}

{% endif %}

In This Issue

{% for section in sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • diff --git a/src/web/templates/issue_public.html b/src/web/templates/issue_public.html index ae8642f..aff3194 100644 --- a/src/web/templates/issue_public.html +++ b/src/web/templates/issue_public.html @@ -2,6 +2,6 @@ {% if empty %}

    No issue yet

    The first issue has not been published.

    {% else %}

    {{ issue.stats_line }}

    A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.

    -{% if !downloads.is_empty() %}

    {% for download in downloads %}{{ download.label }} ({{ download.size_bytes }} bytes){% endfor %}

    {% endif %} +{% if !downloads.is_empty() %}

    {% for download in downloads %}{{ download.label }} ({{ download.size }}){% endfor %}

    {% endif %} {% for section in issue.sections %}

    {{ section.name }}

    {% for entry in section.entries %}

    {{ entry.title }}

    {% if !entry.comment_links.is_empty() %}

    {% for link in entry.comment_links %}{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}{% endfor %}

    {% endif %}{% endfor %}
    {% endfor %}

    Browse the archive

    {% endif %}{% endblock %} diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html index a844ac6..c9d1b93 100644 --- a/src/web/templates/layout.html +++ b/src/web/templates/layout.html @@ -10,8 +10,8 @@
    The Daily EPUB
    - - {% if page.is_admin() %}{% endif %} + + {% if page.is_admin() %}{% endif %} {% match page.flash %}{% when Some with (flash) %}
    {{ flash.text }}
    {% when None %}{% endmatch %}
    {% block content %}{% endblock %}