From 3dc95edf2eefb9fd92efc49d7e64c0aa8b552814 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:10:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E4=B8=BB=E9=A2=98=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E2=80=94=E2=80=94theme.json=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=EF=BC=8C=E9=9D=A2=E6=9D=BF=E7=BC=96=E8=BE=91=EF=BC=8Chub=20?= =?UTF-8?q?=E6=8C=89=E4=B8=BB=E9=A2=98=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主题在 theme.json 的 config 里声明设置项(单行/多行文本、数字、开关、下拉,外加不存值的 分组标题),面板主题卡片上出现设置按钮并按声明画表单。值存在数据库的 setting 表里,按主题 short 区分,更新、重装、删除主题都不影响,备份也带着。 GET /api/themes/{short}/config 返回站长改过的项,可见范围同 /api/nodes;PUT 要管理员登录, 主题须已安装,整个对象受路由 64 KiB 请求体上限约束。面板只保存与默认值不同的项,并保留 表单不认识的 key。 --- src/api.rs | 79 +++++++++++++++++ src/frontend.rs | 8 +- src/main.rs | 2 + web-admin/src/components/Admin.tsx | 133 ++++++++++++++++++++++++++++- web-admin/src/lib/api.test.ts | 29 ++++++- web-admin/src/lib/api.ts | 85 ++++++++++++++++++ 6 files changed, 330 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index 3a1f37c5..043fb148 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1509,6 +1509,57 @@ 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`. +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(); + } + let saved = app.db.get(&theme_config_key(&short)).unwrap_or_else(|| "{}".into()); + ([(header::CONTENT_TYPE, "application/json")], saved).into_response() +} + +/// 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 +2293,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/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..81697206 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, configValues, currentIface, 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,112 @@ 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 = configFields(theme.config) + const [values, setValues] = useState(() => configValues(fields, 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 + + async function save(e: React.FormEvent) { + e.preventDefault() + // 数字框编辑时存的是输入框里的原文;是否为空、是否越界已由浏览器按 required/min/max 拦下。 + const typed = Object.fromEntries( + fields.map((f) => [f.key, f.type === "number" ? Number(values[f.key]) : values[f.key]]), + ) + setSaving(true) + try { + await api(`/themes/${theme.short}/config`, { + method: "PUT", + body: JSON.stringify(configOverrides(fields, saved, typed)), + }) + toast.success("主题设置已保存,公开页刷新后生效") + onClose() + } catch (e) { + toast.error((e as Error).message) + } finally { + setSaving(false) + } + } + + return ( + !open && onClose()}> + e.preventDefault()} className="sm:max-w-lg"> + + {theme.name} 设置 + + 保存后公开页刷新即生效,更新、重装主题都不会丢失。这里填的内容所有访客都能看到,不要填密码或密钥。 + + +
+ {form.map((field, index) => + field.type === "title" ? ( +

{field.label}

+ ) : field.type === "boolean" ? ( + + set(field.key, v)} /> + + ) : ( + + {field.type === "text" ? ( +