Files
Cyrene_v2/internal/services/gateway/upload.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

372 lines
12 KiB
Go

package gateway
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// maxUploadBytes 把单张上传图片限制在 5 MiB。multipart 信封
// (边界、部件头)在其上多留一点余量;解码后的
// 文件本身按这个确切上限校验。
const (
maxUploadBytes = 5 << 20
uploadMultipartSlack = 1 << 20
uploadNameBytes = 16
uploadSniffBytes = 512
)
// uploadFilePrefix 是提供已存上传的同源静态路由
// (在 Handler 中注册)。它位于 /api/v1/ 之下,因此归网关 mux 而非
// SPA 回退所有,也因此属于版本化客户端契约。
const uploadFilePrefix = "/api/v1/files/"
// uploadResponse 是 POST /api/v1/upload 的成功载荷。URL 是由请求
// 权威构建的绝对 http(s) URL,因此能直接交给
// chat.image:视觉层只把 http(s):// 归类为 URL,否则会把
// 裸路径当作 base64。Name 是服务端生成的存储
// 名;客户端原始文件名绝不回显或持久化。
type uploadResponse struct {
URL string `json:"url"`
Size int64 `json:"size"`
MIME string `json:"mime"`
Name string `json:"name"`
}
// handleUpload 接受带单个 `file` 部件的 multipart/form-data 请求,
// 校验它是在大小限制内的光栅图片,并以密码学随机的
// 名字存到 <dataDir>/uploads 下,审计该写操作,
// 返回可作为 chat.image 附上的 URL。它仅 owner/admin(注册在
// Handler 中)。客户端原始文件名被刻意忽略:它由
// 攻击者控制,可能携带路径分隔符或覆盖文件。
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+uploadMultipartSlack)
if err := r.ParseMultipartForm(1 << 20); err != nil {
s.auditClaims(r, "upload.image", "error")
writeUploadBodyError(w, err)
return
}
file, header, err := r.FormFile("file")
if err != nil {
s.auditClaims(r, "upload.image", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "file is required"))
return
}
defer func() { _ = file.Close() }()
if header.Size <= 0 {
s.auditClaims(r, "upload.image", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "file is empty"))
return
}
if header.Size > maxUploadBytes {
s.auditClaims(r, "upload.image", "error")
writeError(w, apperr.Wrap(apperr.ErrPayloadTooLarge, "payload_too_large", "file exceeds the 5MB limit"))
return
}
mime, err := sniffImageMIME(file)
if err != nil {
s.auditClaims(r, "upload.image", "error")
s.log.Error("upload sniff", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "upload_failed", "failed to read upload"))
return
}
ext, ok := uploadExtForMIME(mime)
if !ok {
s.auditClaims(r, "upload.image", "error")
writeError(w, apperr.Wrap(apperr.ErrUnsupportedMediaType, "unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted"))
return
}
name, err := s.storeUpload(file, ext)
if err != nil {
s.auditClaims(r, "upload.image", "error")
s.log.Error("upload store", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "upload_failed", "failed to store upload"))
return
}
s.auditClaims(r, "upload.image", "ok")
writeJSON(w, http.StatusCreated, uploadResponse{
URL: s.uploadURL(r, name),
Size: header.Size,
MIME: mime,
Name: name,
})
}
// guardUploadPaths 拒绝解码后路径含父目录段的静态上传
// 请求,赶在 ServeMux 清理它并把客户端重定向到一个非上传
// 路径(SPA 随后会用 index.html 应答)之前。{name} 路由
// 只匹配一个段,因此 `..` 只可能是逃逸尝试;这里返回 404
// 使每一种穿越形态都被统一拒绝。非上传路径
// 原样放行。
func guardUploadPaths(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, uploadFilePrefix) && hasDotDotSegment(r.URL.Path) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
next.ServeHTTP(w, r)
})
}
// hasDotDotSegment 报告 p 是否有字面的 ".." 路径段。
func hasDotDotSegment(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return true
}
}
return false
}
// handleUploadFile 从 <dataDir>/uploads 提供一份已存上传。它
// 按设计不认证:返回的 URL 会交给 chat.image 与
// <img> 标签,二者都无法附带 bearer token。随机的 128 位
// 名字就是能力;handler 只接受严格的十六进制+已知扩展名
// 名字,且绝不让请求路径逃出 uploads 目录。
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validUploadName(name) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
dir, err := s.uploadDir()
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
path := filepath.Join(dir, name)
if filepath.Dir(path) != filepath.Clean(dir) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
mime, ok := uploadMIMEForExt(filepath.Ext(name))
if !ok {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "file not found"))
return
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, name, info.ModTime(), f)
}
// writeUploadBodyError 把 multipart 解析失败映射为其线上错误:
// 超限请求体是 413,其他(非 multipart、部件畸形)是 400。
func writeUploadBodyError(w http.ResponseWriter, err error) {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeError(w, apperr.Wrap(apperr.ErrPayloadTooLarge, "payload_too_large", "file exceeds the 5MB limit"))
return
}
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid multipart body"))
}
// sniffImageMIME 用标准库嗅探器检测上传头部的类型
// (绝不用客户端提供的 Content-Type 头,它不可信),
// 并回绕部件,使调用方能完整复制它。
func sniffImageMIME(f multipart.File) (string, error) {
buf := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(f, buf)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return "", fmt.Errorf("read upload head: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return "", fmt.Errorf("rewind upload: %w", err)
}
return http.DetectContentType(buf[:n]), nil
}
// uploadExtForMIME 把嗅探出的图片 MIME 映射为磁盘上使用的扩展名。
// 白名单刻意只含光栅:SVG(与 HTML)会被内联服务
// 并可能执行脚本,因此不被接受。
func uploadExtForMIME(mime string) (string, bool) {
switch mime {
case "image/png":
return ".png", true
case "image/jpeg":
return ".jpg", true
case "image/gif":
return ".gif", true
case "image/webp":
return ".webp", true
case "image/bmp":
return ".bmp", true
default:
return "", false
}
}
// uploadMIMEForExt 返回已存上传扩展名对应的 Content-Type。它是
// uploadExtForMIME 的读侧孪生:只有白名单内的扩展名能解析,
// 这正是 validUploadName 严格的原因。
func uploadMIMEForExt(ext string) (string, bool) {
switch strings.ToLower(ext) {
case ".png":
return "image/png", true
case ".jpg", ".jpeg":
return "image/jpeg", true
case ".gif":
return "image/gif", true
case ".webp":
return "image/webp", true
case ".bmp":
return "image/bmp", true
default:
return "", false
}
}
// storeUpload 把 f 写入 <dataDir>/uploads/<random>.<ext> 并返回
// 生成的名字。它先写一个点前缀的临时文件再把它重命名
// 到位,因此部分上传绝不会以可服务的名字可见。
// 目录以 0700 创建,文件继承临时文件的 0600 权限。
func (s *Server) storeUpload(f io.Reader, ext string) (string, error) {
dir, err := s.uploadDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create upload dir: %w", err)
}
name, err := randomUploadName(ext)
if err != nil {
return "", err
}
tmp, err := os.CreateTemp(dir, ".upload-*")
if err != nil {
return "", fmt.Errorf("create upload temp: %w", err)
}
tmpName := tmp.Name()
if _, err := io.Copy(tmp, io.LimitReader(f, maxUploadBytes+1)); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return "", fmt.Errorf("write upload: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return "", fmt.Errorf("close upload: %w", err)
}
if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil {
_ = os.Remove(tmpName)
return "", fmt.Errorf("commit upload: %w", err)
}
return name, nil
}
// uploadDir 返回 <dataDir>/uploads。解析后的数据目录由
// 组合根注入(Deps.DataDir,即 resolveDataDir 交给 DB 与
// 运行时配置的同一个值);已配置的 cfg.DataDir 是回退,使
// 未注入构建的 Server 仍能解析其上传目录。
func (s *Server) uploadDir() (string, error) {
base := strings.TrimSpace(s.dataDir)
if base == "" {
base = strings.TrimSpace(s.cfg.DataDir)
}
if base == "" {
return "", fmt.Errorf("gateway: data directory not configured")
}
return filepath.Join(base, "uploads"), nil
}
// randomUploadName 返回追加 ext 的 128 位随机十六进制名。它
// 是唯一被使用的文件名组成部分:客户端原始名被丢弃。
func randomUploadName(ext string) (string, error) {
buf := make([]byte, uploadNameBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("random upload name: %w", err)
}
return hex.EncodeToString(buf) + ext, nil
}
// validUploadName 报告 name 是否恰好是 32 位小写十六进制主名
// 加上白名单内的图片扩展名。这是静态路由的路径穿越
// 防护:分隔符、点、双点及任何其他形态都在
// 触碰文件系统之前被拒绝。
func validUploadName(name string) bool {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") {
return false
}
stem, ext, ok := strings.Cut(name, ".")
if !ok || len(stem) != uploadNameBytes*2 || ext == "" {
return false
}
for _, c := range stem {
if !isLowerHex(c) {
return false
}
}
_, ok = uploadMIMEForExt("." + ext)
return ok
}
// isLowerHex 报告 c 是否是 ASCII 小写十六进制数字。
func isLowerHex(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
}
// uploadURL 为一份已存上传构建绝对同源 URL。scheme
// 遵循 X-Forwarded-Proto(反向代理),权威是请求
// host,并做净化,使畸形的 Host 头无法把路径语法夹带进
// 返回的 URL;已配置的监听地址是回退。
func (s *Server) uploadURL(r *http.Request, name string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := forwardedProto(r); proto != "" {
scheme = proto
}
host := sanitizeHost(r.Host)
if host == "" {
host = net.JoinHostPort(s.cfg.Server.Host, strconv.Itoa(s.cfg.Server.Port))
}
return scheme + "://" + host + uploadFilePrefix + name
}
// forwardedProto 在首个 X-Forwarded-Proto 值为 http 或
// https 时返回它,否则返回 ""。
func forwardedProto(r *http.Request) string {
v := r.Header.Get("X-Forwarded-Proto")
if v == "" {
return ""
}
v = strings.ToLower(strings.TrimSpace(strings.Split(v, ",")[0]))
if v == "http" || v == "https" {
return v
}
return ""
}
// sanitizeHost 拒绝含路径、userinfo 或空白语法的 host
// 值;结果为空会让 uploadURL 回退到已配置的地址。
func sanitizeHost(host string) string {
host = strings.TrimSpace(host)
if host == "" || strings.ContainsAny(host, "/\\@ \t") {
return ""
}
return host
}