//! 站点级设置(页头滚动信息、侧栏顺序/显隐)。 //! //! 存 `setting` 表:`key = 'site'`,`value` 是一段 JSON,`version` 做乐观锁。 //! 读的时候任何字段缺失都回落默认值,坏 JSON 也回落默认值—— //! 设置坏掉不应该让首页白屏。 use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use crate::{db::map_error, error::StashError}; pub const SETTINGS_KEY: &str = "site"; pub const MAX_HEADER_ITEMS: usize = 8; pub const MIN_SPEED: u32 = 8; pub const MAX_SPEED: u32 = 120; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum Tone { #[default] Plain, Primary, Success, Neutral, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct HeaderItem { /// 文本,支持占位符 `{count}` / `{project}` / `{status}` pub text: String, /// 前置小标签,如「本地」「状态」 pub tag: Option, pub tone: Tone, } impl Default for HeaderItem { fn default() -> Self { Self { text: String::new(), tag: None, tone: Tone::Plain, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct HeaderSettings { pub enabled: bool, /// 滚动一轮的秒数 pub speed: u32, /// 信息条目之间的间距(px),默认 26 ≈ 两个中文字宽 pub gap: u32, pub items: Vec, } impl Default for HeaderSettings { fn default() -> Self { Self { enabled: true, speed: 24, gap: 26, items: vec![ HeaderItem { text: "共 {count} 个本地应用".into(), tag: None, tone: Tone::Plain, }, HeaderItem { text: "{project}".into(), tag: Some("本地".into()), tone: Tone::Primary, }, HeaderItem { text: "{status}".into(), tag: Some("状态".into()), tone: Tone::Success, }, ], } } } fn default_true() -> bool { true } impl Default for SidebarSettings { fn default() -> Self { Self { show_profile: true, show_service: true, show_nav: true, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct SidebarSettings { /// 是否显示「本地管理员」资料卡 #[serde(default = "default_true")] pub show_profile: bool, /// 是否显示「本地服务」面板 #[serde(default = "default_true")] pub show_service: bool, /// 是否显示「分组导航」卡片(首页 / 分组树) #[serde(default = "default_true")] pub show_nav: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct BackgroundSettings { /// 亮色背景图地址;空表示用内置图 pub light: String, /// 暗色背景图地址;空表示用内置图 pub dark: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct SearchEngineSettings { pub id: String, pub name: String, /// 搜索地址模板,用 `{query}` 表示关键词 pub url: String, pub enabled: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct SearchSettings { /// `internal` 或某个引擎 id pub default: String, pub engines: Vec, } impl Default for SearchSettings { fn default() -> Self { Self { default: "internal".into(), engines: vec![ SearchEngineSettings { id: "google".into(), name: "Google".into(), url: "https://www.google.com/search?q={query}".into(), enabled: true, }, SearchEngineSettings { id: "bing".into(), name: "Bing".into(), url: "https://www.bing.com/search?q={query}".into(), enabled: true, }, SearchEngineSettings { id: "baidu".into(), name: "百度".into(), url: "https://www.baidu.com/s?wd={query}".into(), enabled: true, }, SearchEngineSettings { id: "sogou".into(), name: "搜狗".into(), url: "https://www.sogou.com/web?query={query}".into(), enabled: true, }, SearchEngineSettings { id: "360".into(), name: "360".into(), url: "https://www.so.com/s?q={query}".into(), enabled: true, }, ], } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct ProfileSettings { pub name: String, pub role: String, pub description: String, pub location: String, /// 头像地址;空字符串表示用首字占位 pub avatar: String, } impl Default for ProfileSettings { fn default() -> Self { Self { name: "本地管理员".into(), role: "管理员".into(), description: "本地静态站点托管与预览,所有项目都运行在这台机器上。".into(), location: "IP属地:局域网".into(), avatar: String::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct BrandSettings { /// 品牌文字(页头左侧,可留空只显示 logo) pub name: String, /// 没有 logo 图片时,圆形标记里显示的首字 pub mark: String, /// logo 图片地址;空字符串表示用首字 pub logo: String, } impl Default for BrandSettings { fn default() -> Self { Self { name: "STASH".into(), mark: "S".into(), logo: String::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct ActionSettings { /// 明暗切换图标 #[serde(default = "default_true")] pub theme: bool, /// 刷新图标(重扫本地项目并重拉内容库) pub refresh: bool, /// 站点设置图标(仅登录后出现) pub settings: bool, /// 登录 / 退出登录 pub session: bool, } impl Default for ActionSettings { fn default() -> Self { Self { theme: true, refresh: true, settings: true, session: true, } } } /// 配色:浅色 / 深色 / 跟随系统。 /// 落到 `` 上,深色由组件库的令牌块接管,页面不用改组件。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ThemeSetting { Light, Dark, #[default] Auto, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct SiteMetaSettings { /// 浏览器标签标题 pub title: String, /// favicon 地址;空字符串表示用内置图标 pub favicon: String, /// 配色 pub theme: ThemeSetting, pub brand: BrandSettings, pub actions: ActionSettings, } impl Default for SiteMetaSettings { fn default() -> Self { Self { title: "Stash · 本地应用工作区".into(), favicon: String::new(), theme: ThemeSetting::Auto, brand: BrandSettings::default(), actions: ActionSettings::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, pub background: BackgroundSettings, pub search: SearchSettings, pub profile: ProfileSettings, pub site: SiteMetaSettings, } /// 读到/保存后的完整状态:设置本体 + 版本号(前端提交时要带回来)。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SettingsDocument { pub version: i64, pub settings: Settings, } /// 校验:条数、长度、速度范围。 pub fn validate(settings: &Settings) -> Result<(), StashError> { if settings.header.items.is_empty() { return Err(StashError::InvalidDirectory("页头至少保留一条信息".into())); } if settings.header.items.len() > MAX_HEADER_ITEMS { return Err(StashError::InvalidDirectory(format!( "页头最多 {MAX_HEADER_ITEMS} 条信息" ))); } if !(MIN_SPEED..=MAX_SPEED).contains(&settings.header.speed) { return Err(StashError::InvalidDirectory(format!( "滚动速度需在 {MIN_SPEED}–{MAX_SPEED} 秒之间" ))); } if settings.header.gap > 120 { return Err(StashError::InvalidDirectory("信息间距最多 120".into())); } for item in &settings.header.items { let text = item.text.trim(); if text.is_empty() { return Err(StashError::InvalidDirectory("信息文本不能为空".into())); } if text.chars().count() > 60 { return Err(StashError::InvalidDirectory("单条信息最多 60 个字".into())); } if let Some(tag) = &item.tag { if tag.chars().count() > 8 { return Err(StashError::InvalidDirectory("标签最多 8 个字".into())); } } } if settings.profile.name.chars().count() > 40 { return Err(StashError::InvalidDirectory("名称最多 40 个字".into())); } if settings.profile.role.chars().count() > 20 { return Err(StashError::InvalidDirectory("角色最多 20 个字".into())); } if settings.profile.description.chars().count() > 200 { return Err(StashError::InvalidDirectory("描述最多 200 个字".into())); } if settings.profile.location.chars().count() > 40 { return Err(StashError::InvalidDirectory("位置最多 40 个字".into())); } if settings.background.light.chars().count() > 500 { return Err(StashError::InvalidDirectory( "亮色背景地址最多 500 个字符".into(), )); } if settings.background.dark.chars().count() > 500 { return Err(StashError::InvalidDirectory( "暗色背景地址最多 500 个字符".into(), )); } if settings.search.engines.len() > 10 { return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into())); } let mut ids = std::collections::HashSet::new(); for engine in &settings.search.engines { if !matches!( engine.id.as_str(), "google" | "bing" | "baidu" | "sogou" | "360" ) { return Err(StashError::InvalidDirectory("搜索引擎标识不合法".into())); } if !ids.insert(engine.id.as_str()) { return Err(StashError::InvalidDirectory("搜索引擎标识不能重复".into())); } let name = engine.name.trim(); if name.is_empty() || name.chars().count() > 20 { return Err(StashError::InvalidDirectory("搜索引擎名称需为 1–20 个字".into())); } let url = engine.url.trim(); if url.chars().count() > 500 || !(url.starts_with("http://") || url.starts_with("https://")) || !url.contains("{query}") { return Err(StashError::InvalidDirectory( "搜索地址需以 http(s):// 开头并包含 {query}".into(), )); } } let default = settings.search.default.trim(); if default != "internal" { let engine = settings .search .engines .iter() .find(|engine| engine.id == default) .ok_or_else(|| StashError::InvalidDirectory("默认搜索引擎不存在".into()))?; if !engine.enabled { return Err(StashError::InvalidDirectory("默认搜索引擎未启用".into())); } } if settings.site.title.chars().count() > 120 { return Err(StashError::InvalidDirectory("页面标题最多 120 个字".into())); } if settings.site.favicon.chars().count() > 500 { return Err(StashError::InvalidDirectory("favicon 地址最多 500 个字符".into())); } if settings.site.brand.name.chars().count() > 20 { return Err(StashError::InvalidDirectory("品牌文字最多 20 个字".into())); } if settings.site.brand.mark.chars().count() > 4 { return Err(StashError::InvalidDirectory("logo 标记最多 4 个字".into())); } if settings.site.brand.logo.chars().count() > 500 { return Err(StashError::InvalidDirectory("logo 地址最多 500 个字符".into())); } Ok(()) } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or(0) } /// 读设置;没有记录时返回默认值与 version = 0。 pub fn load(conn: &Connection) -> Result { let row: Option<(String, i64)> = conn .query_row( "SELECT value, version FROM setting WHERE key = ?1", [SETTINGS_KEY], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(map_error)?; let Some((raw, version)) = row else { return Ok(SettingsDocument { version: 0, settings: Settings::default(), }); }; let settings = serde_json::from_str::(&raw).unwrap_or_else(|error| { tracing::warn!("设置 JSON 解析失败,使用默认值: {error}"); Settings::default() }); Ok(SettingsDocument { version, settings }) } /// 保存设置;`expected_version` 与库里不一致时返回冲突。 pub fn save( conn: &mut Connection, settings: Settings, expected_version: i64, ) -> Result { validate(&settings)?; let value = serde_json::to_string(&settings) .map_err(|error| StashError::Database(format!("设置序列化失败: {error}")))?; let tx = conn.transaction().map_err(map_error)?; let updated = if expected_version == 0 { tx.execute( "INSERT INTO setting (key, value, version, updated_at) VALUES (?1, ?2, 1, ?3) ON CONFLICT(key) DO NOTHING", params![SETTINGS_KEY, value, now()], ) .map_err(map_error)? } else { tx.execute( "UPDATE setting SET value = ?1, version = version + 1, updated_at = ?2 WHERE key = ?3 AND version = ?4", params![value, now(), SETTINGS_KEY, expected_version], ) .map_err(map_error)? }; if updated == 0 { return Err(StashError::Conflict( "设置已被其它页面修改,请刷新后重试".into(), )); } let (version,): (i64,) = tx .query_row( "SELECT version FROM setting WHERE key = ?1", [SETTINGS_KEY], |row| Ok((row.get(0)?,)), ) .map_err(map_error)?; tx.commit().map_err(map_error)?; Ok(SettingsDocument { version, settings }) } #[cfg(test)] mod tests { use super::*; fn db() -> Connection { let conn = Connection::open_in_memory().expect("内存库应能打开"); conn.execute_batch( "CREATE TABLE setting ( key TEXT PRIMARY KEY, value TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL );", ) .expect("应能建表"); conn } #[test] fn defaults_are_stable_and_valid() { let settings = Settings::default(); assert!(settings.header.enabled); assert_eq!(settings.header.items.len(), 3); assert!(validate(&settings).is_ok()); } #[test] fn roundtrip_and_version_bump() { let mut conn = db(); let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功"); assert_eq!(saved.version, 1); let loaded = load(&conn).expect("应能读"); assert_eq!(loaded, saved); let mut next = saved.settings.clone(); next.header.speed = 40; let saved2 = save(&mut conn, next, saved.version).expect("更新应成功"); assert_eq!(saved2.version, 2); assert_eq!(saved2.settings.header.speed, 40); } #[test] fn stale_version_conflicts() { let mut conn = db(); let saved = save(&mut conn, Settings::default(), 0).expect("首次保存应成功"); let error = save(&mut conn, Settings::default(), saved.version - 1).expect_err("应冲突"); assert!(matches!(error, StashError::Conflict(_))); } #[test] fn invalid_settings_are_rejected() { let mut conn = db(); let mut bad = Settings::default(); bad.header.speed = 1; assert!(save(&mut conn, bad, 0).is_err()); let mut empty = Settings::default(); empty.header.items.clear(); assert!(save(&mut conn, empty, 0).is_err()); } #[test] fn broken_json_falls_back_to_defaults() { let conn = db(); conn.execute( "INSERT INTO setting (key, value, version, updated_at) VALUES (?1, '{bad json', 3, 0)", [SETTINGS_KEY], ) .expect("应能写入"); let loaded = load(&conn).expect("坏 JSON 不应报错"); assert_eq!(loaded.version, 3); assert_eq!(loaded.settings, Settings::default()); } }