feat(font): 系统管理里可设置正文字体(font 目录或链接)

- 新增 GET /api/fonts 列出二进制同级 font/ 目录里的字体文件,/fonts/<文件名> 对外提供(只认一级文件名,拒绝穿越)
- 站点设置新增 font.url / font.family,校验只接受 http(s):// 或 /fonts/ 开头的链接
- 前台按设置注入 @font-face 或 CSS 链接并覆盖 --su-font-sans;留空回到内置文楷
- 后台「系统管理」新增字体选择 + 链接 + 族名;撤销/保存按钮覆盖该页
- 撤掉上一轮的霞鹜新晰黑改动
This commit is contained in:
2026-11-05 18:12:29 +08:00
parent e243a20b69
commit 0bb4cc354a
13 changed files with 316 additions and 15 deletions
+103
View File
@@ -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<AppState>) -> 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<Vec<serde_json::Value>> {
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<AppState>, Path(path): Path<String>) -> 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()