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) }