diff --git a/src/api.rs b/src/api.rs index 3a1f37c5..840803ce 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1509,6 +1509,65 @@ pub async fn delete_theme(_: Admin, State(app): State, Path(short): Path } } +/// Where a theme's saved settings live. Keyed by `short` in the database rather +/// than stored beside the theme, so updating, reinstalling or deleting the +/// theme leaves them in place, and a backup carries them. +fn theme_config_key(short: &str) -> String { + format!("theme_config:{short}") +} + +/// The settings saved for one theme in the panel: only the fields changed from +/// the defaults its `theme.json` declares, which the theme fills in itself. +/// Any theme may be named, installed or not, so a theme under development +/// reads its own settings from whichever hub it proxies to. +/// +/// Anonymous, under the same condition as `/api/nodes`. One request is one +/// primary-key read answering at most 64 KiB, the router's body limit having +/// bounded the write. That is the cost class of `/api/me`, so there is no gate: +/// on a three-core hub (debug build), 120 concurrent requests for a 60 KiB +/// value held the panel's `/api/nodes` at a 230 ms median, against 160 ms +/// under the same load on `/api/me`. +/// +/// A failed read answers 500 rather than `{}`: the panel saves on top of what +/// it reads, so an empty answer would erase every saved override. +pub async fn theme_config( + State(app): State, + headers: HeaderMap, + Path(short): Path, +) -> Response { + if !authed(&app, &headers) && !app.public_page() { + return (StatusCode::UNAUTHORIZED, "sign-in required").into_response(); + } + if !crate::frontend::valid_short(&short) { + return StatusCode::NOT_FOUND.into_response(); + } + match app.db.lookup(&theme_config_key(&short)) { + Ok(saved) => { + let saved = saved.unwrap_or_else(|| "{}".into()); + ([(header::CONTENT_TYPE, "application/json")], saved).into_response() + } + Err(e) => fail(e), + } +} + +/// Replaces a theme's saved settings. Values are not checked against the +/// theme's declared fields: the theme must validate what it reads regardless, +/// since a value saved under one version of the theme meets the next. +pub async fn save_theme_config( + _: Admin, + State(app): State, + Path(short): Path, + Json(values): Json>, +) -> Response { + if !crate::frontend::valid_short(&short) || !crate::frontend::selectable(&app, &short) { + return bad("theme is not installed"); + } + match app.db.set(&theme_config_key(&short), &Value::Object(values).to_string()) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => fail(e), + } +} + pub async fn themes(_: Admin, State(app): State) -> Response { match crate::frontend::themes(&app) { Ok(themes) => Json(json!({"themes": themes})).into_response(), @@ -2242,6 +2301,34 @@ mod tests { assert!(app.agents.read().unwrap().is_empty(), "the node must read as offline at once"); } + /// What the panel saves is what an anonymous visitor reads, under the same + /// condition as the node list, and only an installed theme takes a write. + #[tokio::test] + async fn saved_theme_settings_reach_visitors_while_the_status_page_is_open() { + let app = std::sync::Arc::new(app()); + let state = || axum::extract::State(app.clone()); + let read = |short: &str| theme_config(state(), HeaderMap::new(), Path(short.to_owned())); + let body = |r: Response| async { axum::body::to_bytes(r.into_body(), usize::MAX).await.unwrap() }; + + assert_eq!(&body(read("default").await).await[..], b"{}", "nothing saved reads as no overrides"); + let values = json!({"notice": "维护中", "show_price": false}).as_object().unwrap().clone(); + let saved = save_theme_config(Admin, state(), Path("default".into()), Json(values.clone())).await; + assert_eq!(saved.status(), StatusCode::NO_CONTENT); + let read_back: Value = serde_json::from_slice(&body(read("default").await).await).unwrap(); + assert_eq!(read_back, Value::Object(values.clone())); + + let missing = save_theme_config(Admin, state(), Path("aurora".into()), Json(values.clone())).await; + assert_eq!(missing.status(), StatusCode::BAD_REQUEST, "a theme that is not installed takes no write"); + assert_eq!(read("../etc").await.status(), StatusCode::NOT_FOUND); + + app.db.set("public_page", "off").unwrap(); + assert_eq!( + read("default").await.status(), + StatusCode::UNAUTHORIZED, + "a closed status page hides them too" + ); + } + /// A write naming a node that no longer exists, such as one deleted from /// another tab, is refused rather than reported as saved. #[tokio::test] diff --git a/src/db.rs b/src/db.rs index 260fd7e7..992fc80a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -576,11 +576,16 @@ impl Db { // ---- settings ---- pub fn get(&self, key: &str) -> Option { - self.conn() + self.lookup(key).ok().flatten() + } + + /// As [`Db::get`], with a failed read kept apart from an absent key, for a + /// caller that would otherwise act on "nothing saved". + pub fn lookup(&self, key: &str) -> Result> { + Ok(self + .conn() .query_row("SELECT value FROM setting WHERE key = ?1", [key], |r| r.get(0)) - .optional() - .ok() - .flatten() + .optional()?) } pub fn set(&self, key: &str, value: &str) -> Result<()> { diff --git a/src/frontend.rs b/src/frontend.rs index 4525a885..9f06ba6e 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -41,6 +41,12 @@ pub struct Theme { pub version: String, pub author: String, pub url: String, + /// The settings form the panel draws for this theme. Passed through + /// unparsed: a malformed field costs that one field in the panel rather than + /// removing the whole theme from the list, and a hub predating the field + /// ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, #[serde(default)] pub selected: bool, /// Set only for the copy embedded in the binary. Never read from a manifest: @@ -162,7 +168,7 @@ fn read_inside(root: &Path, relative: &str) -> Option> { /// name and anything carrying a path separator. `default` is admitted: an /// installed copy of the built-in theme takes that name and is served in its /// place, leaving the embedded one as the fallback beneath it. -fn valid_short(short: &str) -> bool { +pub fn valid_short(short: &str) -> bool { !short.is_empty() && short.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') } diff --git a/src/main.rs b/src/main.rs index 807dbb66..6f61900b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -442,6 +442,7 @@ async fn main() -> Result<()> { .route("/api/nodes", get(api::nodes)) .route("/api/nodes/{id}/metrics", get(api::metrics)) .route("/api/ws", get(api::live_ws)) + .route("/api/themes/{short}/config", get(api::theme_config)) // Sign-in. .route("/api/auth/login", post(auth::login)) .route("/api/auth/logout", post(auth::logout)) @@ -465,6 +466,7 @@ async fn main() -> Result<()> { .route("/api/themes/{short}", delete(api::delete_theme)) .route("/api/themes/{short}/preview", get(api::theme_preview)) .route("/api/themes/{short}/update", post(api::update_theme)) + .route("/api/themes/{short}/config", put(api::save_theme_config)) .route("/api/db", get(api::db_stats)) .route("/api/db/backup", get(api::db_backup)) .route("/api/db/vacuum", post(api::db_vacuum)) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index a25162ea..4a7b405d 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react" import { flushSync } from "react-dom" -import { ArrowUpCircle, Bell, CalendarClock, ChevronRight, Copy, Database, Download, GripVertical, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, Trash2, Upload } from "lucide-react" +import { ArrowUpCircle, Bell, CalendarClock, ChevronRight, Copy, Database, Download, GripVertical, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, SlidersHorizontal, Trash2, Upload } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" @@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { addresses, api, badIfaceName, behind, changes, currentIface, GIB, ifaceChoice, ifaceSpec, outdatedAgents, provisioningSite, trafficCorrection, upload, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" +import { addresses, api, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, ifaceChoice, ifaceSpec, outdatedAgents, provisioningSite, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" import { bytes, CYCLES, FOREVER, money, uptime } from "@/lib/format" // Counters the panel can correct after migration or an accounting error. @@ -1302,6 +1302,179 @@ type Theme = { // 内置主题在二进制里,没有目录可删。装上一份同名的会顶替它,那一份就是普通 // 主题,删掉之后内置的重新顶上。 builtin: boolean + // theme.json 里声明的设置表单,原样转过来,由 configFields 挑出能画的字段。 + config?: unknown +} + +// 主题在 theme.json 里声明的设置。hub 只存与默认值不同的项,其余由主题用自己的默认值补上, +// 所以主题作者日后改了某个默认值,没动过这一项的站点会跟着变。 +// +// 字段不多时是一列;多了改成宽对话框:按分组标题分节,左侧切换,右侧两列, +// 否则几十项排成一条细长的列表。有改动的节在导航上带一个点。 +function ThemeSettings({ theme, saved, onClose }: { + theme: Theme + saved: Record + onClose: () => void +}) { + const form = configForm(theme.config) + const fields = form.filter((entry): entry is ConfigField => entry.type !== "title") + const sections = configSections(form) + const large = fields.length > 6 + const paged = large && sections.length > 1 + const [current, setCurrent] = useState(0) + const [values, setValues] = useState(() => configValues(fields, saved)) + // What the save builds on. Keys the form does not declare are kept, except + // after 恢复默认: that also clears them, the only way from the panel to drop + // a value, publicly readable, left by a field the theme has since removed. + const [base, setBase] = useState(saved) + const [saving, setSaving] = useState(false) + const set = (key: string, value: unknown) => setValues((old) => ({ ...old, [key]: value })) + const label = (field: ConfigField) => field.label || field.key + // A number box holds its text while being edited; an empty one holds no + // number, where Number("") would read as 0. + const typed = (f: ConfigField) => + f.type !== "number" ? values[f.key] : values[f.key] === "" ? NaN : Number(values[f.key]) + const differs = (f: ConfigField) => typed(f) !== f.default + + async function save(e: React.FormEvent) { + e.preventDefault() + // The browser checks required/min/max only on the boxes on screen; a + // section switched away from is no longer rendered, so its numbers are + // checked here and the offending one brought back into view. + const invalid = fields.find((f) => f.type === "number" && !fits(f, typed(f))) + if (invalid) { + setCurrent(Math.max(0, sections.findIndex((section) => section.fields.includes(invalid)))) + const range = + invalid.min !== undefined && invalid.max !== undefined ? `${invalid.min}–${invalid.max} 之间的` + : invalid.min !== undefined ? `不小于 ${invalid.min} 的` + : invalid.max !== undefined ? `不大于 ${invalid.max} 的` : "" + return toast.error(`「${label(invalid)}」要填${range}数字`) + } + setSaving(true) + try { + await api(`/themes/${theme.short}/config`, { + method: "PUT", + body: JSON.stringify(configOverrides(fields, base, Object.fromEntries(fields.map((f) => [f.key, typed(f)])))), + }) + toast.success("主题设置已保存,公开页刷新后生效") + onClose() + } catch (e) { + toast.error((e as Error).message) + } finally { + setSaving(false) + } + } + + const input = (field: ConfigField) => + field.type === "boolean" ? ( + + set(field.key, v)} /> + + ) : ( + + {field.type === "text" ? ( +