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:
+103
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user