From 572056ce3be94f5cd03d83e42caf1aec0d0d456f Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Thu, 3 Sep 2026 17:49:48 +0000 Subject: [PATCH] Web dashboard step 5: settings page with toml_edit writer Schema derived from Config::default() and the live config, help text for every shipped key, env locks and presence-only secrets, typed saves that preserve comments and validate through Config::load before an atomic rename, provider add/remove, the change history, and config reload on mtime. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM --- .../briefs/web-dashboard/handoff-step5.md | 62 + ...hboard-implementation-review-2026-09-03.md | 92 + src/web/dashboard/settings.rs | 2580 ++++++++++++++++- src/web/mod.rs | 36 + src/web/static/app.css | 17 + src/web/static/app.js | 8 + src/web/templates/dashboard/settings.html | 41 + .../templates/dashboard/settings_history.html | 18 + systemd/daily-epub.service | 5 +- 9 files changed, 2856 insertions(+), 3 deletions(-) create mode 100644 docs/plans/briefs/web-dashboard/handoff-step5.md create mode 100644 docs/reviews/2026-09-03-web-dashboard-implementation-review-2026-09-03.md create mode 100644 src/web/templates/dashboard/settings.html create mode 100644 src/web/templates/dashboard/settings_history.html diff --git a/docs/plans/briefs/web-dashboard/handoff-step5.md b/docs/plans/briefs/web-dashboard/handoff-step5.md new file mode 100644 index 0000000..7f5ade0 --- /dev/null +++ b/docs/plans/briefs/web-dashboard/handoff-step5.md @@ -0,0 +1,62 @@ +# Step 5 handoff — settings + +## Landed + +- `src/web/dashboard/settings.rs`: a schema derived by walking serialized + default and live `Config` values, including optional/env-only fields, exact + field kinds and enum choices, derived environment names, source tracking, + README/example-backed help text, stable group order, and dotted-path anchors. +- `/dashboard/settings`: reload-on-mtime with a visible load-error banner and + last-good config retention; secret presence-only rendering, environment + locks, typed inputs, defaults/reset controls, and weight normalization notes. +- Typed `toml_edit` saves that preserve comments/order, collect field errors, + write multiline string arrays, create missing tables, validate through + `Config::load` in `.tmp.`, preserve permissions, rename atomically, + swap the live config, and record one attributed `config_changes` row per key. +- Provider add/remove flows through the same validated writer, including name + and kind validation, role-reference refusal, placeholder defaults, audit + history, and redaction of a hand-written provider `api_key` from history. +- `/dashboard/settings/history`, newest first at 100 rows per page. +- A step 5 CSS block and reset helper in `app.js`; `/etc/daily-epub` added to + the server unit's `ReadWritePaths`. +- Sixteen settings tests covering every schema and writer case listed in §17, + including router-level rendering and POST behavior through `oneshot`. + +## Deviations and notes + +- Shipped providers cannot be removed, even when unreferenced. They are members + of `Config::default()`, so deleting their TOML table would immediately restore + the built-in entry and falsely report success. They can be edited or left + unreferenced; custom providers can be removed normally. +- `FieldKind::Enum` owns `Vec` rather than the plan sketch's static + slice because `llm.bulk` and `llm.editor` must include provider names derived + at runtime. The rendered behavior and validation table are unchanged. +- Restart notices also cover `server.public_url`, `server.session_days`, + `server.login_attempts`, and `server.login_window_minutes`, in addition to + the plan sketch's `database_path` and `server.bind`, because those values are + captured when the server/session/throttle layers are built. +- An absent key whose effective value is already the submitted default remains + absent. This reconciles “only changed fields” with the no-JavaScript form + posting every editable field; resetting an explicit non-default file value + still writes the default explicitly. +- Step 6 should call `WebState::reload_if_changed` before starting each job, as + §4.2 requires. Step 5 supplies the shared helper; this branch's jobs module is + intentionally still the parallel-step stub. +- The shared brief's sandbox list omits three pre-existing OpenAI fake-server + tests that bind through the same `curate::llm::tests::serve` helper as its four + named Anthropic tests. The step 1 handoff records those three additional + sandbox failures; they are not settings regressions. + +## Verification + +- `cargo fmt`: pass. +- `cargo clippy --all-targets -- -D warnings`: pass. +- Focused `cargo test web::dashboard::settings -- --nocapture`: **16 passed, + 0 failed**. +- Unfiltered `cargo test`: **379 library tests passed** before the harness + reported the expected loopback-bind failures (the 10 named library tests plus + the three OpenAI tests noted above); no settings test failed. +- `cargo test` with all pre-existing loopback listener tests excluded: + **411 passed, 0 failed, 15 filtered out** (379 library, 7 binary-unit, and 25 + integration tests passed; 13 library listener tests and both `m7_server` + tests were filtered). diff --git a/docs/reviews/2026-09-03-web-dashboard-implementation-review-2026-09-03.md b/docs/reviews/2026-09-03-web-dashboard-implementation-review-2026-09-03.md new file mode 100644 index 0000000..310e517 --- /dev/null +++ b/docs/reviews/2026-09-03-web-dashboard-implementation-review-2026-09-03.md @@ -0,0 +1,92 @@ +# Web dashboard step 5 implementation review + +The final step 5 working tree implements the settings schema, editor, provider +operations, history page, reload-on-mtime behavior, and systemd write access +described by the plan. The implementation is well covered by behavior-focused +unit and router tests. The review found one security issue in the inherited +work—removing a provider could have copied a hand-written `api_key` into +`config_changes`—and fixed it with redaction plus a regression assertion. + +## Critical + +None. + +## High + +None. The provider-history secret exposure found during review is fixed in +`provider_literal`: `api_key` is removed before a whole-provider literal is +stored or rendered by the history page. + +## Medium + +None. + +## Low + +### Shipped provider removal differs from the literal plan wording + +- Evidence: `ProviderCard::removable` and `remove_provider` refuse entries in + `default_providers()`, even when no `[llm]` role references them. +- Plan difference: §13.3 only explicitly requires refusal when an `[llm]` role + names the provider. +- Reason and recommendation: deleting a shipped provider's file table cannot + remove it from the effective config because `Config::default()` supplies it + again; pretending otherwise would reset it while reporting removal. Keep the + explicit refusal unless config loading later gains a provider tombstone or + replacement-map semantic. + +### Absent defaults are not materialized by an otherwise unchanged form + +- Evidence: `plan_changes` compares an absent file value through its effective + default before deciding whether to write. +- Plan difference: §13.2's “explicit beats implicit” sentence can be read as + requiring an absent default-valued key to be written whenever posted. +- Reason and recommendation: the same section says the form carries every + editable field and only changed fields are written. Materializing every + absent default on any no-JavaScript save would violate that behavior. Keep + the effective-value comparison; a reset from an explicit non-default value + still writes the default explicitly. Clarify this sentence in a future plan + revision if touched-vs-untouched browser state becomes a requirement. + +## Nits + +None. + +## Plan Coverage + +| Requirement | Status | Evidence | +|---|---|---| +| Derived schema, kinds, sources, help, group order and anchors | Implemented as planned | `schema`, `schema_with_env`, `walk`, `kind_for`, `SETTINGS_HELP` | +| Secret redaction and environment locks | Implemented as planned | `source_for`, secret field rendering, provider-literal redaction | +| Typed `toml_edit` save with collected errors | Implemented as planned | `plan_changes`, `apply_change` | +| Temp-file validation, permission preservation, rename and live swap | Implemented as planned | `write_validated`, `install` | +| One attributed history row per changed key | Implemented as planned | `record_changes`, `config_changes` | +| Provider add/remove and referenced-provider refusal | Implemented with the shipped-provider qualification above | `add_provider`, `remove_provider` | +| Reload on mtime with previous config retained after an error | Implemented as planned | `WebState::reload_if_changed`, settings GET banner | +| Settings history page, newest first, 100 per page | Implemented as planned | `history`, `config_changes`, `settings_history.html` | +| `/etc/daily-epub` writable in the server unit | Implemented as planned | `systemd/daily-epub.service` | + +## Testing Assessment + +Existing tests are meaningful: they compare the schema with serialized +`Config::default()`, compare shipped TOML leaves with the help table, exercise +all enum options through deserialization and `validate()`, prove secrets carry +no value, mutate and restore a real environment variable, compare untouched +configuration lines byte-for-byte, verify multiline arrays and new tables, +exercise validation failure and permission preservation, inspect persisted +history rows, add/remove/refuse providers, and drive the settings routes through +`Router::oneshot`. The provider test also proves a file-sourced API key does not +reach the audit table. + +No weak or missing test from the step 5 list remains. The only environmental +suite limitation is the repository's pre-existing listener tests, which cannot +bind loopback in the sandbox. + +## Open Questions + +- Should the plan eventually define a tombstone for removing shipped providers, + or should the current “leave built-ins unreferenced” behavior become the + documented contract? +- The shared brief lists four Anthropic listener tests as sandbox-bound, while + the step 1 handoff also identifies three OpenAI tests using the same loopback + fake server. The latter fail with the identical sandbox permission error. diff --git a/src/web/dashboard/settings.rs b/src/web/dashboard/settings.rs index 19a9939..7c71cec 100644 --- a/src/web/dashboard/settings.rs +++ b/src/web/dashboard/settings.rs @@ -1,10 +1,2588 @@ -//! Dashboard: settings pages. Filled in by web dashboard plan step 5. +//! Dashboard: settings pages (web dashboard plan §13). +//! +//! The schema is *derived*: [`schema`] walks `toml::Value::try_from(Config::default())` +//! and the live config in parallel, so a new key in [`Config`] shows up on the +//! page without UI work. Saving goes through `toml_edit` so comments and key +//! order in `config.toml` survive, and every write is validated by +//! [`Config::load`] on a temp file before it is renamed over the original. +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use askama::Template; +use axum::Form; use axum::Router; +use axum::body::Bytes; +use axum::extract::{Extension, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum_login::tower_sessions::Session; +use serde::Deserialize; +use sqlx::Row; +use toml_edit::{Array, DocumentMut, Item, Table, Value}; +use crate::config::{ + Config, ENV_PREFIX, ENV_SECRET_ALIAS, ENV_SPLIT, ProviderConfig, ProviderKind, + default_providers, +}; use crate::server::AppState; +use crate::web::session::{AuthSession, Viewer}; +use crate::web::{Flash, Html, Page, Pagination, WebError, WebState, format_time, take_flash}; /// Routes contributed by this page group (merged by `dashboard::router`). pub fn routes() -> Router { Router::new() + .route("/dashboard/settings", get(show).post(save)) + .route( + "/dashboard/settings/providers", + axum::routing::post(providers), + ) + .route("/dashboard/settings/history", get(history)) +} + +// --------------------------------------------------------------------------- +// Schema (§13.1) +// --------------------------------------------------------------------------- + +/// How a leaf renders and how a posted value is typed before it is written. +#[derive(Debug, Clone, PartialEq)] +pub enum FieldKind { + Bool, + Integer, + Float, + Text, + /// An array of strings, edited one item per line. + TextList, + /// A fixed set of accepted values (an empty string means "absent" for + /// optional keys such as `providers.*.effort`). + Enum(Vec), + /// Never rendered, never written; shown as set / not set. + Secret, + Path, +} + +/// Where the live value of a key comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Source { + Default, + File, + /// Overridden by this environment variable; the field is locked. + Env(String), +} + +#[derive(Debug, Clone)] +pub struct SettingField { + /// Dotted path, e.g. `curation.ranking.deep_keep`. + pub path: String, + /// The enclosing table, e.g. `curation.ranking` (empty at the top level). + pub group: String, + /// The last path segment. + pub key: String, + pub kind: FieldKind, + /// Rendered live value (`TextList`: one item per line; `Secret`: never the value). + pub current: String, + pub default: String, + pub source: Source, + pub help: Option<&'static str>, + pub restart_required: bool, +} + +impl SettingField { + pub fn input_id(&self) -> String { + format!("f-{}", self.path.replace('.', "-")) + } + + pub fn is_secret(&self) -> bool { + self.kind == FieldKind::Secret + } + + pub fn is_env(&self) -> bool { + matches!(self.source, Source::Env(_)) + } + + pub fn env_var(&self) -> String { + match &self.source { + Source::Env(name) => name.clone(), + _ => env_name(&self.path), + } + } + + pub fn is_file(&self) -> bool { + self.source == Source::File + } + + pub fn is_bool(&self) -> bool { + self.kind == FieldKind::Bool + } + + pub fn is_text_list(&self) -> bool { + self.kind == FieldKind::TextList + } + + pub fn is_number(&self) -> bool { + matches!(self.kind, FieldKind::Integer | FieldKind::Float) + } + + pub fn is_integer(&self) -> bool { + self.kind == FieldKind::Integer + } + + pub fn options(&self) -> &[String] { + match &self.kind { + FieldKind::Enum(options) => options, + _ => &[], + } + } + + pub fn is_enum(&self) -> bool { + matches!(self.kind, FieldKind::Enum(_)) + } + + /// Whether the value is set (only meaningful for secrets). + pub fn is_set(&self) -> bool { + self.current == "set" + } + + pub fn kind_name(&self) -> &'static str { + match self.kind { + FieldKind::Bool => "bool", + FieldKind::Integer => "integer", + FieldKind::Float => "float", + FieldKind::Text => "text", + FieldKind::TextList => "list", + FieldKind::Enum(_) => "enum", + FieldKind::Secret => "secret", + FieldKind::Path => "path", + } + } +} + +#[derive(Debug, Clone)] +pub struct SettingGroup { + /// The anchor: the dotted path of the table (`general` at the top level). + pub id: String, + pub title: String, + pub fields: Vec, + /// Set for `providers.` cards. + pub provider: Option, +} + +impl SettingGroup { + pub fn is_weights(&self) -> bool { + self.id.starts_with("curation.ranking.weights.") + } +} + +#[derive(Debug, Clone)] +pub struct ProviderCard { + pub name: String, + /// The `[llm]` roles that name this provider; non-empty ⇒ cannot be removed. + pub referenced_by: Vec<&'static str>, + /// Declared by `Config::default()` (the shipped registry): removing its + /// table from the file would not remove the provider, so it cannot be + /// removed here. + pub built_in: bool, +} + +impl ProviderCard { + pub fn roles(&self) -> String { + self.referenced_by.join(", ") + } + + pub fn removable(&self) -> bool { + self.referenced_by.is_empty() && !self.built_in + } +} + +/// Keys whose new value only takes effect after the server restarts (they are +/// read once when the listener, the session layer or the login throttle are +/// built). +pub const RESTART_REQUIRED: &[&str] = &[ + "database_path", + "server.bind", + "server.public_url", + "server.session_days", + "server.login_attempts", + "server.login_window_minutes", +]; + +/// Optional keys that `Config::default()` leaves unset (and therefore do not +/// appear when the defaults are serialized), with the kind they take. +const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[ + ("miniflux.api_key", FieldKind::Secret), + ("providers.*.api_key", FieldKind::Secret), + ("providers.*.effort", FieldKind::Text), + ("voyage.api_key", FieldKind::Secret), + ("server.hmac_secret", FieldKind::Secret), + ("server.basic_auth_user", FieldKind::Text), + ("server.basic_auth_pass", FieldKind::Secret), + ("xtc.settings", FieldKind::Path), +]; + +const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass"]; + +const PATH_KEYS: &[&str] = &[ + "database_path", + "out_dir", + "profile_path", + "interests_opml", + "publish.epub_dir", + "publish.xtc_dir", + "xtc.settings", +]; + +const PROVIDER_KINDS: &[&str] = &["openai", "anthropic"]; +const ANTHROPIC_EFFORTS: &[&str] = &["", "low", "medium", "high", "xhigh", "max"]; +const SUMMARY_MODELS: &[&str] = &["editor", "bulk"]; +const XTC_FORMATS: &[&str] = &["xtc", "xtch"]; + +/// The rendering order of the group cards (§13.1 item 5); anything the walk +/// finds that is not listed here renders after them, in walk order. +const GROUP_ORDER: &[&str] = &[ + "", + "llm", + "providers.*", + "voyage", + "curation", + "curation.feedback", + "curation.ranking", + "curation.ranking.quotas", + "curation.ranking.weights.preliminary", + "curation.ranking.weights.utility", + "curation.ranking.diversity", + "editorial", + "publish", + "xtc", + "server", + "miniflux", +]; + +/// Help text per key, seeded from the README configuration table and the +/// comments in `config.example.toml`. `providers.*` entries cover every +/// provider table. +#[rustfmt::skip] +pub const SETTINGS_HELP: &[(&str, &str)] = &[ + ("timezone", "IANA time zone for day boundaries and --date interpretation."), + ("lookback_hours", "Size of the ingest window ending at the issue day's end (clamped to now)."), + ("target_article_count", "Soft target the editor aims for. There is no minimum: a nine-pick issue is published as nine."), + ("retention_days", "EPUBs older than this are deleted from publish.epub_dir. SQLite history is kept forever."), + ("xtc_retention_count", "How many XTC issues to keep in publish.xtc_dir. Counted, not dated: each .xtch is ~80-100 MB, so the binding constraint is disk, not age."), + ("world_briefing", "Include the Wikipedia Current Events section."), + ("database_path", "SQLite file; parent directories are created on demand."), + ("out_dir", "Where generate writes artifacts before publishing (overridden by --out)."), + ("profile_path", "Hand-maintained reader profile, loaded every run."), + ("interests_opml", "Scour interests OPML merged with the profile interests."), + ("miniflux.base_url", "Miniflux root (no /v1)."), + ("miniflux.api_key", "X-Auth-Token for Miniflux. Required; environment only."), + ("miniflux.page_limit", "Entries per page for GET /v1/entries; Miniflux caps this at 250."), + ("llm.bulk", "The [providers.*] name that runs triage, deep assessment and every fallback. Empty means no bulk provider (those stages are skipped)."), + ("llm.editor", "The provider that assembles the lineup, writes the summaries and The Brief and rebuilds the profile. Empty means everything runs on bulk. Naming the same provider as bulk shares one client and one ceiling."), + ("llm.triage_batch_size", "Articles per first-pass triage request."), + ("llm.deep_batch_size", "Articles per close-reading assessment request."), + ("llm.score_temperature", "Scoring temperature, sent only to openai-kind providers."), + ("llm.editorial_temperature", "Summaries and The Brief on an openai-kind provider."), + ("providers.*.kind", "openai (chat completions at {base_url}/chat/completions, bearer key) or anthropic (the Messages API with output_config.effort and a cached system block)."), + ("providers.*.base_url", "Endpoint root. DeepSeek https://api.deepseek.com/v1; Anthropic https://api.anthropic.com; Gemini https://generativelanguage.googleapis.com/v1beta/openai."), + ("providers.*.model", "Model name sent with every request."), + ("providers.*.api_key", "Environment only (DAILY_EPUB_PROVIDERS____API_KEY). Absent means that role is unavailable and degrades (bulk to heuristic curation, editor to bulk)."), + ("providers.*.effort", "anthropic: low, medium, high, xhigh or max (output_config.effort). openai: passed through as reasoning_effort (Gemini takes minimal-high); leave empty for models without one (DeepSeek)."), + ("providers.*.max_daily_usd", "That provider's ceiling per UTC day of the run's start, not per run. Tripping it skips the provider's remaining calls; the paper still publishes. 0 disables the guard."), + ("providers.*.max_concurrent_requests", "Triage and deep-assessment batches in flight on the bulk provider; summaries in flight on the summary provider."), + ("providers.*.price_input_per_mtok", "USD per 1M cache-miss input tokens (cost guardrail arithmetic only)."), + ("providers.*.price_cache_read_per_mtok", "USD per 1M cache-hit input tokens."), + ("providers.*.price_cache_write_per_mtok", "USD per 1M tokens written to the prompt cache (implicit caches charge nothing)."), + ("providers.*.price_output_per_mtok", "USD per 1M output tokens, thinking tokens included where the provider bills them as output."), + ("voyage.enabled", "Embed articles and interests with Voyage AI. Off means cached vectors only; the learned signals are simply absent, never a penalty."), + ("voyage.base_url", "POST {base_url}/embeddings."), + ("voyage.model", "Embedding model; changing it invalidates the cache."), + ("voyage.api_key", "Environment only (DAILY_EPUB_VOYAGE__API_KEY). Absent means cached vectors only."), + ("voyage.output_dimension", "One of 256, 512, 1024, 2048."), + ("voyage.batch_size", "Texts per request."), + ("voyage.max_concurrent_requests", "Requests in flight."), + ("voyage.max_input_chars", "Per-article cut, on a char boundary."), + ("voyage.max_daily_usd", "Runaway guard at $0.02 per 1M tokens."), + ("curation.max_article_count", "Hard ceiling on issue size; there is no minimum. Must be >= target_article_count."), + ("curation.recent_rejection_days", "Churn window for recent low triage/deep assessments."), + ("curation.recent_rejection_floor", "Scores below this floor are excluded during the churn window (except auto-includes)."), + ("curation.always_include_feeds", "Miniflux feed ids or URL substrings that can never be dropped. One per line."), + ("curation.blocked_domains", "Hosts excluded outright. One per line."), + ("curation.paywall_domains", "Extra paywalled hosts, merged with the built-in list (nytimes, wsj, ft, economist, ...). A short body from one of these is marked excerpt-only and penalized. One per line."), + ("curation.sections", "The only section names the editor may use, one per line. World Briefing is reserved and never offered."), + ("curation.feedback.loved_value", "Weight for a Loved it verdict."), + ("curation.feedback.good_value", "Weight for a Good verdict."), + ("curation.feedback.not_for_me_value", "Weight for a Not for me verdict."), + ("curation.feedback.verdicts_in_prompt", "Recent explicit verdicts included in the system prompt."), + ("curation.ranking.triage_max", "Eligible articles the triage LLM reads."), + ("curation.ranking.deep_keep", "Size of the deep-assessment set. Must be >= shortlist_keep."), + ("curation.ranking.shortlist_keep", "What the editor sees. Must be >= target_article_count."), + ("curation.ranking.assessment_reuse_days", "Days a stored deep assessment is reused instead of re-asked."), + ("curation.ranking.rating_lookback_days", "How far back explicit ratings count."), + ("curation.ranking.rating_half_life_days", "Ratings decay with this half-life."), + ("curation.ranking.neighbour_k", "Rated neighbours per side for the knn signal."), + ("curation.ranking.negative_coefficient", "How strongly Not for me neighbours pull a candidate down."), + ("curation.ranking.knn_floor", "Rated articles with embeddings before the knn signal starts to count."), + ("curation.ranking.knn_full", "Rated articles at which the knn signal reaches full weight. Must be > knn_floor."), + ("curation.ranking.feed_floor", "Attributable ratings before the feed-affinity signal starts to count."), + ("curation.ranking.feed_full", "Attributable ratings at which feed affinity reaches full weight. Must be > feed_floor."), + ("curation.ranking.semantic_min_words", "Bodies shorter than this are not embedded."), + ("curation.ranking.exploration_slots", "Shortlist slots reserved for exploration picks."), + ("curation.ranking.embedding_retention_days", "features prune: unrated, unpublished vectors older than this are deleted."), + ("curation.ranking.telemetry_retention_days", "features prune: candidate_runs and article_assessments older than this are deleted."), + ("curation.ranking.quotas.triage", "Deep-set slots filled by triage score."), + ("curation.ranking.quotas.interest", "Deep-set slots filled by interest similarity."), + ("curation.ranking.quotas.knn", "Deep-set slots filled by rated-neighbour preference."), + ("curation.ranking.weights.preliminary.interest", "Interest similarity in the preliminary blend."), + ("curation.ranking.weights.preliminary.knn", "Rated-neighbour preference in the preliminary blend."), + ("curation.ranking.weights.preliminary.heuristic", "Heuristic score in the preliminary blend."), + ("curation.ranking.weights.preliminary.feed", "Feed affinity in the preliminary blend."), + ("curation.ranking.weights.preliminary.social", "Social signal in the preliminary blend."), + ("curation.ranking.weights.utility.quality", "Deep-assessment quality in the utility score."), + ("curation.ranking.weights.utility.fit", "Deep-assessment fit in the utility score."), + ("curation.ranking.weights.utility.knn", "Rated-neighbour preference in the utility score."), + ("curation.ranking.weights.utility.interest", "Interest similarity in the utility score."), + ("curation.ranking.weights.utility.feed", "Feed affinity in the utility score."), + ("curation.ranking.weights.utility.triage", "Triage score in the utility score."), + ("curation.ranking.weights.utility.social", "Social signal in the utility score."), + ("curation.ranking.weights.utility.heuristic", "Heuristic score in the utility score."), + ("curation.ranking.diversity.cluster_threshold", "Cosine similarity at which two candidates share a cluster (0-1)."), + ("curation.ranking.diversity.per_cluster_cap", "Shortlist picks allowed per cluster."), + ("curation.ranking.diversity.utility_protected", "Top-utility candidates exempt from the cluster cap."), + ("editorial.summary_model", "Which [llm] role writes the per-article summaries: editor (with per-article bulk fallback) or bulk."), + ("editorial.summary_input_tokens", "Article text offered to the summary prompt."), + ("publish.epub_dir", "Both EPUB editions land here by atomic copy, and this is the directory the OPDS feed lists. Point a BookOrbit watched folder at it if you want its UI too."), + ("publish.xtc_dir", "XTC artifacts. Not listed in the OPDS feed, but downloadable at /files/xtc/ for sideloading."), + ("xtc.enabled", "Off skips the converter entirely."), + ("xtc.command", "Converter executable (the converter has no global npm bin, so it runs through node)."), + ("xtc.args", "Prefix arguments, one per line; the code appends -o -f (plus -c )."), + ("xtc.format", "xtc (1-bit) or xtch (2-bit grayscale, better images, ~96 KB per rendered page)."), + ("xtc.settings", "Settings JSON passed as -c. Required in practice: without font.path the converter exits before doing any work. Start from xtc-settings.example.json."), + ("server.bind", "Listen address."), + ("server.public_url", "Base URL the rating links inside the EPUB are built from."), + ("server.hmac_secret", "Environment only (DAILY_EPUB_SERVER__HMAC_SECRET or DAILY_EPUB_SECRET). Without it, rating links are rejected with 403."), + ("server.basic_auth_user", "Optional Basic auth user for /opds/* and /files/*; signed-in web users may download without it."), + ("server.basic_auth_pass", "Password for server.basic_auth_user. Environment only."), + ("server.session_days", "Sliding lifetime for dashboard login sessions."), + ("server.login_attempts", "Login attempts allowed per IP in one throttle window."), + ("server.login_window_minutes", "Length of the login throttle window."), + ("server.jobs_enabled", "Allow the dashboard to start the fixed systemd job catalogue."), + ("server.journal_lines", "Journal lines shown on a dashboard job page (10-5000)."), +]; + +/// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2). +pub fn env_name(path: &str) -> String { + format!( + "{ENV_PREFIX}{}", + path.to_uppercase().replace('.', ENV_SPLIT) + ) +} + +fn matches_pattern(pattern: &str, path: &str) -> bool { + let mut pattern = pattern.split('.'); + let mut path = path.split('.'); + loop { + match (pattern.next(), path.next()) { + (None, None) => return true, + (Some(p), Some(segment)) if p == "*" || p == segment => continue, + _ => return false, + } + } +} + +/// The help entry for a key (`providers..x` matches `providers.*.x`). +pub fn help_for(path: &str) -> Option<&'static str> { + SETTINGS_HELP + .iter() + .find(|(pattern, _)| matches_pattern(pattern, path)) + .map(|(_, help)| *help) +} + +fn optional_kind(path: &str) -> Option<&'static FieldKind> { + OPTIONAL_KEYS + .iter() + .find(|(pattern, _)| matches_pattern(pattern, path)) + .map(|(_, kind)| kind) +} + +fn is_optional(path: &str) -> bool { + optional_kind(path).is_some() +} + +/// Render a TOML value the way the form shows it (arrays one item per line). +fn render_toml(value: &toml::Value) -> String { + match value { + toml::Value::String(s) => s.clone(), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => fmt_float(*f), + toml::Value::Boolean(b) => b.to_string(), + toml::Value::Array(items) => items.iter().map(render_toml).collect::>().join("\n"), + other => other.to_string(), + } +} + +/// At most six decimals, trailing zeros trimmed, always one decimal digit. +pub fn fmt_float(value: f64) -> String { + let text = format!("{:.6}", round6(value)); + let trimmed = text.trim_end_matches('0'); + if trimmed.ends_with('.') { + format!("{trimmed}0") + } else { + trimmed.to_string() + } +} + +fn round6(value: f64) -> f64 { + (value * 1_000_000.0).round() / 1_000_000.0 +} + +/// `has_default`: `Config::default()` holds a value for this key, so an +/// optional key cannot be unset through the file (the default would fill it +/// back in) and the "(none)" option is not offered. +fn kind_for( + path: &str, + config: &Config, + sample: Option<&toml::Value>, + has_default: bool, +) -> FieldKind { + let last = path.rsplit('.').next().unwrap_or(path); + if SECRET_SUFFIXES.contains(&last) { + return FieldKind::Secret; + } + let segments: Vec<&str> = path.split('.').collect(); + if let ["providers", name, key] = segments.as_slice() { + match *key { + "kind" => return FieldKind::Enum(strings(PROVIDER_KINDS)), + "effort" => { + let anthropic = config + .providers + .get(*name) + .is_some_and(|provider| provider.kind == ProviderKind::Anthropic); + return if anthropic { + let offered = if has_default { + &ANTHROPIC_EFFORTS[1..] + } else { + ANTHROPIC_EFFORTS + }; + FieldKind::Enum(strings(offered)) + } else { + FieldKind::Text + }; + } + _ => {} + } + } + match path { + "editorial.summary_model" => return FieldKind::Enum(strings(SUMMARY_MODELS)), + "xtc.format" => return FieldKind::Enum(strings(XTC_FORMATS)), + "llm.bulk" => return FieldKind::Enum(config.providers.keys().cloned().collect()), + "llm.editor" => { + let mut options = vec![String::new()]; + options.extend(config.providers.keys().cloned()); + return FieldKind::Enum(options); + } + _ => {} + } + if PATH_KEYS.contains(&path) { + return FieldKind::Path; + } + match sample { + Some(toml::Value::Boolean(_)) => FieldKind::Bool, + Some(toml::Value::Integer(_)) => FieldKind::Integer, + Some(toml::Value::Float(_)) => FieldKind::Float, + Some(toml::Value::Array(_)) => FieldKind::TextList, + Some(_) => FieldKind::Text, + None => optional_kind(path).cloned().unwrap_or(FieldKind::Text), + } +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +/// Look a dotted path up in a parsed document. +fn file_item<'a>(doc: &'a DocumentMut, path: &str) -> Option<&'a Item> { + let mut item = doc.as_item(); + for segment in path.split('.') { + item = item.get(segment)?; + } + Some(item) +} + +fn config_table(config: &Config) -> toml::Table { + match toml::Value::try_from(config) { + Ok(toml::Value::Table(table)) => table, + Ok(_) => toml::Table::new(), + Err(error) => { + tracing::error!(%error, "serializing the configuration for the settings page failed"); + toml::Table::new() + } + } +} + +/// Derive the settings schema (§13.1) for the live `config`, with `file` the +/// parsed `config.toml` (for the `File` source), reading the process +/// environment for `Env` sources. +pub fn schema(config: &Config, file: Option<&DocumentMut>) -> Vec { + schema_with_env(config, file, &|name| std::env::var_os(name).is_some()) +} + +/// [`schema`] with the environment lookup injected (tests). +pub fn schema_with_env( + config: &Config, + file: Option<&DocumentMut>, + env_is_set: &dyn Fn(&str) -> bool, +) -> Vec { + let defaults = config_table(&Config::default()); + let current = config_table(config); + let mut groups = Vec::new(); + walk( + "", + Some(&defaults), + Some(¤t), + config, + file, + env_is_set, + &mut groups, + ); + let rank = |group: &SettingGroup| { + let path = if group.id == "general" { "" } else { &group.id }; + GROUP_ORDER + .iter() + .position(|pattern| matches_pattern(pattern, path)) + .unwrap_or(GROUP_ORDER.len()) + }; + groups.sort_by_key(rank); + groups +} + +fn walk( + prefix: &str, + defaults: Option<&toml::Table>, + current: Option<&toml::Table>, + config: &Config, + file: Option<&DocumentMut>, + env_is_set: &dyn Fn(&str) -> bool, + groups: &mut Vec, +) { + let join = |key: &str| { + if prefix.is_empty() { + key.to_string() + } else { + format!("{prefix}.{key}") + } + }; + let mut keys: Vec = Vec::new(); + for table in [defaults, current].into_iter().flatten() { + for key in table.keys() { + if !keys.contains(key) { + keys.push(key.clone()); + } + } + } + for (pattern, _) in OPTIONAL_KEYS { + let (table, key) = pattern.rsplit_once('.').unwrap_or(("", pattern)); + if matches_pattern(table, prefix) && !keys.iter().any(|existing| existing == key) { + keys.push(key.to_string()); + } + } + + let mut fields = Vec::new(); + let mut subtables = Vec::new(); + for key in keys { + let path = join(&key); + let dv = defaults.and_then(|table| table.get(&key)); + let cv = current.and_then(|table| table.get(&key)); + if matches!(dv, Some(toml::Value::Table(_))) || matches!(cv, Some(toml::Value::Table(_))) { + subtables.push(( + path, + dv.and_then(toml::Value::as_table), + cv.and_then(toml::Value::as_table), + )); + continue; + } + let kind = kind_for(&path, config, dv.or(cv), dv.is_some()); + let source = source_for(&path, file, env_is_set); + let (current_text, default_text) = if kind == FieldKind::Secret { + let set = cv + .and_then(toml::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()); + ( + if set { "set" } else { "not set" }.to_string(), + String::new(), + ) + } else { + ( + cv.map(render_toml).unwrap_or_default(), + dv.map(render_toml).unwrap_or_default(), + ) + }; + fields.push(SettingField { + key: key.clone(), + group: prefix.to_string(), + kind, + current: current_text, + default: default_text, + source, + help: help_for(&path), + restart_required: RESTART_REQUIRED.contains(&path.as_str()), + path, + }); + } + if !fields.is_empty() { + let provider = prefix + .strip_prefix("providers.") + .filter(|name| !name.contains('.')) + .map(|name| ProviderCard { + name: name.to_string(), + referenced_by: config + .llm + .roles() + .into_iter() + .filter(|(_, provider)| *provider == name) + .map(|(role, _)| role) + .collect(), + built_in: default_providers().contains_key(name), + }); + groups.push(SettingGroup { + id: if prefix.is_empty() { + "general".to_string() + } else { + prefix.to_string() + }, + title: if prefix.is_empty() { + "General".to_string() + } else { + format!("[{prefix}]") + }, + fields, + provider, + }); + } + for (path, dv, cv) in subtables { + walk(&path, dv, cv, config, file, env_is_set, groups); + } +} + +fn source_for(path: &str, file: Option<&DocumentMut>, env_is_set: &dyn Fn(&str) -> bool) -> Source { + let name = env_name(path); + if env_is_set(&name) { + return Source::Env(name); + } + if path == "server.hmac_secret" && env_is_set(ENV_SECRET_ALIAS) { + return Source::Env(ENV_SECRET_ALIAS.to_string()); + } + if file.and_then(|doc| file_item(doc, path)).is_some() { + Source::File + } else { + Source::Default + } +} + +// --------------------------------------------------------------------------- +// Writer (§13.2) +// --------------------------------------------------------------------------- + +/// A posted value after typing by [`FieldKind`]; `Absent` removes an optional key. +#[derive(Debug, Clone, PartialEq)] +enum Typed { + Bool(bool), + Int(i64), + Float(f64), + Str(String), + List(Vec), + Absent, +} + +fn parse_posted(field: &SettingField, raw: &str) -> Result, String> { + let optional = is_optional(&field.path); + let trimmed = raw.trim(); + let err = |message: &str| Err(format!("{}: {message}", field.path)); + let typed = match &field.kind { + FieldKind::Secret => return Ok(None), + FieldKind::Bool => match trimmed { + "true" | "on" | "1" => Typed::Bool(true), + "false" | "off" | "0" => Typed::Bool(false), + _ => return err("must be true or false"), + }, + FieldKind::Integer => match trimmed.parse::() { + Ok(value) => Typed::Int(value), + Err(_) => return err("must be a whole number"), + }, + FieldKind::Float => match trimmed.parse::() { + Ok(value) if value.is_finite() => Typed::Float(round6(value)), + _ => return err("must be a number"), + }, + FieldKind::Enum(options) => { + if !options.iter().any(|option| option == trimmed) { + return err(&format!( + "must be one of {}", + options + .iter() + .map(|option| if option.is_empty() { "(empty)" } else { option }) + .collect::>() + .join(", ") + )); + } + if trimmed.is_empty() && optional { + Typed::Absent + } else { + Typed::Str(trimmed.to_string()) + } + } + FieldKind::Text | FieldKind::Path => { + if trimmed.is_empty() && optional { + Typed::Absent + } else { + Typed::Str(trimmed.to_string()) + } + } + FieldKind::TextList => Typed::List( + raw.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(), + ), + }; + Ok(Some(typed)) +} + +fn typed_from_edit(value: &Value, kind: &FieldKind) -> Option { + Some(match value { + Value::Boolean(b) => Typed::Bool(*b.value()), + Value::Integer(i) if *kind == FieldKind::Float => Typed::Float(*i.value() as f64), + Value::Integer(i) => Typed::Int(*i.value()), + Value::Float(f) => Typed::Float(round6(*f.value())), + Value::String(s) => Typed::Str(s.value().clone()), + Value::Array(items) => Typed::List( + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(), + ), + Value::Datetime(_) | Value::InlineTable(_) => return None, + }) +} + +fn typed_from_toml(value: &toml::Value, kind: &FieldKind) -> Option { + Some(match value { + toml::Value::Boolean(b) => Typed::Bool(*b), + toml::Value::Integer(i) if *kind == FieldKind::Float => Typed::Float(*i as f64), + toml::Value::Integer(i) => Typed::Int(*i), + toml::Value::Float(f) => Typed::Float(round6(*f)), + toml::Value::String(s) => Typed::Str(s.clone()), + toml::Value::Array(items) => Typed::List( + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect(), + ), + _ => return None, + }) +} + +fn edit_value(typed: &Typed) -> Option { + Some(match typed { + Typed::Bool(b) => Value::from(*b), + Typed::Int(i) => Value::from(*i), + Typed::Float(f) => Value::from(*f), + Typed::Str(s) => Value::from(s.as_str()), + Typed::List(items) => { + let mut array = Array::new(); + for item in items { + array.push_formatted(Value::from(item.as_str()).decorated("\n ", "")); + } + if !items.is_empty() { + array.set_trailing_comma(true); + array.set_trailing("\n"); + } + Value::Array(array) + } + Typed::Absent => return None, + }) +} + +/// The TOML literal of a value without its surrounding whitespace or comments. +fn literal(value: &Value) -> String { + let mut bare = value.clone(); + bare.decor_mut().clear(); + bare.to_string().trim().to_string() +} + +/// One key the writer changed, with the literals for `config_changes`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangedKey { + pub key: String, + pub old: Option, + pub new: Option, +} + +/// A change planned against the file; applied by [`apply_change`]. +#[derive(Debug, Clone)] +struct PlannedChange { + path: String, + value: Typed, +} + +/// Compare every posted field with what the file holds (or the default when +/// the file lacks the key) and keep the ones that differ (§13.2 item 2). +/// Field errors are collected, never partial. +fn plan_changes( + groups: &[SettingGroup], + doc: &DocumentMut, + posted: &[(String, String)], +) -> Result, Vec> { + let defaults = config_table(&Config::default()); + let mut errors = Vec::new(); + let mut changes = Vec::new(); + for group in groups { + for field in &group.fields { + if field.is_env() || field.is_secret() { + continue; + } + // Last value wins when a name is posted twice. + let Some((_, raw)) = posted.iter().rev().find(|(name, _)| *name == field.path) else { + continue; + }; + let typed = match parse_posted(field, raw) { + Ok(Some(typed)) => typed, + Ok(None) => continue, + Err(message) => { + errors.push(message); + continue; + } + }; + let default = lookup_toml(&defaults, &field.path) + .and_then(|value| typed_from_toml(value, &field.kind)); + if typed == Typed::Absent && default.is_some() { + errors.push(format!( + "{}: the built-in default is {}; it can be changed but not unset", + field.path, field.default + )); + continue; + } + let in_file = file_item(doc, &field.path) + .and_then(Item::as_value) + .and_then(|value| typed_from_edit(value, &field.kind)); + let effective = match in_file { + Some(value) => value, + None => default.unwrap_or(Typed::Absent), + }; + if effective != typed { + changes.push(PlannedChange { + path: field.path.clone(), + value: typed, + }); + } + } + } + if errors.is_empty() { + Ok(changes) + } else { + Err(errors) + } +} + +fn lookup_toml<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> { + let mut segments = path.split('.'); + let mut value = table.get(segments.next()?)?; + for segment in segments { + value = value.as_table()?.get(segment)?; + } + Some(value) +} + +/// Walk to the table holding `path`'s leaf, creating intermediate tables as +/// implicit ones and the leaf's own table as an explicit `[header]`. +fn leaf_table<'a>(doc: &'a mut DocumentMut, path: &str) -> (&'a mut Table, String) { + let mut segments: Vec<&str> = path.split('.').collect(); + let key = segments.pop().unwrap_or_default().to_string(); + let mut table = doc.as_table_mut(); + let depth = segments.len(); + for (index, segment) in segments.into_iter().enumerate() { + let is_leaf_parent = index + 1 == depth; + let item = table.entry(segment).or_insert_with(|| { + let mut fresh = Table::new(); + fresh.set_implicit(!is_leaf_parent); + Item::Table(fresh) + }); + if !item.is_table() { + // `None`, or a value where a table is needed: replace it (the + // loader would have rejected such a file anyway). + let mut fresh = Table::new(); + fresh.set_implicit(!is_leaf_parent); + *item = Item::Table(fresh); + } + table = match item.as_table_mut() { + Some(table) => table, + None => unreachable!("item was just made a table"), + }; + if is_leaf_parent && table.is_implicit() { + table.set_implicit(false); + } + } + (table, key) +} + +/// Set one leaf in the document, keeping the key's comments and the value's +/// trailing comment; returns the before/after literals. +fn apply_change(doc: &mut DocumentMut, change: &PlannedChange) -> ChangedKey { + let (table, key) = leaf_table(doc, &change.path); + let old = table.get(&key).and_then(Item::as_value).map(literal); + let new = match edit_value(&change.value) { + None => { + table.remove(&key); + None + } + Some(mut value) => { + if let Some(existing) = table.get_mut(&key).and_then(Item::as_value_mut) { + *value.decor_mut() = existing.decor().clone(); + *existing = value; + table.get(&key).and_then(Item::as_value).map(literal) + } else { + let rendered = literal(&value); + table.insert(&key, Item::Value(value)); + Some(rendered) + } + } + }; + ChangedKey { + key: change.path.clone(), + old, + new, + } +} + +/// Parse `path` into a document; a missing file starts empty. +pub fn read_document(path: &Path) -> Result { + if !path.exists() { + return Ok(DocumentMut::new()); + } + let raw = std::fs::read_to_string(path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + DocumentMut::from_str(&raw) + .map_err(|error| format!("{} does not parse: {error}", path.display())) +} + +/// Render `doc` to `.tmp.`, validate it with [`Config::load`], +/// copy the original's permissions and rename it over the original (§13.2 +/// item 3). On any failure the original is untouched and the temp file is gone. +pub fn write_validated(path: &Path, doc: &DocumentMut) -> Result { + let tmp = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id())); + let cleanup = |tmp: &Path| { + let _ = std::fs::remove_file(tmp); + }; + std::fs::write(&tmp, doc.to_string()).map_err(|error| { + cleanup(&tmp); + format!("could not write {}: {error}", tmp.display()) + })?; + let config = match Config::load(Some(&tmp)) { + Ok(config) => config, + Err(error) => { + cleanup(&tmp); + return Err(error.to_string()); + } + }; + if let Ok(metadata) = std::fs::metadata(path) + && let Err(error) = std::fs::set_permissions(&tmp, metadata.permissions()) + { + cleanup(&tmp); + return Err(format!( + "could not copy permissions onto {}: {error}", + tmp.display() + )); + } + std::fs::rename(&tmp, path).map_err(|error| { + cleanup(&tmp); + format!("could not replace {}: {error}", path.display()) + })?; + Ok(config) +} + +/// What a save attempt produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveOutcome { + /// The file was written and the live config swapped. + Saved { + changed: Vec, + restart_required: Vec, + }, + /// Nothing was written; the messages go back to the form. + Rejected(Vec), +} + +fn config_path(state: &AppState) -> Result { + state.config_path.clone().ok_or_else(|| { + WebError::BadRequest("no config file is configured; start the server with --config".into()) + }) +} + +/// Swap the live config after a successful write and remember the new mtime. +fn install(state: &AppState, path: &Path, config: Config) { + match state.config.write() { + Ok(mut live) => *live = std::sync::Arc::new(config), + Err(poisoned) => *poisoned.into_inner() = std::sync::Arc::new(config), + } + let mtime = std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok(); + match state.web.config_mtime.lock() { + Ok(mut cached) => *cached = mtime, + Err(poisoned) => *poisoned.into_inner() = mtime, + } +} + +async fn record_changes( + state: &AppState, + user_id: i64, + changed: &[ChangedKey], +) -> Result<(), WebError> { + let now = crate::db::fmt_ts(jiff::Timestamp::now()); + for change in changed { + sqlx::query( + "INSERT INTO config_changes (user_id, key, old_value, new_value, changed_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(user_id) + .bind(&change.key) + .bind(change.old.as_deref()) + .bind(change.new.as_deref()) + .bind(&now) + .execute(state.db.pool()) + .await + .map_err(crate::db::DbError::from)?; + } + Ok(()) +} + +/// Write the changed fields of a settings form (§13.2): plan against the +/// file, write through [`write_validated`], record `config_changes`, swap the +/// live config. +pub async fn save_settings( + state: &AppState, + user_id: i64, + posted: &[(String, String)], +) -> Result { + let path = config_path(state)?; + let mut doc = match read_document(&path) { + Ok(doc) => doc, + Err(message) => return Ok(SaveOutcome::Rejected(vec![message])), + }; + let groups = schema(&state.config(), Some(&doc)); + let planned = match plan_changes(&groups, &doc, posted) { + Ok(planned) => planned, + Err(errors) => return Ok(SaveOutcome::Rejected(errors)), + }; + if planned.is_empty() { + return Ok(SaveOutcome::Saved { + changed: Vec::new(), + restart_required: Vec::new(), + }); + } + let changed: Vec = planned + .iter() + .map(|change| apply_change(&mut doc, change)) + .collect(); + let config = match write_validated(&path, &doc) { + Ok(config) => config, + Err(message) => return Ok(SaveOutcome::Rejected(vec![message])), + }; + record_changes(state, user_id, &changed).await?; + install(state, &path, config); + let restart_required = changed + .iter() + .filter(|change| RESTART_REQUIRED.contains(&change.key.as_str())) + .map(|change| change.key.clone()) + .collect(); + Ok(SaveOutcome::Saved { + changed, + restart_required, + }) +} + +// --------------------------------------------------------------------------- +// Providers (§13.3) +// --------------------------------------------------------------------------- + +fn valid_provider_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn provider_literal(name: &str, table: &Table) -> String { + // A provider key is supposed to come from the environment, but Config + // still accepts `api_key` in TOML. Never copy a hand-written key into the + // audit table (and therefore onto the history page) when removing a + // provider. + let mut redacted = table.clone(); + redacted.remove("api_key"); + format!("[providers.{name}]\n{redacted}") + .trim_end() + .to_string() +} + +/// Insert `[providers.]` with `ProviderConfig::default()`, the chosen +/// kind and placeholders, through the validate-and-rename path. +pub async fn add_provider( + state: &AppState, + user_id: i64, + name: &str, + kind: ProviderKind, +) -> Result { + let path = config_path(state)?; + if !valid_provider_name(name) { + return Ok(SaveOutcome::Rejected(vec![format!( + "provider name {name:?} must be lowercase ascii letters, digits or '_'" + )])); + } + let mut doc = match read_document(&path) { + Ok(doc) => doc, + Err(message) => return Ok(SaveOutcome::Rejected(vec![message])), + }; + if state.config().providers.contains_key(name) + || file_item(&doc, &format!("providers.{name}")).is_some() + { + return Ok(SaveOutcome::Rejected(vec![format!( + "providers.{name} already exists" + )])); + } + let provider = ProviderConfig { + kind, + base_url: "https://api.example.com/v1".into(), + model: "model-name".into(), + ..ProviderConfig::default() + }; + let rendered = toml::to_string(&provider) + .map_err(|error| WebError::Internal(anyhow::anyhow!("serializing provider: {error}")))?; + let mut table = DocumentMut::from_str(&rendered) + .map_err(|error| WebError::Internal(anyhow::anyhow!("parsing provider: {error}")))? + .into_table(); + table.set_implicit(false); + let literal = provider_literal(name, &table); + { + let providers = doc.as_table_mut().entry("providers").or_insert_with(|| { + let mut fresh = Table::new(); + fresh.set_implicit(true); + Item::Table(fresh) + }); + let Some(providers) = providers.as_table_mut() else { + return Ok(SaveOutcome::Rejected(vec![ + "providers is not a table in the config file".into(), + ])); + }; + providers.insert(name, Item::Table(table)); + } + let config = match write_validated(&path, &doc) { + Ok(config) => config, + Err(message) => return Ok(SaveOutcome::Rejected(vec![message])), + }; + let changed = vec![ChangedKey { + key: format!("providers.{name}"), + old: None, + new: Some(literal), + }]; + record_changes(state, user_id, &changed).await?; + install(state, &path, config); + Ok(SaveOutcome::Saved { + changed, + restart_required: Vec::new(), + }) +} + +/// Remove `[providers.]`; refused while an `[llm]` role names it, and +/// for the shipped providers (`Config::default()` declares them, so deleting +/// their table would only reset them to the built-in values). +pub async fn remove_provider( + state: &AppState, + user_id: i64, + name: &str, +) -> Result { + let path = config_path(state)?; + let config = state.config(); + let roles: Vec<&str> = config + .llm + .roles() + .into_iter() + .filter(|(_, provider)| *provider == name) + .map(|(role, _)| role) + .collect(); + if !roles.is_empty() { + return Ok(SaveOutcome::Rejected(vec![format!( + "providers.{name} is named by llm.{}; reassign the role first", + roles.join(" and llm.") + )])); + } + if default_providers().contains_key(name) { + return Ok(SaveOutcome::Rejected(vec![format!( + "providers.{name} is built in (declared by the defaults); it cannot be removed, \ + only left unreferenced" + )])); + } + let mut doc = match read_document(&path) { + Ok(doc) => doc, + Err(message) => return Ok(SaveOutcome::Rejected(vec![message])), + }; + let removed = doc + .as_table_mut() + .get_mut("providers") + .and_then(Item::as_table_mut) + .and_then(|providers| providers.remove(name)) + .and_then(|item| item.into_table().ok()); + let Some(removed) = removed else { + return Ok(SaveOutcome::Rejected(vec![format!( + "providers.{name} is not declared in the config file" + )])); + }; + let config = match write_validated(&path, &doc) { + Ok(config) => config, + Err(message) => return Ok(SaveOutcome::Rejected(vec![message])), + }; + let changed = vec![ChangedKey { + key: format!("providers.{name}"), + old: Some(provider_literal(name, &removed)), + new: None, + }]; + record_changes(state, user_id, &changed).await?; + install(state, &path, config); + Ok(SaveOutcome::Saved { + changed, + restart_required: Vec::new(), + }) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +#[derive(Template)] +#[template(path = "dashboard/settings.html")] +struct SettingsTemplate { + page: Page, + groups: Vec, + config_path: Option, + load_error: Option, + errors: Vec, + provider_kinds: Vec, +} + +fn viewer_of(user: Option, next: &str) -> Result { + user.map(Viewer::from) + .ok_or_else(|| WebError::Unauthenticated { + next: next.to_string(), + }) +} + +fn render_settings( + state: &AppState, + viewer: Viewer, + flash: Option, + load_error: Option, + errors: Vec, + posted: &[(String, String)], + status: StatusCode, +) -> Response { + let config = state.config(); + let doc = state + .config_path + .as_deref() + .and_then(|path| read_document(path).ok()); + let mut groups = schema(&config, doc.as_ref()); + for group in &mut groups { + for field in &mut group.fields { + if field.is_env() || field.is_secret() { + continue; + } + if let Some((_, raw)) = posted.iter().rev().find(|(name, _)| *name == field.path) { + field.current = raw.clone(); + } + } + } + let mut page = Page::new("Settings", Some(viewer), "settings"); + page.flash = flash; + let mut response = Html(SettingsTemplate { + page, + groups, + config_path: state + .config_path + .as_ref() + .map(|path| path.display().to_string()), + load_error, + errors, + provider_kinds: strings(PROVIDER_KINDS), + }) + .into_response(); + if response.status() == StatusCode::OK { + *response.status_mut() = status; + } + response +} + +/// `GET /dashboard/settings` (§13.1): reload the file when its mtime changed, +/// then render every key. +async fn show( + State(state): State, + auth: AuthSession, + Extension(session): Extension, +) -> Result { + let viewer = viewer_of(auth.user().await, "/dashboard/settings")?; + let load_error = WebState::reload_if_changed(&state) + .err() + .map(|error| error.to_string()); + let flash = take_flash(&session).await?; + Ok(render_settings( + &state, + viewer, + flash, + load_error, + Vec::new(), + &[], + StatusCode::OK, + )) +} + +async fn respond( + state: &AppState, + session: &Session, + viewer: Viewer, + outcome: SaveOutcome, + posted: &[(String, String)], +) -> Result { + match outcome { + SaveOutcome::Saved { + changed, + restart_required, + } => { + let mut text = if changed.is_empty() { + "Nothing changed.".to_string() + } else { + format!( + "Saved {} setting{} — they apply to the next run.", + changed.len(), + if changed.len() == 1 { "" } else { "s" } + ) + }; + if !restart_required.is_empty() { + text.push_str(&format!( + " Restart the server for: {}.", + restart_required.join(", ") + )); + } + session + .insert( + "flash", + Flash { + kind: "success".into(), + text, + }, + ) + .await + .map_err(|error| WebError::Internal(error.into()))?; + Ok(Redirect::to("/dashboard/settings").into_response()) + } + SaveOutcome::Rejected(errors) => Ok(render_settings( + state, + viewer, + None, + None, + errors, + posted, + StatusCode::BAD_REQUEST, + )), + } +} + +/// `POST /dashboard/settings` (§13.2). +async fn save( + State(state): State, + auth: AuthSession, + Extension(session): Extension, + body: Bytes, +) -> Result { + let viewer = viewer_of(auth.user().await, "/dashboard/settings")?; + config_path(&state)?; + // A file that no longer loads is reported by the GET; the save below + // re-reads the document from disk regardless. + let _ = WebState::reload_if_changed(&state); + let posted: Vec<(String, String)> = url::form_urlencoded::parse(&body).into_owned().collect(); + let outcome = save_settings(&state, viewer.id, &posted).await?; + respond(&state, &session, viewer, outcome, &posted).await +} + +#[derive(Debug, Deserialize)] +struct ProviderForm { + action: String, + name: String, + #[serde(default)] + kind: Option, +} + +/// `POST /dashboard/settings/providers` (§13.3): `action=add|remove`. +async fn providers( + State(state): State, + auth: AuthSession, + Extension(session): Extension, + Form(form): Form, +) -> Result { + let viewer = viewer_of(auth.user().await, "/dashboard/settings")?; + config_path(&state)?; + let _ = WebState::reload_if_changed(&state); + let name = form.name.trim(); + let outcome = match form.action.as_str() { + "add" => { + let kind = match form.kind.as_deref().map(str::trim) { + Some("openai") => ProviderKind::OpenAi, + Some("anthropic") => ProviderKind::Anthropic, + _ => { + return Err(WebError::BadRequest( + "kind must be openai or anthropic".into(), + )); + } + }; + add_provider(&state, viewer.id, name, kind).await? + } + "remove" => remove_provider(&state, viewer.id, name).await?, + _ => return Err(WebError::BadRequest("action must be add or remove".into())), + }; + respond(&state, &session, viewer, outcome, &[]).await +} + +// --------------------------------------------------------------------------- +// History (§13.4) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct ConfigChange { + pub id: i64, + pub key: String, + pub old_value: Option, + pub new_value: Option, + pub changed_at: String, + pub username: Option, +} + +#[derive(Template)] +#[template(path = "dashboard/settings_history.html")] +struct HistoryTemplate { + page: Page, + changes: Vec, + pagination: Pagination, +} + +#[derive(Debug, Default, Deserialize)] +struct HistoryQuery { + #[serde(default)] + page: Option, +} + +const HISTORY_PER_PAGE: u32 = 100; + +/// `config_changes` newest first, `HISTORY_PER_PAGE` per page. +pub async fn config_changes( + state: &AppState, + page: u32, +) -> Result<(Vec, Pagination), WebError> { + let config = state.config(); + let total: i64 = sqlx::query("SELECT COUNT(*) AS n FROM config_changes") + .fetch_one(state.db.pool()) + .await + .map_err(crate::db::DbError::from)? + .get("n"); + let pagination = Pagination { + page: page.max(1), + per_page: HISTORY_PER_PAGE, + total, + }; + let rows = sqlx::query( + "SELECT c.id, c.key, c.old_value, c.new_value, c.changed_at, u.username + FROM config_changes c + LEFT JOIN users u ON u.id = c.user_id + ORDER BY c.changed_at DESC, c.id DESC + LIMIT ? OFFSET ?", + ) + .bind(i64::from(HISTORY_PER_PAGE)) + .bind(pagination.offset()) + .fetch_all(state.db.pool()) + .await + .map_err(crate::db::DbError::from)?; + let changes = rows + .into_iter() + .map(|row| { + let changed_at: String = row.get("changed_at"); + ConfigChange { + id: row.get("id"), + key: row.get("key"), + old_value: row.get("old_value"), + new_value: row.get("new_value"), + changed_at: crate::db::parse_ts("config_changes.changed_at", &changed_at) + .map(|ts| format_time(ts, &config)) + .unwrap_or(changed_at), + username: row.get("username"), + } + }) + .collect(); + Ok((changes, pagination)) +} + +/// `GET /dashboard/settings/history`. +async fn history( + State(state): State, + auth: AuthSession, + Query(query): Query, +) -> Result { + let viewer = viewer_of(auth.user().await, "/dashboard/settings/history")?; + let (changes, pagination) = config_changes(&state, query.page.unwrap_or(1)).await?; + Ok(Html(HistoryTemplate { + page: Page::new("Settings history", Some(viewer), "settings"), + changes, + pagination, + }) + .into_response()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use axum::body::{Body, to_bytes}; + use axum::http::{Method, Request, header}; + use tower::ServiceExt; + + use super::*; + use crate::db::Db; + use crate::server::router; + use crate::web::users; + + fn example_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml") + } + + /// Every leaf path of a TOML table, depth first. + fn leaves(prefix: &str, table: &toml::Table, out: &mut Vec) { + for (key, value) in table { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match value { + toml::Value::Table(inner) => leaves(&path, inner, out), + _ => out.push(path), + } + } + } + + fn fields(groups: &[SettingGroup]) -> Vec<&SettingField> { + groups + .iter() + .flat_map(|group| group.fields.iter()) + .collect() + } + + fn field<'a>(groups: &'a [SettingGroup], path: &str) -> &'a SettingField { + fields(groups) + .into_iter() + .find(|field| field.path == path) + .unwrap_or_else(|| panic!("{path} missing from the schema")) + } + + async fn test_state(config_file: Option<&Path>) -> (tempfile::TempDir, AppState, i64) { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) + .await + .unwrap(); + let config = match config_file { + Some(path) => Config::load(Some(path)).unwrap(), + None => Config::default(), + }; + let state = AppState::new(db, config, config_file.map(Path::to_path_buf)); + let admin = users::add(&state.db, "admin", "correct horse battery", true) + .await + .unwrap(); + (dir, state, admin.id) + } + + fn copy_example(dir: &Path) -> PathBuf { + let path = dir.join("config.toml"); + std::fs::copy(example_path(), &path).unwrap(); + path + } + + fn posted(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + /// Push the file's mtime into the future so a rewrite within the same + /// timestamp tick still counts as a change. + fn touch_later(path: &Path, seconds: u64) { + let later = std::time::SystemTime::now() + std::time::Duration::from_secs(seconds); + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(later) + .unwrap(); + } + + #[test] + fn every_default_leaf_appears_exactly_once() { + let groups = schema(&Config::default(), None); + let mut expected = Vec::new(); + leaves("", &config_table(&Config::default()), &mut expected); + assert!(expected.len() > 80, "{}", expected.len()); + let all = fields(&groups); + for path in &expected { + let count = all.iter().filter(|field| field.path == *path).count(); + assert_eq!(count, 1, "{path} appears {count} times"); + } + let mut seen = std::collections::HashSet::new(); + for field in &all { + assert!(seen.insert(&field.path), "{} duplicated", field.path); + assert_eq!( + field.group, + field.path.rsplit_once('.').map(|(g, _)| g).unwrap_or(""), + ); + } + // Optional keys the defaults leave unset still get a field. + for path in [ + "providers.deepseek.api_key", + "providers.deepseek.effort", + "server.hmac_secret", + "xtc.settings", + "server.basic_auth_user", + ] { + field(&groups, path); + } + } + + #[test] + fn groups_render_in_the_plan_order_with_anchors() { + let groups = schema(&Config::default(), None); + let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect(); + assert_eq!( + ids, + [ + "general", + "llm", + "providers.anthropic", + "providers.deepseek", + "providers.gemini", + "voyage", + "curation", + "curation.feedback", + "curation.ranking", + "curation.ranking.quotas", + "curation.ranking.weights.preliminary", + "curation.ranking.weights.utility", + "curation.ranking.diversity", + "editorial", + "publish", + "xtc", + "server", + "miniflux", + ] + ); + let anthropic = groups + .iter() + .find(|group| group.id == "providers.anthropic") + .unwrap(); + assert_eq!( + anthropic.provider.as_ref().unwrap().referenced_by, + vec!["editor"] + ); + let gemini = groups + .iter() + .find(|group| group.id == "providers.gemini") + .unwrap(); + assert!(gemini.provider.as_ref().unwrap().referenced_by.is_empty()); + assert!( + groups + .iter() + .find(|group| group.id == "curation") + .unwrap() + .provider + .is_none() + ); + } + + #[test] + fn every_shipped_key_and_every_field_has_help() { + let raw = std::fs::read_to_string(example_path()).unwrap(); + let table: toml::Table = toml::from_str(&raw).unwrap(); + let mut shipped = Vec::new(); + leaves("", &table, &mut shipped); + assert!(shipped.len() > 80, "{}", shipped.len()); + for path in &shipped { + assert!(help_for(path).is_some(), "no help for {path}"); + } + for field in fields(&schema(&Config::default(), None)) { + assert!(field.help.is_some(), "no help for {}", field.path); + } + } + + #[test] + fn kinds_follow_the_static_table() { + let groups = schema(&Config::default(), None); + assert_eq!(field(&groups, "world_briefing").kind, FieldKind::Bool); + assert_eq!(field(&groups, "lookback_hours").kind, FieldKind::Integer); + assert_eq!( + field(&groups, "llm.score_temperature").kind, + FieldKind::Float + ); + assert_eq!(field(&groups, "llm.score_temperature").current, "0.3"); + assert_eq!(field(&groups, "timezone").kind, FieldKind::Text); + assert_eq!( + field(&groups, "curation.sections").kind, + FieldKind::TextList + ); + assert!( + field(&groups, "curation.sections") + .current + .starts_with("Top Stories\nTech & Engineering\n") + ); + assert_eq!(field(&groups, "database_path").kind, FieldKind::Path); + assert_eq!(field(&groups, "xtc.settings").kind, FieldKind::Path); + assert_eq!( + field(&groups, "providers.deepseek.kind").kind, + FieldKind::Enum(strings(PROVIDER_KINDS)) + ); + assert_eq!( + field(&groups, "providers.anthropic.effort").kind, + FieldKind::Enum(strings(&ANTHROPIC_EFFORTS[1..])) + ); + let mut with_new = Config::default(); + with_new.providers.insert( + "fresh".into(), + ProviderConfig { + kind: ProviderKind::Anthropic, + ..ProviderConfig::default() + }, + ); + assert_eq!( + field(&schema(&with_new, None), "providers.fresh.effort").kind, + FieldKind::Enum(strings(ANTHROPIC_EFFORTS)) + ); + assert_eq!(field(&groups, "providers.anthropic.effort").current, "high"); + assert_eq!( + field(&groups, "providers.deepseek.effort").kind, + FieldKind::Text + ); + assert_eq!( + field(&groups, "llm.bulk").kind, + FieldKind::Enum(strings(&["anthropic", "deepseek", "gemini"])) + ); + assert_eq!( + field(&groups, "llm.editor").kind, + FieldKind::Enum(strings(&["", "anthropic", "deepseek", "gemini"])) + ); + assert_eq!( + field(&groups, "editorial.summary_model").kind, + FieldKind::Enum(strings(SUMMARY_MODELS)) + ); + assert_eq!( + field(&groups, "xtc.format").kind, + FieldKind::Enum(strings(XTC_FORMATS)) + ); + assert!(field(&groups, "server.bind").restart_required); + assert!(field(&groups, "database_path").restart_required); + assert!(!field(&groups, "lookback_hours").restart_required); + } + + #[test] + fn secrets_never_carry_a_value() { + let mut config = Config::default(); + config.miniflux.api_key = Some("hunter2-miniflux".into()); + config.voyage.api_key = Some("hunter2-voyage".into()); + config.server.hmac_secret = Some("hunter2-hmac".into()); + config.server.basic_auth_pass = Some("hunter2-basic".into()); + if let Some(provider) = config.providers.get_mut("deepseek") { + provider.api_key = Some("hunter2-deepseek".into()); + } + let groups = schema_with_env(&config, None, &|_| false); + for path in [ + "miniflux.api_key", + "voyage.api_key", + "server.hmac_secret", + "server.basic_auth_pass", + "providers.deepseek.api_key", + "providers.anthropic.api_key", + ] { + let field = field(&groups, path); + assert_eq!(field.kind, FieldKind::Secret, "{path}"); + assert!(field.default.is_empty()); + } + assert_eq!(field(&groups, "miniflux.api_key").current, "set"); + assert_eq!( + field(&groups, "providers.anthropic.api_key").current, + "not set" + ); + for field in fields(&groups) { + assert!(!field.current.contains("hunter2"), "{}", field.path); + assert!(!field.default.contains("hunter2"), "{}", field.path); + } + } + + #[test] + fn env_detection_uses_the_derived_name() { + let name = "DAILY_EPUB_CURATION__RANKING__TELEMETRY_RETENTION_DAYS"; + assert_eq!(env_name("curation.ranking.telemetry_retention_days"), name); + assert_eq!( + env_name("providers.gemini.api_key"), + ProviderConfig::api_key_env_var("gemini") + ); + let previous = std::env::var_os(name); + // SAFETY: the variable is unique to this test, holds the default + // value so a concurrent `Config::load` sees no change, and is + // restored before the assertions. + unsafe { std::env::set_var(name, "180") }; + let groups = schema(&Config::default(), None); + match previous { + Some(value) => unsafe { std::env::set_var(name, value) }, + None => unsafe { std::env::remove_var(name) }, + } + assert_eq!( + field(&groups, "curation.ranking.telemetry_retention_days").source, + Source::Env(name.to_string()) + ); + assert_eq!( + field(&groups, "curation.ranking.embedding_retention_days").source, + Source::Default + ); + + let groups = schema_with_env(&Config::default(), None, &|var| var == ENV_SECRET_ALIAS); + assert_eq!( + field(&groups, "server.hmac_secret").source, + Source::Env(ENV_SECRET_ALIAS.to_string()) + ); + let doc = + DocumentMut::from_str("lookback_hours = 30\n[llm]\nbulk = \"deepseek\"\n").unwrap(); + let groups = schema_with_env(&Config::default(), Some(&doc), &|_| false); + assert_eq!(field(&groups, "lookback_hours").source, Source::File); + assert_eq!(field(&groups, "llm.bulk").source, Source::File); + assert_eq!(field(&groups, "llm.editor").source, Source::Default); + } + + #[test] + fn enum_options_round_trip_through_validate() { + let groups = schema(&Config::default(), None); + let mut checked = 0; + for field in fields(&groups) { + let FieldKind::Enum(options) = &field.kind else { + continue; + }; + for option in options { + let mut table = config_table(&Config::default()); + let mut segments: Vec<&str> = field.path.split('.').collect(); + let key = segments.pop().unwrap(); + let mut target = &mut table; + for segment in segments { + target = target + .get_mut(segment) + .and_then(toml::Value::as_table_mut) + .unwrap(); + } + if option.is_empty() && is_optional(&field.path) { + target.remove(key); + } else { + target.insert(key.to_string(), toml::Value::String(option.clone())); + } + let config: Config = toml::Value::Table(table) + .try_into() + .unwrap_or_else(|error| panic!("{} = {option:?}: {error}", field.path)); + config + .validate() + .unwrap_or_else(|error| panic!("{} = {option:?}: {error}", field.path)); + checked += 1; + } + } + assert!(checked >= 20, "{checked}"); + } + + #[test] + fn floats_render_with_at_most_six_decimals() { + assert_eq!(fmt_float(0.3), "0.3"); + assert_eq!(fmt_float(60.0), "60.0"); + assert_eq!(fmt_float(0.0028), "0.0028"); + assert_eq!(fmt_float(0.1 + 0.2), "0.3"); + assert_eq!(fmt_float(1.23456789), "1.234568"); + } + + #[tokio::test] + async fn changing_three_keys_preserves_comments_and_order() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + let (_db_dir, state, admin) = test_state(Some(&path)).await; + let original = std::fs::read_to_string(&path).unwrap(); + let sections = field(&schema(&state.config(), None), "curation.sections") + .current + .clone(); + let outcome = save_settings( + &state, + admin, + &posted(&[ + ("lookback_hours", "30"), + ("curation.ranking.deep_keep", "150"), + ("xtc.format", "xtc"), + // Unchanged values are skipped even when posted. + ("timezone", "America/New_York"), + ("curation.ranking.rating_half_life_days", "60"), + ("llm.score_temperature", "0.3"), + ("curation.sections", §ions), + ]), + ) + .await + .unwrap(); + let SaveOutcome::Saved { + changed, + restart_required, + } = outcome + else { + panic!("{outcome:?}"); + }; + assert_eq!( + changed, + vec![ + ChangedKey { + key: "lookback_hours".into(), + old: Some("26".into()), + new: Some("30".into()), + }, + ChangedKey { + key: "curation.ranking.deep_keep".into(), + old: Some("120".into()), + new: Some("150".into()), + }, + ChangedKey { + key: "xtc.format".into(), + old: Some("\"xtch\"".into()), + new: Some("\"xtc\"".into()), + }, + ] + ); + assert!(restart_required.is_empty()); + + let saved = std::fs::read_to_string(&path).unwrap(); + let before: Vec<&str> = original.lines().collect(); + let after: Vec<&str> = saved.lines().collect(); + assert_eq!(before.len(), after.len()); + let mut differing = Vec::new(); + for (index, (old, new)) in before.iter().zip(&after).enumerate() { + if old != new { + differing.push((index, *old, *new)); + } + } + assert_eq!(differing.len(), 3, "{differing:?}"); + assert_eq!(differing[0].2, "lookback_hours = 30"); + assert_eq!( + differing[1].2, + "deep_keep = 150 # deep-assessment set" + ); + assert_eq!( + differing[2].2, + "format = \"xtc\" # xtc (1-bit) | xtch (grayscale)" + ); + assert_eq!( + original.lines().filter(|line| line.contains('#')).count(), + saved.lines().filter(|line| line.contains('#')).count() + ); + assert_eq!(state.config().lookback_hours, 30); + assert_eq!(state.config().curation.ranking.deep_keep, 150); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + + let (changes, pagination) = config_changes(&state, 1).await.unwrap(); + assert_eq!(pagination.total, 3); + assert_eq!(changes[0].key, "xtc.format"); + assert_eq!(changes[0].old_value.as_deref(), Some("\"xtch\"")); + assert_eq!(changes[0].new_value.as_deref(), Some("\"xtc\"")); + assert_eq!(changes[0].username.as_deref(), Some("admin")); + assert_eq!(changes[2].key, "lookback_hours"); + } + + #[tokio::test] + async fn a_new_key_in_an_absent_table_creates_the_table() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "# minimal\nlookback_hours = 26\n").unwrap(); + let (_db_dir, state, admin) = test_state(Some(&path)).await; + let outcome = save_settings( + &state, + admin, + &posted(&[ + ("curation.feedback.good_value", "0.5"), + ("curation.ranking.quotas.knn", "25"), + ("world_briefing", "false"), + ("server.bind", "127.0.0.1:3500"), + ]), + ) + .await + .unwrap(); + let SaveOutcome::Saved { + changed, + restart_required, + } = outcome + else { + panic!("{outcome:?}"); + }; + assert_eq!(changed.len(), 4); + let good = changed + .iter() + .find(|c| c.key == "curation.feedback.good_value") + .unwrap(); + assert_eq!(good.old, None); + assert_eq!(good.new.as_deref(), Some("0.5")); + assert_eq!(restart_required, vec!["server.bind".to_string()]); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!( + saved.starts_with("# minimal\nlookback_hours = 26\nworld_briefing = false\n"), + "{saved}" + ); + assert!( + saved.contains("[curation.feedback]\ngood_value = 0.5\n"), + "{saved}" + ); + assert!( + saved.contains("[curation.ranking.quotas]\nknn = 25\n"), + "{saved}" + ); + assert!( + saved.contains("[server]\nbind = \"127.0.0.1:3500\"\n"), + "{saved}" + ); + assert!(!saved.contains("[curation]\n"), "{saved}"); + let reloaded = Config::load(Some(&path)).unwrap(); + assert_eq!(reloaded.curation.feedback.good_value, 0.5); + assert_eq!(reloaded.curation.ranking.quotas.knn, 25); + assert!(!reloaded.world_briefing); + assert_eq!(state.config().server.bind, "127.0.0.1:3500"); + } + + #[tokio::test] + async fn text_lists_write_multi_line_arrays_and_optional_keys_can_be_removed() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + let (_db_dir, state, admin) = test_state(Some(&path)).await; + let outcome = save_settings( + &state, + admin, + &posted(&[ + ("curation.blocked_domains", " a.example \n\nb.example\n"), + ("curation.sections", "Top Stories\nNiche Corner"), + ("providers.anthropic.effort", "low"), + ("xtc.settings", ""), + ("llm.editor", ""), + ]), + ) + .await + .unwrap(); + let SaveOutcome::Saved { changed, .. } = outcome else { + panic!("{outcome:?}"); + }; + assert_eq!(changed.len(), 5); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!( + saved.contains("blocked_domains = [\n \"a.example\",\n \"b.example\",\n]\n"), + "{saved}" + ); + assert!( + saved.contains( + "sections = [\n \"Top Stories\",\n \"Niche Corner\",\n]\n\n[curation.feedback]" + ), + "{saved}" + ); + assert!( + saved + .lines() + .any(|line| line.starts_with("effort = \"low\" ") + && line.contains("# low | medium")), + "{saved}" + ); + assert!( + saved.contains("effort = \"high\" # minimal"), + "{saved}" + ); + assert!(!saved.contains("\nsettings = "), "{saved}"); + assert!( + saved + .lines() + .any(|line| line.starts_with("editor = \"\" ") && line.contains("# lineup")), + "{saved}" + ); + let removed = changed.iter().find(|c| c.key == "xtc.settings").unwrap(); + assert_eq!( + removed.old.as_deref(), + Some("\"/etc/daily-epub/xtc-settings.json\"") + ); + assert_eq!(removed.new, None); + let config = state.config(); + assert_eq!(config.curation.blocked_domains, ["a.example", "b.example"]); + assert_eq!(config.providers["anthropic"].effort.as_deref(), Some("low")); + assert_eq!(config.xtc.settings, None); + assert_eq!(config.llm.editor_name(), None); + + // A key the defaults fill in cannot be unset through the file. + let outcome = save_settings(&state, admin, &posted(&[("providers.gemini.effort", "")])) + .await + .unwrap(); + let SaveOutcome::Rejected(errors) = outcome else { + panic!("{outcome:?}"); + }; + assert!( + errors[0].contains("cannot be unset") || errors[0].contains("not unset"), + "{errors:?}" + ); + assert_eq!( + state.config().providers["gemini"].effort.as_deref(), + Some("high") + ); + } + + #[tokio::test] + async fn invalid_values_are_rejected_and_the_file_is_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + let (_db_dir, state, admin) = test_state(Some(&path)).await; + let original = std::fs::read_to_string(&path).unwrap(); + + // Field errors are collected before anything is written. + let outcome = save_settings( + &state, + admin, + &posted(&[ + ("lookback_hours", "thirty"), + ("xtc.format", "pdf"), + ("retention_days", "30"), + ]), + ) + .await + .unwrap(); + let SaveOutcome::Rejected(errors) = outcome else { + panic!("{outcome:?}"); + }; + assert_eq!(errors.len(), 2, "{errors:?}"); + assert!(errors[0].starts_with("lookback_hours:"), "{errors:?}"); + assert!( + errors[1].starts_with("xtc.format: must be one of"), + "{errors:?}" + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + + // A value the loader rejects leaves the file alone too. + let outcome = save_settings( + &state, + admin, + &posted(&[("curation.ranking.deep_keep", "10")]), + ) + .await + .unwrap(); + let SaveOutcome::Rejected(errors) = outcome else { + panic!("{outcome:?}"); + }; + assert!( + errors[0].contains("deep_keep >= shortlist_keep"), + "{errors:?}" + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + assert_eq!(state.config().curation.ranking.deep_keep, 120); + let (_, pagination) = config_changes(&state, 1).await.unwrap(); + assert_eq!(pagination.total, 0); + + // A good save keeps the permissions. + let outcome = save_settings(&state, admin, &posted(&[("retention_days", "30")])) + .await + .unwrap(); + assert!(matches!(outcome, SaveOutcome::Saved { .. }), "{outcome:?}"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o640); + } + + #[tokio::test] + async fn providers_can_be_added_and_removed_unless_referenced() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + let (_db_dir, state, admin) = test_state(Some(&path)).await; + + let refused = remove_provider(&state, admin, "anthropic").await.unwrap(); + let SaveOutcome::Rejected(errors) = refused else { + panic!("{refused:?}"); + }; + assert!(errors[0].contains("llm.editor"), "{errors:?}"); + + for bad in ["Bad-Name", "", "deepseek"] { + let outcome = add_provider(&state, admin, bad, ProviderKind::OpenAi) + .await + .unwrap(); + assert!( + matches!(outcome, SaveOutcome::Rejected(_)), + "{bad}: {outcome:?}" + ); + } + + let added = add_provider(&state, admin, "local_llm", ProviderKind::Anthropic) + .await + .unwrap(); + let SaveOutcome::Saved { changed, .. } = added else { + panic!("{added:?}"); + }; + assert_eq!(changed[0].key, "providers.local_llm"); + assert!( + changed[0] + .new + .as_deref() + .unwrap() + .starts_with("[providers.local_llm]\nkind = \"anthropic\"") + ); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!( + saved.contains( + "\n[providers.local_llm]\nkind = \"anthropic\"\nbase_url = \"https://api.example.com/v1\"\nmodel = \"model-name\"\n" + ), + "{saved}" + ); + assert!(!saved.contains("api_key ="), "{saved}"); + assert_eq!( + state.config().providers["local_llm"].kind, + ProviderKind::Anthropic + ); + let groups = schema(&state.config(), None); + assert_eq!( + field(&groups, "llm.bulk").kind, + FieldKind::Enum(strings(&["anthropic", "deepseek", "gemini", "local_llm"])) + ); + + // Config accepts a hand-written provider key even though the docs say + // it belongs in the environment. Removing that provider must not copy + // the key into `config_changes` and expose it on the history page. + let with_file_secret = saved.replace( + "model = \"model-name\"\n", + "model = \"model-name\"\napi_key = \"history-must-not-leak\"\n", + ); + std::fs::write(&path, with_file_secret).unwrap(); + + // Shipped providers come back from `Config::default()` however the + // file looks, so they are refused rather than silently reset. + let built_in = remove_provider(&state, admin, "gemini").await.unwrap(); + let SaveOutcome::Rejected(errors) = built_in else { + panic!("{built_in:?}"); + }; + assert!(errors[0].contains("built in"), "{errors:?}"); + assert!( + !groups + .iter() + .find(|group| group.id == "providers.gemini") + .unwrap() + .provider + .as_ref() + .unwrap() + .removable() + ); + + let removed = remove_provider(&state, admin, "local_llm").await.unwrap(); + let SaveOutcome::Saved { changed, .. } = removed else { + panic!("{removed:?}"); + }; + assert_eq!(changed[0].key, "providers.local_llm"); + assert!( + changed[0] + .old + .as_deref() + .unwrap() + .contains("model = \"model-name\"") + ); + assert!( + !changed[0] + .old + .as_deref() + .unwrap() + .contains("history-must-not-leak") + ); + assert_eq!(changed[0].new, None); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(!saved.contains("[providers.local_llm]"), "{saved}"); + assert!(saved.contains("[providers.gemini]"), "{saved}"); + assert!(!state.config().providers.contains_key("local_llm")); + let (changes, pagination) = config_changes(&state, 1).await.unwrap(); + assert_eq!(pagination.total, 2); + assert_eq!(changes[0].key, "providers.local_llm"); + assert_eq!(changes[0].new_value, None); + assert!( + !changes[0] + .old_value + .as_deref() + .unwrap() + .contains("history-must-not-leak") + ); + assert_eq!(changes[1].key, "providers.local_llm"); + assert_eq!(changes[1].old_value, None); + } + + #[tokio::test] + async fn reload_on_mtime_swaps_the_config_and_reports_a_broken_file() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + let (_db_dir, state, _) = test_state(Some(&path)).await; + assert!(WebState::reload_if_changed(&state).unwrap()); + assert!(!WebState::reload_if_changed(&state).unwrap()); + + let edited = std::fs::read_to_string(&path) + .unwrap() + .replace("lookback_hours = 26", "lookback_hours = 40"); + std::fs::write(&path, edited).unwrap(); + touch_later(&path, 5); + assert!(WebState::reload_if_changed(&state).unwrap()); + assert_eq!(state.config().lookback_hours, 40); + assert!(!WebState::reload_if_changed(&state).unwrap()); + + std::fs::write(&path, "lookback_hours = 0\n").unwrap(); + touch_later(&path, 10); + let error = WebState::reload_if_changed(&state).unwrap_err(); + assert!(error.to_string().contains("lookback_hours"), "{error}"); + assert_eq!(state.config().lookback_hours, 40); + + let (_db_dir, none, _) = test_state(None).await; + assert!(!WebState::reload_if_changed(&none).unwrap()); + } + + async fn login(app: &axum::Router) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header("sec-fetch-site", "same-origin") + .header("x-forwarded-for", "192.0.2.50") + .body(Body::from( + "username=admin&password=correct+horse+battery&next=%2F", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() + } + + fn get(uri: &str, cookie: &str) -> Request { + Request::builder() + .uri(uri) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() + } + + fn post(uri: &str, cookie: &str, body: &str) -> Request { + Request::builder() + .method(Method::POST) + .uri(uri) + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header("sec-fetch-site", "same-origin") + .body(Body::from(body.to_string())) + .unwrap() + } + + async fn text(response: Response) -> String { + String::from_utf8( + to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap() + .to_vec(), + ) + .unwrap() + } + + #[tokio::test] + async fn settings_pages_render_save_and_show_hand_edits() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + let (_db_dir, state, _) = test_state(Some(&path)).await; + let app = router(state.clone()); + let cookie = login(&app).await; + + let page = app + .clone() + .oneshot(get("/dashboard/settings", &cookie)) + .await + .unwrap(); + assert_eq!(page.status(), StatusCode::OK); + let html = text(page).await; + for anchor in [ + "id=\"curation.feedback\"", + "id=\"curation.ranking\"", + "id=\"curation.ranking.weights.preliminary\"", + "id=\"providers.gemini\"", + ] { + assert!(html.contains(anchor), "{anchor}"); + } + assert!(html.contains("name=\"curation.ranking.deep_keep\"")); + assert!(html.contains("renormalized")); + assert!(html.contains("not set")); + assert!(html.contains("/dashboard/settings/history")); + + let saved = app + .clone() + .oneshot(post( + "/dashboard/settings", + &cookie, + "lookback_hours=28&server.bind=127.0.0.1%3A3600", + )) + .await + .unwrap(); + assert_eq!(saved.status(), StatusCode::SEE_OTHER); + assert_eq!( + saved.headers().get(header::LOCATION).unwrap(), + "/dashboard/settings" + ); + let page = text( + app.clone() + .oneshot(get("/dashboard/settings", &cookie)) + .await + .unwrap(), + ) + .await; + assert!(page.contains("Saved 2 settings"), "{page}"); + assert!( + page.contains("Restart the server for: server.bind"), + "{page}" + ); + assert!(page.contains("value=\"28\"")); + assert_eq!(state.config().lookback_hours, 28); + + let rejected = app + .clone() + .oneshot(post("/dashboard/settings", &cookie, "lookback_hours=abc")) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + let html = text(rejected).await; + assert!( + html.contains("lookback_hours: must be a whole number"), + "{html}" + ); + assert!(html.contains("value=\"abc\""), "{html}"); + + // A hand edit on disk shows up on the next view; a broken one is reported. + let edited = std::fs::read_to_string(&path) + .unwrap() + .replace("lookback_hours = 28", "lookback_hours = 31"); + std::fs::write(&path, edited).unwrap(); + touch_later(&path, 5); + let page = text( + app.clone() + .oneshot(get("/dashboard/settings", &cookie)) + .await + .unwrap(), + ) + .await; + assert!(page.contains("value=\"31\""), "{page}"); + std::fs::write(&path, "lookback_hours = 0\n").unwrap(); + touch_later(&path, 10); + let page = text( + app.clone() + .oneshot(get("/dashboard/settings", &cookie)) + .await + .unwrap(), + ) + .await; + assert!( + page.contains("! config.toml on disk does not load:"), + "{page}" + ); + assert!(page.contains("value=\"31\""), "{page}"); + + let history = text( + app.clone() + .oneshot(get("/dashboard/settings/history", &cookie)) + .await + .unwrap(), + ) + .await; + assert!(history.contains("server.bind"), "{history}"); + assert!(history.contains("127.0.0.1:3600"), "{history}"); + assert!(history.contains("admin"), "{history}"); + } + + #[tokio::test] + async fn provider_forms_and_missing_config_path() { + let dir = tempfile::tempdir().unwrap(); + let path = copy_example(dir.path()); + let (_db_dir, state, _) = test_state(Some(&path)).await; + let app = router(state.clone()); + let cookie = login(&app).await; + let added = app + .clone() + .oneshot(post( + "/dashboard/settings/providers", + &cookie, + "action=add&name=mistral&kind=openai", + )) + .await + .unwrap(); + assert_eq!(added.status(), StatusCode::SEE_OTHER); + assert!(state.config().providers.contains_key("mistral")); + let refused = app + .clone() + .oneshot(post( + "/dashboard/settings/providers", + &cookie, + "action=remove&name=deepseek", + )) + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert!(text(refused).await.contains("llm.bulk")); + let bad_kind = app + .clone() + .oneshot(post( + "/dashboard/settings/providers", + &cookie, + "action=add&name=other&kind=cohere", + )) + .await + .unwrap(); + assert_eq!(bad_kind.status(), StatusCode::BAD_REQUEST); + + let (_dir, no_path, _) = test_state(None).await; + let app = router(no_path); + let cookie = login(&app).await; + let page = app + .clone() + .oneshot(get("/dashboard/settings", &cookie)) + .await + .unwrap(); + assert_eq!(page.status(), StatusCode::OK); + assert!(text(page).await.contains("No config file is configured")); + let save = app + .clone() + .oneshot(post("/dashboard/settings", &cookie, "lookback_hours=28")) + .await + .unwrap(); + assert_eq!(save.status(), StatusCode::BAD_REQUEST); + let anonymous = app + .oneshot( + Request::builder() + .uri("/dashboard/settings") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::FOUND); + } } diff --git a/src/web/mod.rs b/src/web/mod.rs index 358838a..ce53444 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -105,6 +105,42 @@ impl fmt::Debug for WebState { } } +impl WebState { + /// Config reload on mtime (dashboard plan §4.2): when `config_path`'s + /// modification time differs from the cached one, re-run `Config::load` + /// and swap the live config. Returns `Ok(true)` when a reload happened, + /// `Ok(false)` when nothing changed or no file is configured, and the + /// load error when the file on disk no longer loads — the previous + /// config stays live and the cached mtime is left alone so the next call + /// tries again. Called by the settings page and by job starts. + pub fn reload_if_changed( + state: &crate::server::AppState, + ) -> Result { + let Some(path) = state.config_path.as_deref() else { + return Ok(false); + }; + let mtime = std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok(); + let mut cached = state + .web + .config_mtime + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if *cached == mtime { + return Ok(false); + } + let config = crate::config::Config::load(Some(path))?; + match state.config.write() { + Ok(mut live) => *live = std::sync::Arc::new(config), + Err(poisoned) => *poisoned.into_inner() = std::sync::Arc::new(config), + } + *cached = mtime; + tracing::info!(path = %path.display(), "reloaded configuration from disk"); + Ok(true) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Flash { pub kind: String, diff --git a/src/web/static/app.css b/src/web/static/app.css index e4c269d..c5fa5e5 100644 --- a/src/web/static/app.css +++ b/src/web/static/app.css @@ -57,3 +57,20 @@ thead { position:sticky; top:0; background:var(--bg); } .rating-prompt { margin-right:.25rem; } .rating-note { flex-basis:100%; } @media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } } +/* 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; } +.settings-form { display:block; } +.setting { display:grid; grid-template-columns:minmax(14rem,1fr) 2fr; gap:.5rem 1rem; padding:.5rem 0; border-top:1px solid var(--rule); } +.setting .help,.setting .default,.settings .meta { color:var(--muted); font-size:.85rem; margin:.2rem 0; } +.setting-input input,.setting-input select,.setting-input textarea { width:100%; max-width:36rem; } +.setting-input textarea { min-height:5rem; font-family:ui-monospace,monospace; } +.setting-input input:disabled,.setting-input textarea:disabled { opacity:.6; } +.setting .lines { white-space:pre-line; } +.settings .banner { padding:.6rem .8rem; border:1px solid var(--down); margin:1rem 0; } +.settings .reset,.settings .danger { font:inherit; font-size:.85rem; border:0; background:transparent; color:var(--accent); text-decoration:underline; padding:0; cursor:pointer; } +.settings .actions { position:sticky; bottom:0; background:var(--bg); padding:.7rem 0; border-top:1px solid var(--rule); } +.settings form.inline { display:none; } +.settings .add-provider { max-width:36rem; } +.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; } +@media (max-width:40rem) { .setting { display:block; } } diff --git a/src/web/static/app.js b/src/web/static/app.js index 071adc1..6b85470 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -39,3 +39,11 @@ document.querySelectorAll("details[id]").forEach((details) => { details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed")); } catch (_) {} }); +/* step 5: settings — "reset to default" fills the field with its default */ +document.addEventListener("click", (event) => { + const button = event.target.closest("button[data-reset]"); + if (!button) return; + const input = document.getElementById(button.dataset.reset); + if (!input) return; + input.value = button.dataset.default; +}); diff --git a/src/web/templates/dashboard/settings.html b/src/web/templates/dashboard/settings.html new file mode 100644 index 0000000..443f4a4 --- /dev/null +++ b/src/web/templates/dashboard/settings.html @@ -0,0 +1,41 @@ +{% extends "layout.html" %}{% block content %}
+

