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 请求, // 校验它是在大小限制内的光栅图片,并以密码学随机的 // 名字存到 /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 从 /uploads 提供一份已存上传。它 // 按设计不认证:返回的 URL 会交给 chat.image 与 // 标签,二者都无法附带 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 写入 /uploads/. 并返回 // 生成的名字。它先写一个点前缀的临时文件再把它重命名 // 到位,因此部分上传绝不会以可服务的名字可见。 // 目录以 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 返回 /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 }