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 // /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 /uploads. It is // unauthenticated by design: the returned URL is handed to chat.image and to // 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 /uploads/. 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 /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 }