三块改动,均为现场反馈:
一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 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/ 同步更新。
317 lines
10 KiB
Go
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)
|
|
}
|