Files
AskaEth fc96d17660 feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容
三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
2026-09-10 17:45:11 +08:00

72 lines
2.5 KiB
Go

package httpapi
import "testing"
// 文件名收敛规则:取 Base、只保留 [A-Za-z0-9._-] 与汉字、其余换成下划线,
// 结果为空或只剩扩展名时拒绝。它同时守住落盘路径与 URL,不允许有例外。
func TestSafeFontName(t *testing.T) {
ok := map[string]string{
"my-font.ttf": "my-font.ttf",
"思源宋体.otf": "思源宋体.otf",
"Noto Sans SC.woff2": "Noto_Sans_SC.woff2",
`C:\Fonts\思源 宋体.TTF`: "思源_宋体.TTF",
"../../etc/passwd.ttf": "passwd.ttf",
"/abs/path/font.woff": "font.woff",
"字体 v2 (正式).woff2": "字体_v2__正式_.woff2",
"a/b/c/d/..name...ttf": "..name...ttf",
`sub\dir\zh-cn-bold.ttf`: "zh-cn-bold.ttf",
}
for raw, want := range ok {
got, valid := safeFontName(raw)
if !valid || got != want {
t.Errorf("safeFontName(%q) = (%q, %v),期望 (%q, true)", raw, got, valid, want)
}
}
// 只有扩展名、空名、非白名单扩展名一律拒绝。
for _, raw := range []string{"", " ", ".ttf", ".woff2", "字体.xlsx", "font", `C:\Fonts\`, "..", "../"} {
if got, valid := safeFontName(raw); valid {
t.Errorf("safeFontName(%q) 应拒绝,得到 %q", raw, got)
}
}
}
// 静态访问只认「上传时收敛过的名字」,避免用别名读到别人的文件或目录。
func TestFontContentType(t *testing.T) {
want := map[string]string{
"a.ttf": "font/ttf",
"a.OTF": "font/otf",
"a.woff": "font/woff",
"a.woff2": "font/woff2",
"思源宋体.ttf": "font/ttf",
}
for name, ctype := range want {
got, ok := fontContentType(name)
if !ok || got != ctype {
t.Errorf("fontContentType(%q) = (%q, %v),期望 %q", name, got, ok, ctype)
}
}
for _, name := range []string{"", "a.png", "..", "../a.ttf", "sub/a.ttf", `sub\a.ttf`, "a b.ttf", "没有扩展名"} {
if _, ok := fontContentType(name); ok {
t.Errorf("fontContentType(%q) 应拒绝", name)
}
}
}
// 字体 id 的解析与生成必须对称,否则删除接口会找不到文件。
func TestCustomFontName(t *testing.T) {
name, ok := customFontName("custom:思源宋体.ttf")
if !ok || name != "思源宋体.ttf" {
t.Fatalf("customFontName = (%q, %v)", name, ok)
}
for _, id := range []string{"builtin:noto-sans", "custom:", "custom:../x.ttf", "custom:x.png", "思源宋体.ttf"} {
if _, ok := customFontName(id); ok {
t.Errorf("customFontName(%q) 应拒绝", id)
}
}
if !isBuiltinFontID("builtin:misans") || isBuiltinFontID("builtin:nope") {
t.Error("builtin 字体 id 白名单不正确")
}
}