Files
Scoreboard/server/internal/httpapi/fonts.go
T
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

317 lines
10 KiB
Go

package httpapi
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
)
// ---------- 字体清单 ----------
// fontDTO 是 GET /api/fonts 的一项,也是上传成功后返回的对象。
// 内置字体没有文件,URL 为 null;上传字体带 /uploads/fonts/<文件名> 下的静态地址。
type fontDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Builtin bool `json:"builtin"`
URL *string `json:"url"`
}
// builtinFonts 是随前端一起分发的内置字体(@font-face 见 web/src/shared/styles/fonts.css)。
// id 与 CSS 家族名的对应关系是固定契约,前端 web/src/display/fonts.ts 必须与这里一致。
var builtinFonts = []fontDTO{
{ID: "builtin:noto-sans", Label: "Noto Sans SC(思源黑体同源)", Builtin: true},
{ID: "builtin:noto-serif", Label: "Noto Serif SC(思源宋体同源)", Builtin: true},
{ID: "builtin:misans", Label: "MiSans", Builtin: true},
{ID: "builtin:source-han-sans", Label: "Source Han Sans CN(思源黑体)", Builtin: true},
}
// 上传字体的限制:扩展名白名单与单文件上限。
const (
maxFontBytes = 20 << 20
maxFontFormSize = maxFontBytes + 1<<20
customFontID = "custom:"
)
// allowedFontTypes 是允许上传的字体扩展名与对应 Content-Type。
var allowedFontTypes = map[string]string{
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
}
func isBuiltinFontID(id string) bool {
for _, f := range builtinFonts {
if f.ID == id {
return true
}
}
return false
}
// fontsDir 返回当前赛事项目的字体目录 <项目目录>/uploads/fonts。
// 放在 uploads 下是为了随赛事包一起走:导出整包就带上了字体。
func (s *server) fontsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法管理字体"}
}
return filepath.Join(dir, "uploads", "fonts"), nil
}
// safeFontName 把客户端文件名收敛成安全落盘名。
//
// 先取 Base 去掉目录部分,再只保留 ASCII 字母数字、点、下划线、连字符与汉字,
// 其余字符替换为下划线:字体是外部资源,名字会进 URL 与磁盘路径,必须挡住路径穿越;
// 保留汉字是为了让「思源宋体.ttf」这类中文名还能认出是什么字体。
// 返回 false 表示收敛后为空或只剩扩展名,调用方按 400 处理。
func safeFontName(raw string) (string, bool) {
// Windows 客户端可能传 "C:\Fonts\思源.ttf",反斜杠在 Linux 上不会被 Base 拆开。
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(raw), `\`, "/"))
var out strings.Builder
for _, r := range base {
switch {
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-'):
out.WriteRune(r)
case unicode.Is(unicode.Han, r):
out.WriteRune(r)
default:
out.WriteRune('_')
}
}
name := out.String()
ext := strings.ToLower(filepath.Ext(name))
if name == "" || name == ext {
return "", false
}
if _, ok := allowedFontTypes[ext]; !ok {
return "", false
}
return name, true
}
// fontContentType 校验上传字体的文件名并给出 Content-Type。
// 只接受「单层文件名 + 白名单扩展名」,且必须与 safeFontName 收敛结果一致。
func fontContentType(name string) (string, bool) {
if name == "" || strings.ContainsAny(name, `/\`) {
return "", false
}
safe, ok := safeFontName(name)
if !ok || safe != name {
return "", false
}
return allowedFontTypes[strings.ToLower(filepath.Ext(name))], true
}
// customFontName 从 `custom:<文件名>` 形式的 id 里取出文件名;不是上传字体时返回 false。
func customFontName(id string) (string, bool) {
if !strings.HasPrefix(id, customFontID) {
return "", false
}
name := strings.TrimPrefix(id, customFontID)
if _, ok := fontContentType(name); !ok {
return "", false
}
return name, true
}
// checkDisplayFont 校验 display.font_* 的取值:空串表示用默认字体,
// 其余必须是内置 id,或确实存在于 uploads/fonts 下的上传字体。
func (s *server) checkDisplayFont(key, value string) error {
id := strings.TrimSpace(value)
if id == "" {
return nil
}
if isBuiltinFontID(id) {
return nil
}
name, ok := customFontName(id)
if !ok {
return badRequest(key+" 不是已知字体,请从字体列表中选择", value)
}
dir, err := s.fontsDir()
if err != nil {
return err
}
if info, err := os.Stat(filepath.Join(dir, name)); err != nil || info.IsDir() {
return badRequest(key+" 引用的字体 "+name+" 不存在,请先上传", value)
}
return nil
}
// ---------- 接口 ----------
// listFontsHandler 返回内置字体(固定顺序在前)与已上传字体(按文件名排序)。
func (s *server) listFontsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
items := make([]fontDTO, len(builtinFonts))
copy(items, builtinFonts)
// 没有字体目录说明还没上传过,属于正常状态而不是错误。
if dir, err := s.fontsDir(); err == nil {
entries, err := os.ReadDir(dir)
if err == nil {
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
if _, ok := fontContentType(e.Name()); ok {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
items = append(items, customFontDTO(name))
}
}
}
return map[string]any{"fonts": items}, nil
}
func customFontDTO(name string) fontDTO {
url := "/uploads/fonts/" + name
return fontDTO{ID: customFontID + name, Label: name, Builtin: false, URL: &url}
}
// uploadFontHandler 保存 multipart file 字段里的字体文件,成功返回 201 与字体对象。
func (s *server) uploadFontHandler(w http.ResponseWriter, r *http.Request) (any, error) {
dir, err := s.fontsDir()
if err != nil {
return nil, err
}
r.Body = http.MaxBytesReader(w, r.Body, maxFontFormSize)
if err := r.ParseMultipartForm(maxFontFormSize); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return nil, fontTooLarge()
}
return nil, badRequest("上传内容解析失败", err.Error())
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", nil)
}
defer func() { _ = file.Close() }()
name, ok := safeFontName(header.Filename)
if !ok {
return nil, badRequest("字体文件名非法,只支持 ttf/otf/woff/woff2 且需带文件名", header.Filename)
}
// header.Size 由客户端声明,不可信,按真实读到的字节数判限。
body, err := io.ReadAll(io.LimitReader(file, maxFontBytes+1))
if err != nil {
return nil, badRequest("读取上传内容失败", err.Error())
}
if len(body) > maxFontBytes {
return nil, fontTooLarge()
}
if len(body) == 0 {
return nil, badRequest("上传内容为空", nil)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
// O_EXCL:并发上传同名文件时只有一个成功,其余按重名拒绝。
out, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if errors.Is(err, os.ErrExist) {
return nil, conflict("字体 " + name + " 已存在,请先删除或重命名后再上传")
}
if err != nil {
return nil, internal(err)
}
if _, err := out.Write(body); err != nil {
_ = out.Close()
_ = os.Remove(filepath.Join(dir, name))
return nil, internal(err)
}
if err := out.Close(); err != nil {
_ = os.Remove(filepath.Join(dir, name))
return nil, internal(err)
}
s.audit(r.Context(), "font.upload", "font", 0, nil, map[string]any{"file": name, "size": len(body)})
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": customFontDTO(name)})
return nil, errHandled
}
// fontTooLarge 返回 413:字体超过上限时必须给出状态码,前端才知道是「太大」而不是「格式不对」。
func fontTooLarge() *apiError {
return &apiError{status: http.StatusRequestEntityTooLarge, code: "VALIDATION_FAILED",
message: "字体文件超过 20 MiB 上限"}
}
// deleteFontHandler 删除上传字体;仍被 display.font_title / display.font_body 引用时拒绝。
func (s *server) deleteFontHandler(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
if _, ok := fontContentType(name); !ok {
return notFound("字体不存在")
}
dir, err := s.fontsDir()
if err != nil {
return err
}
if err := s.ensureFontUnused(r, name); err != nil {
return err
}
if err := os.Remove(filepath.Join(dir, name)); err != nil {
if os.IsNotExist(err) {
return notFound("字体不存在")
}
return internal(err)
}
s.audit(r.Context(), "font.delete", "font", 0, map[string]any{"file": name}, nil)
return nil
}
// ensureFontUnused 挡住「删掉正在被大屏使用的字体」:删了之后大屏只剩回落字体,
// 现场看到的是「字体突然变了」,比直接拒绝更难排查。
func (s *server) ensureFontUnused(r *http.Request, name string) error {
settings, err := s.getSettings(r.Context())
if err != nil {
return err
}
id := customFontID + name
for _, ref := range []*string{settings.Display.FontTitle, settings.Display.FontBody} {
if ref != nil && *ref == id {
return conflict("字体 " + name + " 正在被大屏使用,请先在赛事信息里改掉标题/正文字体")
}
}
return nil
}
// serveFont 匿名提供上传字体:大屏的 @font-face 不带会话,不能要求登录。
func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := fontContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
dir, err := s.fontsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
w.Header().Set("Content-Type", ctype)
// 字体名是用户起的、可以同名重传,不能像随机名图片那样长缓存,否则换了字体现场看不出来。
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, file)
}