Files
Cyrene_v2/internal/services/gateway/upload.go
T
AskaEth 7ecb43bd5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 图片上传端点(B1) — POST /v1/upload + GET /v1/files/{name}
- upload.go: multipart上传(owner/admin+审计)→ {url,size,mime,name}; MaxBytesReader 5MB + DetectContentType嗅探 + 白名单raster(拒SVG/HTML) + crypto/rand 128-bit随机名 + 0700目录
- 静态服务: 同端口 GET /v1/files/{name}, 能力URL, 路径穿越全形态拒绝(.. / %2f / %2e%2e), nosniff, Range
- errors: ErrPayloadTooLarge(413)/ErrUnsupportedMediaType(415)
- server.go 路由+Deps.DataDir; wire.go resolveDataDir 注入
- 8测试+真实二进制E2E, build/vet/test绿+交叉编译
- Docs/api-client v0.24 + api-reference §0.3 同步
2026-09-08 21:36:03 +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 caps a single uploaded image at 5 MiB. The multipart envelope
// (boundaries, part headers) gets a little headroom on top of that; the decoded
// file itself is checked against this exact limit.
const (
maxUploadBytes = 5 << 20
uploadMultipartSlack = 1 << 20
uploadNameBytes = 16
uploadSniffBytes = 512
)
// uploadFilePrefix is the same-origin static route that serves stored uploads
// (registered in Handler). It lives under /api/v1/ so the gateway mux — not the
// SPA fallback — owns it, and so it is part of the versioned client contract.
const uploadFilePrefix = "/api/v1/files/"
// uploadResponse is the POST /api/v1/upload success payload. URL is an absolute
// http(s) URL built from the request authority so it can be handed straight to
// chat.image: the vision layer classifies only http(s):// as a URL and would
// otherwise treat a bare path as base64. Name is the server-generated storage
// name; the client's original filename is never echoed back or persisted.
type uploadResponse struct {
URL string `json:"url"`
Size int64 `json:"size"`
MIME string `json:"mime"`
Name string `json:"name"`
}
// handleUpload accepts a multipart/form-data request with a single `file` part,
// validates it is a raster image within the size limit, stores it under
// <dataDir>/uploads under a cryptographically random name, audits the write and
// returns the URL to attach as chat.image. It is owner/admin only (registered in
// Handler). The client's original filename is deliberately ignored: it is
// attacker-controlled and could carry a path separator or overwrite a file.
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 rejects a static-upload request whose decoded path contains a
// parent-directory segment, before ServeMux can clean it and redirect the client
// to a non-upload path (which the SPA would then answer with index.html). The
// {name} route only ever matches one segment, so a `..` can only be an escape
// attempt; answering 404 here keeps every traversal shape uniformly rejected.
// Non-upload paths pass through untouched.
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 reports whether p has a literal ".." path segment.
func hasDotDotSegment(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return true
}
}
return false
}
// handleUploadFile serves a stored upload from <dataDir>/uploads. It is
// unauthenticated by design: the returned URL is handed to chat.image and to
// <img> tags, neither of which can attach a bearer token. The random 128-bit
// name is the capability; the handler accepts only a strict hex+known-extension
// name and never lets a request path escape the uploads directory.
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 maps a multipart parse failure to its wire error: an
// over-limit body is 413, anything else (not multipart, malformed part) is 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 detects the content type of the upload head with the stdlib
// sniffer (never the client-supplied Content-Type header, which is untrusted)
// and rewinds the part so the caller can copy it in full.
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 maps a sniffed image MIME to the extension used on disk. The
// whitelist is deliberately raster-only: SVG (and HTML) would be served inline
// and could execute script, so they are not accepted.
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 returns the Content-Type for a stored upload extension. It is
// the read-side twin of uploadExtForMIME: only whitelisted extensions resolve,
// which is what makes validUploadName strict.
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 writes f to <dataDir>/uploads/<random>.<ext> and returns the
// generated name. It writes to a dot-prefixed temp file first and renames it
// into place, so a partial upload is never visible under a servable name. The
// directory is created 0700 and the file inherits the temp file's 0600 mode.
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 returns <dataDir>/uploads. The resolved data dir is injected by the
// composition root (Deps.DataDir, the same value resolveDataDir hands the DB and
// the runtime configs); the configured cfg.DataDir is the fallback so a Server
// built without the injection still resolves its uploads.
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 returns a 128-bit random hex name with ext appended. It is
// the only filename component ever used: the client's original name is dropped.
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 reports whether name is exactly a 32-lowercase-hex stem plus a
// whitelisted image extension. This is the path-traversal guard for the static
// route: separators, dots, dots-dots and any other shape are rejected before the
// filesystem is touched.
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 reports whether c is an ASCII lowercase hex digit.
func isLowerHex(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
}
// uploadURL builds the absolute, same-origin URL for a stored upload. The scheme
// honours X-Forwarded-Proto (reverse proxy) and the authority is the request
// host, sanitized so a malformed Host header cannot smuggle path syntax into the
// returned URL; the configured listen address is the fallback.
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 returns the first X-Forwarded-Proto value when it is http or
// https, else "".
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 rejects a host value containing path, userinfo or whitespace
// syntax; an empty result makes uploadURL fall back to the configured address.
func sanitizeHost(host string) string {
host = strings.TrimSpace(host)
if host == "" || strings.ContainsAny(host, "/\\@ \t") {
return ""
}
return host
}