Settings

+

{% match config_path %}{% when Some with (path) %}Editing {{ path }}. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with --config to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %} · History

+{% match load_error %}{% when Some with (error) %}{% when None %}{% endmatch %} +{% if !errors.is_empty() %}{% endif %} +
+{% for group in groups %}
+

{{ group.title }}

+{% match group.provider %}{% when Some with (provider) %}

Key: {{ crate::config::ProviderConfig::api_key_env_var(provider.name) }} in the env file. {% if provider.removable() %}Not named by any [llm] role. {% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by llm.{{ provider.roles() }}; reassign the role before removing it.{% endif %}

{% when None %}{% endmatch %} +{% if group.is_weights() %}

Weights need not sum to 1; they are renormalized over the signals present for each article.

{% endif %} +{% for field in group.fields %}
+
{% if field.restart_required %} restart{% endif %}{% match field.help %}{% when Some with (help) %}

{{ help }}

{% when None %}{% endmatch %}
+
+{% if field.is_secret() %}

{% if field.is_set() %}set{% if field.is_env() %} (from {{ field.env_var() }}){% else if field.is_file() %} (in the config file — move it to the env file){% endif %}{% else %}not set — set {{ field.env_var() }} in the env file{% endif %}

+{% else if field.is_env() %}{% if field.is_text_list() %}{% else %}{% endif %}

locked by {{ field.env_var() }} in the env file

+{% else if field.is_bool() %} +{% else if field.is_enum() %} +{% else if field.is_text_list() %} +{% else if field.is_number() %} +{% else %} +{% endif %} +{% if !field.is_secret() && !field.is_env() %}

default: {% if field.default.is_empty() %}unset{% else if field.is_text_list() %}{{ field.default }}{% else %}{{ field.default }}{% endif %}{% if field.current != field.default %} {% endif %}

{% endif %} +
+
+{% endfor %} +
+{% endfor %} +
{% match config_path %}{% when Some with (_path) %}{% when None %}{% endmatch %}
+
+{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}
{% endif %}{% when None %}{% endmatch %}{% endfor %} +
+

Add provider

+
+ + + +

Inserts [providers.<name>] with placeholder base_url and model; edit them above afterwards and put DAILY_EPUB_PROVIDERS__<NAME>__API_KEY in the env file.

+ +
+
+
{% endblock %} diff --git a/src/web/templates/dashboard/settings_history.html b/src/web/templates/dashboard/settings_history.html new file mode 100644 index 0000000..e55fbce --- /dev/null +++ b/src/web/templates/dashboard/settings_history.html @@ -0,0 +1,18 @@ +{% extends "layout.html" %}{% block content %}
+

Settings history

+

Settings · every change made through the dashboard, newest first.

+{% if changes.is_empty() %}

No settings have been changed through the dashboard yet.

{% else %}
+ +{% for change in changes %} + + + + + +{% endfor %} +
WhenWhoKeyBeforeAfter
{{ change.changed_at }}{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}removed user{% endmatch %}{{ change.key }}{% match change.old_value %}{% when Some with (value) %}
{{ value }}
{% when None %}absent{% endmatch %}
{% match change.new_value %}{% when Some with (value) %}
{{ value }}
{% when None %}removed{% endmatch %}
+{% include "_pagination.html" %} +{% if pagination.page < pagination.pages() %}

Older

{% endif %} +{% if pagination.page > 1 %}

Newer

{% endif %} +{% endif %} +
{% endblock %} diff --git a/systemd/daily-epub.service b/systemd/daily-epub.service index c9306b7..403ef4d 100644 --- a/systemd/daily-epub.service +++ b/systemd/daily-epub.service @@ -36,9 +36,10 @@ TimeoutStopSec=20s StateDirectory=daily-epub StateDirectoryMode=0750 WorkingDirectory=/var/lib/daily-epub -# The publish dirs from [publish] in config.toml — keep these in sync. +# The publish dirs from [publish] in config.toml — keep these in sync — plus +# /etc/daily-epub so the dashboard settings page can rewrite config.toml. # Every path listed here must exist at start, or the unit fails with 226/NAMESPACE. -ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc +ReadWritePaths=/home/thallada/bookorbit/books/daily-epub /var/lib/daily-epub/xtc /etc/daily-epub # --- hardening (spec §3.15) --------------------------------------------- ProtectSystem=strict