diff --git a/ALRIGHT-DESIGN.md b/ALRIGHT-DESIGN.md index 03224a2..b8be143 100644 --- a/ALRIGHT-DESIGN.md +++ b/ALRIGHT-DESIGN.md @@ -88,7 +88,7 @@ | 角色 | 字体 | 说明 | | --- | --- | --- | -| 正文 | **LXGW Neo XiHei(霞鹜新晰黑)** | 应用当前使用;`cdn.jsdelivr.net/npm/cn-fontsource-lxgw-neo-xi-hei-regular` 按 unicode-range 分片加载,在 `stash-web/src/index.css` 覆盖 `--su-font-sans`(组件库自身默认仍是文楷) | +| 正文 | **LXGW WenKai(霞鹜文楷)** | 内置默认,`cdn.staticfile.net`;后台「系统管理 → 字体」可换成 `font/` 目录里的字体或外部字体链接 | | 回退 | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif` | 字体加载失败时 | | 数字(主题) | `Outfit-Medium` | 仅主题的 `font-price` / `font-number` 使用,Stash 未引入 | | 等宽 | `ui-monospace, Consolas, monospace` | 路由地址等路径文本 | diff --git a/HANDOFF.md b/HANDOFF.md index cc2b112..0092ee7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -113,6 +113,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 "header": { "enabled": true, "speed": 24, "gap": 26, "items": [...] }, "sidebar": { "show_profile": true, "show_service": false, "show_nav": true }, "background": { "light": "", "dark": "" }, // 空 = 内置背景 + "font": { "url": "", "family": "" }, // 空 = 内置文楷;url 为 /fonts/xxx 或外部链接 "search": { "default": "internal", "engines": [ @@ -148,7 +149,7 @@ stash-ui/src/components/ SearchField / Input / EngineIcon 等 - 侧边栏:资料卡 / 本地服务 / 分组导航显隐; - 内容卡片:卡片 + 勾选分组 + 预览条数 + 排序; - **资料管理**:头像、名称、角色、简介、位置; -- **系统管理**:改密码、下载备份。 +- **系统管理**:改密码、下载备份、正文字体(`font/` 目录里的文件或外部字体 / CSS 链接)。 搜索框行为: @@ -176,6 +177,7 @@ GET /api/catalog2 分组树 + 卡片 GET /api/catalog/events SSE:catalog 变化 GET /api/settings PATCH /api/settings 需登录,{version,settings} +GET /api/fonts font/ 目录里的字体清单(公开读) POST /api/nodes 需登录 PATCH /api/nodes/{key} 需登录 DELETE /api/nodes/{key} 需登录 diff --git a/README.md b/README.md index 6526f5c..5c569fd 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 - **分组管理**:树形表格,可建根 / 子分组、图标(字符或 iconfont 图标名)、排序权重、启停、删除; - **数据管理**:选分组后列出条目(含子分组),每条可单独设格式,可新建 / 编辑 / 删除; - **首页管理**(子项):**页头**(站点信息 / 操作工具 / 滚动信息三个页签)、**侧边栏**(三块侧栏卡片显隐)、**内容卡片**(左侧可拖拽排序的卡片列表 + 右侧分组表格:状态、预览条数、顺序、展示); - - **资料管理**与**系统管理**(改密码、下载整库备份); + - **资料管理**与**系统管理**(改密码、下载整库备份;系统管理里还能设正文字体:从程序同级的 `font/` 目录选,或填字体链接); - 分组的「状态」是**唯一可见性来源**:停用的分组在侧栏和卡片里都不再出现,层层往下生效; - 详情页的 `[编辑]` 只在登录后显示;保存后局部刷新,`updated_at` 乐观锁; - `/admin` 的「系统管理」页可以改密码、下载整库备份(`VACUUM INTO` 快照); @@ -77,6 +77,7 @@ cargo run -- --root ./sites --host 0.0.0.0 --port 3000 | `/c/**`、`/g/**` | 前端路由地址(分组页 / 条目详情),没有实体文件时同样返回 `index.html` | | `/admin`、`/login`、`/interface` | 固定页面地址,同样返回 `index.html` | | `/assets/**` | 内嵌前端的静态资源(文件名带 hash,长缓存) | +| `/fonts/**` | 程序同级 `font/` 目录里的字体文件(后台字体设置用,`no-cache`) | | `/favicon.svg` | 内嵌前端根目录文件 | | 其他路径 | `ServeDir` 托管 `--root` 目录,目录自动补 `index.html` | | 仍未命中 | 内置 404 页面(显示请求路径) | @@ -544,7 +545,7 @@ data: {"changed":true} | --- | --- | | 健康与项目 | `GET /api/health`、`GET /api/projects`、`GET /api/projects/events`(SSE) | | 内容库 | `GET /api/catalog2`、`GET /api/catalog/events`(SSE)、`POST /api/nodes`、`PATCH/DELETE /api/nodes/{key}`、`GET /api/items/{key}`、`POST /api/items`、`PATCH/DELETE /api/items/{key}`、`POST /api/items/{key}/view`、`POST /api/items/{key}/like`、`POST /api/cards`、`PATCH/DELETE /api/cards/{id}`、`PUT /api/cards/{id}/groups` | -| 站点设置 | `GET/PATCH /api/settings`、`GET /api/backup` | +| 站点设置 | `GET/PATCH /api/settings`、`GET /api/fonts`(`font/` 目录里的字体清单)、`GET /api/backup` | | 认证 | `GET /api/auth/session`、`POST /api/auth/setup`、`POST /api/auth/login`、`POST /api/auth/logout`、`PATCH /api/auth/password` | 写接口(POST / PATCH / PUT / DELETE)都要求登录会话,并校验请求的 `Origin` 与本站同源。 diff --git a/src/config.rs b/src/config.rs index db871db..86c8cf9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,8 @@ use crate::error::StashError; /// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。 pub const DATA_DIR_NAME: &str = "data"; +/// 字体目录名:与二进制同级,后台「系统管理 → 字体」只从这个目录列字体。 +pub const FONT_DIR_NAME: &str = "font"; /// 内容库文件名:固定为这个名字,一个实例一个库。 pub const DB_FILE_NAME: &str = "stash.db"; @@ -13,6 +15,8 @@ pub struct Config { pub root: PathBuf, /// 数据目录:存放内容库与将来的备份 / 导出文件 pub data_dir: PathBuf, + /// 字体目录:后台字体下拉只列这里的文件,通过 `/fonts/<文件名>` 对外提供 + pub font_dir: PathBuf, /// 内容库路径,固定为 `data/stash.db` pub db_path: PathBuf, /// 监听端口 @@ -34,6 +38,7 @@ impl Default for Config { root: PathBuf::from("../public"), db_path: resolve_db_path(&data_dir), data_dir, + font_dir: PathBuf::from(FONT_DIR_NAME), port: 8080, host: "127.0.0.1".to_string(), directory_listing: true, @@ -65,6 +70,18 @@ pub fn resolve_db_path(data_dir: &Path) -> PathBuf { data_dir.join(DB_FILE_NAME) } +/// 字体目录的默认位置:**二进制所在目录**下的 `font/`,理由同 `data/`。 +pub fn default_font_dir() -> PathBuf { + default_font_dir_from(std::env::current_exe().ok().as_deref()) +} + +fn default_font_dir_from(exe: Option<&Path>) -> PathBuf { + match exe.and_then(Path::parent) { + Some(dir) => dir.join(FONT_DIR_NAME), + None => PathBuf::from(FONT_DIR_NAME), + } +} + /// 校验内容库没有落在静态托管目录里。 /// /// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供, @@ -151,6 +168,15 @@ mod tests { assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME)); } + #[test] + fn font_dir_follows_the_binary_directory() { + assert_eq!( + default_font_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))), + PathBuf::from(r"D:\apps\stash\font") + ); + assert_eq!(default_font_dir_from(None), PathBuf::from(FONT_DIR_NAME)); + } + #[test] fn db_path_is_fixed_inside_the_data_dir() { assert_eq!( diff --git a/src/db/settings.rs b/src/db/settings.rs index b9b942c..2df925b 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -119,6 +119,17 @@ pub struct BackgroundSettings { pub dark: String, } +/// 正文字体:空链接表示内置文楷;否则是 `font/` 目录里的文件(`/fonts/...`) +/// 或外部字体 / CSS 链接。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(default)] +pub struct FontSettings { + /// 字体文件或 CSS 链接;空字符串表示用内置霞鹜文楷 + pub url: String, + /// 字体族名:链接到 CSS 时填 CSS 里声明的名字;字体文件留空则用 `Stash Font` + pub family: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct SearchEngineSettings { @@ -288,6 +299,7 @@ pub struct Settings { pub header: HeaderSettings, pub sidebar: SidebarSettings, pub background: BackgroundSettings, + pub font: FontSettings, pub search: SearchSettings, pub profile: ProfileSettings, pub site: SiteMetaSettings, @@ -354,6 +366,22 @@ pub fn validate(settings: &Settings) -> Result<(), StashError> { "暗色背景地址最多 500 个字符".into(), )); } + if settings.font.url.chars().count() > 500 { + return Err(StashError::InvalidDirectory("字体链接最多 500 个字符".into())); + } + let font_url = settings.font.url.trim(); + if !font_url.is_empty() + && !(font_url.starts_with("http://") + || font_url.starts_with("https://") + || font_url.starts_with("/fonts/")) + { + return Err(StashError::InvalidDirectory( + "字体链接需以 http(s):// 或 /fonts/ 开头".into(), + )); + } + if settings.font.family.chars().count() > 100 { + return Err(StashError::InvalidDirectory("字体族名最多 100 个字符".into())); + } if settings.search.engines.len() > 10 { return Err(StashError::InvalidDirectory("搜索引擎最多 10 个".into())); } @@ -551,6 +579,14 @@ mod tests { let mut empty = Settings::default(); empty.header.items.clear(); assert!(save(&mut conn, empty, 0).is_err()); + + let mut bad_font = Settings::default(); + bad_font.font.url = "javascript:alert(1)".into(); + assert!(save(&mut conn, bad_font, 0).is_err()); + + let mut ok_font = Settings::default(); + ok_font.font.url = "/fonts/x.woff2".into(); + assert!(save(&mut conn, ok_font, 0).is_ok()); } #[test] diff --git a/src/main.rs b/src/main.rs index 2c2f37b..f9b3ecf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use clap::Parser; use colored::*; use stash::{ Config, - config::{default_data_dir, display_path, resolve_db_path, validate_db_location}, + config::{default_data_dir, default_font_dir, display_path, resolve_db_path, validate_db_location}, start, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -94,10 +94,17 @@ async fn main() -> anyhow::Result<()> { let data_dir = data_dir.canonicalize().unwrap_or(data_dir); let db_path = resolve_db_path(&data_dir); + // 字体目录:跟 data/ 一样放在二进制旁边,后台只从这里列字体 + let font_dir = default_font_dir(); + std::fs::create_dir_all(&font_dir)?; + let font_dir = font_dir.canonicalize().unwrap_or(font_dir); + let font_dir_display = display_path(&font_dir); + // 4. 构造配置并启动 let config = Config { root: abs_root.clone(), data_dir: data_dir.clone(), + font_dir, db_path: db_path.clone(), port: cli.port, host: cli.host, @@ -121,6 +128,7 @@ async fn main() -> anyhow::Result<()> { println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline()); println!(" ➜ 托管路径: {}", display_path(&abs_root).white()); println!(" ➜ 内容库: {}", display_path(&db_path).white()); + println!(" ➜ 字体目录: {}", font_dir_display.white()); println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white()); println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed()); println!("{}", "--------------------------------------------------".bright_blue()); diff --git a/src/server.rs b/src/server.rs index bd6bee9..c39d17c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -589,6 +589,80 @@ async fn patch_settings( } } +// ---------------- 字体 ---------------- + +/// 字体文件后缀:后台字体下拉只列这几种。 +const FONT_EXTENSIONS: [&str; 4] = ["woff2", "woff", "ttf", "otf"]; + +/// GET /api/fonts —— `font/` 目录里的字体文件(后台字体下拉用,公开读) +async fn list_fonts(State(state): State) -> Response { + let dir = state.config.font_dir.clone(); + match tokio::task::spawn_blocking(move || scan_fonts(&dir)).await { + Ok(Ok(fonts)) => Json(serde_json::json!({ "fonts": fonts })).into_response(), + Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()), + } +} + +/// 列目录:目录不存在按「没有字体」处理,不让后台报错。 +fn scan_fonts(dir: &std::path::Path) -> std::io::Result> { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + + let mut fonts: Vec<(String, serde_json::Value)> = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + continue; + }; + if !FONT_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str()) { + continue; + } + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + fonts.push(( + name.to_string(), + serde_json::json!({ "name": name, "url": format!("/fonts/{name}") }), + )); + } + fonts.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(fonts.into_iter().map(|(_, value)| value).collect()) +} + +/// GET /fonts/{path} —— `font/` 目录里的字体文件。 +/// 只认一级文件名:`..`、子目录一律 404,避免越界读文件。 +async fn font_asset(State(state): State, Path(path): Path) -> Response { + let reject = || not_found_page(&format!("/fonts/{path}")); + if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") { + return reject(); + } + + let file = state.config.font_dir.join(&path); + let bytes = match tokio::fs::read(&file).await { + Ok(bytes) => bytes, + Err(_) => return reject(), + }; + + let mime = mime_guess::from_path(&path).first_or_octet_stream(); + let content_type = HeaderValue::from_str(mime.as_ref()) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); + ( + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, HeaderValue::from_static(HTML_CACHE)), + ], + bytes, + ) + .into_response() +} + // ---------------- 条目编辑 / 备份 ---------------- /// GET /api/items/{key} —— 编辑表单的初始值(公开,便于未登录时预览) @@ -970,6 +1044,8 @@ fn build_router(state: AppState) -> Router { .route("/api/auth/logout", post(auth_logout)) .route("/api/auth/password", patch(auth_password)) .route("/api/settings", get(get_settings).patch(patch_settings)) + .route("/api/fonts", get(list_fonts)) + .route("/fonts/{*path}", get(font_asset)) .route("/api/items/{key}", get(get_item).patch(patch_item).delete(delete_item)) .route("/api/items", post(create_item)) .route("/api/items/{key}/view", post(post_view)) @@ -1092,6 +1168,33 @@ mod tests { }) } + #[test] + fn scan_fonts_lists_only_font_files() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/font-scan"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("应能建目录"); + std::fs::write(dir.join("b.ttf"), b"x").expect("应能写入"); + std::fs::write(dir.join("a.WOFF2"), b"x").expect("应能写入"); + std::fs::write(dir.join("note.txt"), b"x").expect("应能写入"); + + let fonts = scan_fonts(&dir).expect("应能列目录"); + let names: Vec<_> = fonts + .iter() + .map(|font| font["name"].as_str().unwrap_or_default()) + .collect(); + assert_eq!(names, vec!["a.WOFF2", "b.ttf"]); + assert_eq!(fonts[0]["url"], "/fonts/a.WOFF2"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn scan_fonts_treats_missing_dir_as_empty() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/no-such-font-dir"); + let _ = std::fs::remove_dir_all(&dir); + assert!(scan_fonts(&dir).expect("目录不存在不应报错").is_empty()); + } + /// 发一个 GET,返回(状态码,Content-Type,响应体)。 async fn get(path: &str) -> (StatusCode, String, String) { let response = router() diff --git a/stash-web/index.html b/stash-web/index.html index 39c0e35..8335957 100644 --- a/stash-web/index.html +++ b/stash-web/index.html @@ -13,10 +13,10 @@ document.documentElement.dataset.scheme = dark ? 'dark' : 'light' } catch (e) {} - + Stash diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index 0f07064..fa34e5a 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -333,6 +333,7 @@ export default function AppNext() { show_nav: true, }, background: cachedBackground ?? { light: '', dark: '' }, + font: { url: '', family: '' }, search: { default: 'internal', engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })), @@ -408,6 +409,41 @@ export default function AppNext() { } }, [scheme]) + // 正文字体:空链接用内置文楷;否则注入 @font-face(字体文件)或 CSS 链接,并覆盖字体令牌 + useEffect(() => { + const url = site.font.url.trim() + const family = site.font.family.trim() + const root = document.documentElement + + document.getElementById('stash-font-link')?.remove() + document.getElementById('stash-font-face')?.remove() + + if (url === '') { + root.style.removeProperty('--su-font-sans') + return + } + + const familyName = family === '' ? 'Stash Font' : family + if (url.toLowerCase().endsWith('.css')) { + const link = document.createElement('link') + link.id = 'stash-font-link' + link.rel = 'stylesheet' + link.href = url + document.head.appendChild(link) + } else { + const style = document.createElement('style') + style.id = 'stash-font-face' + // 用 JSON.stringify 包成 CSS 字符串,引号 / 反斜杠不用自己转义 + style.textContent = `@font-face{font-family:${JSON.stringify(familyName)};src:url(${JSON.stringify(url)});font-display:swap}` + document.head.appendChild(style) + } + + root.style.setProperty( + '--su-font-sans', + `${JSON.stringify(familyName)}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif`, + ) + }, [site.font.url, site.font.family]) + /** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */ const toggleTheme = () => { const next: ThemeChoice = isDark ? 'light' : 'dark' diff --git a/stash-web/src/api.ts b/stash-web/src/api.ts index 9ba6e52..845e5a6 100644 --- a/stash-web/src/api.ts +++ b/stash-web/src/api.ts @@ -30,6 +30,24 @@ type ProjectEventsHandlers = { onConnectionChange?: (connected: boolean) => void } +export type FontFile = { + name: string + /** `/fonts/<文件名>`,直接当作字体文件地址用 */ + url: string +} + +/** 读取服务端 `font/` 目录里的字体文件(后台字体下拉用)。 */ +export async function fetchFonts(): Promise { + try { + const response = await fetch('/api/fonts', { headers: { Accept: 'application/json' } }) + if (!response.ok) return [] + const payload = (await response.json()) as { fonts?: FontFile[] } + return Array.isArray(payload.fonts) ? payload.fonts : [] + } catch { + return [] + } +} + /** * 订阅服务端的项目快照推送。 * 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。 diff --git a/stash-web/src/components/admin/AdminNext.tsx b/stash-web/src/components/admin/AdminNext.tsx index aa6f4ba..6337631 100644 --- a/stash-web/src/components/admin/AdminNext.tsx +++ b/stash-web/src/components/admin/AdminNext.tsx @@ -67,7 +67,7 @@ import { } from '../../manage' import { createItem, deleteItem, fetchItem, saveItem } from '../../items' import { useProjects } from '../../useProjects' -import type { Project } from '../../api' +import { fetchFonts, type FontFile, type Project } from '../../api' import siteBgLight from '../../assets/site-bg.jpg' import siteBgDark from '../../assets/site-bg-dark.jpg' @@ -411,12 +411,18 @@ export function AdminNext() { /> )} - {tab === 'system' && } + {tab === 'system' && ( + setDraft({ ...draft, font })} + /> + )} {(tab === 'home-header' || tab === 'home-background' || tab === 'home-search' || - tab === 'profile') && ( + tab === 'profile' || + tab === 'system') && (