// Package uploads handles image uploads for the branding flow (Tier 2 Block D). // // In dev we write to the local filesystem and serve via a dedicated HTTP // handler; in production the same interface should be backed by S3 + CDN // (left as a deployment task, not application code — same split the project // uses for backups and DB infra). // // Validation philosophy: never trust the Content-Type header from the // client. We decode the bytes through Go's image stdlib and re-encode them // to a known format. That strips EXIF, normalises the byte stream, and // turns a "spoof an exe as a PNG" attempt into a decode error instead of a // stored payload. package uploads import ( "bytes" "context" "crypto/rand" "encoding/hex" "errors" "fmt" "image" // Register the standard decoders so image.Decode recognises jpeg/png. "image/jpeg" "image/png" "io" "os" "path/filepath" "strings" ) // MaxUploadBytes caps every accepted upload. 2 MB covers logos + cover // photos comfortably; the host's browser usually scales further when the // page actually renders. const MaxUploadBytes = 2 * 1024 * 1024 // Format names normalised post-decode. Anything else we reject. const ( FormatJPEG = "jpeg" FormatPNG = "png" ) // ImageStore is the production seam. LocalFSStore is the dev impl; a future // S3Store will satisfy the same interface so handlers don't change. type ImageStore interface { // Save persists the (re-encoded) bytes under a fresh random key and // returns the public URL the frontend should reference. Save(ctx context.Context, body []byte, format string) (publicURL string, err error) // FilePath maps a publicly-served filename back to a local path so the // dev HTTP handler can stream it. S3 impls would return "" + an error // (their URLs hit the CDN directly, not the API). FilePath(filename string) (string, error) } // LocalFSStore writes each upload to a flat directory on disk. Filenames // are 16 hex bytes + the canonical extension so two uploads can't collide // and a guessing attacker has 128 bits to brute-force. type LocalFSStore struct { // Dir is the on-disk directory. Created on first call if missing. Dir string // PublicBase is the URL prefix the frontend will use to fetch saved // images, e.g. "http://localhost:8080/uploads". The API exposes a // matching handler at PublicBase's path. PublicBase string } // Save re-encodes and writes the image to disk. Returns the full public // URL the host should store in their branding row. func (s *LocalFSStore) Save(ctx context.Context, body []byte, format string) (string, error) { if err := os.MkdirAll(s.Dir, 0o755); err != nil { return "", fmt.Errorf("mkdir uploads: %w", err) } ext := ".jpg" if format == FormatPNG { ext = ".png" } name := randName() + ext dest := filepath.Join(s.Dir, name) if err := os.WriteFile(dest, body, 0o644); err != nil { return "", fmt.Errorf("write upload: %w", err) } base := strings.TrimRight(s.PublicBase, "/") return base + "/" + name, nil } // FilePath maps a served filename back to its local path. Refuses any // path with a separator so we can't be tricked into serving /etc/passwd. func (s *LocalFSStore) FilePath(filename string) (string, error) { if filename == "" || strings.ContainsAny(filename, "/\\") || strings.HasPrefix(filename, ".") { return "", errors.New("invalid upload filename") } return filepath.Join(s.Dir, filename), nil } // DecodeAndReencode validates `raw` as a recognised image format and // returns the re-encoded bytes plus the canonical format name. Anything // that doesn't parse as JPEG/PNG (or that exceeds MaxUploadBytes) returns // an error. // // We deliberately don't trust the Content-Type header here — `image.Decode` // sniffs the magic bytes. Stripped through the encoder, the output is the // same image without EXIF / hidden chunks. func DecodeAndReencode(raw []byte) (out []byte, format string, err error) { if len(raw) == 0 { return nil, "", errors.New("empty upload") } if len(raw) > MaxUploadBytes { return nil, "", fmt.Errorf("upload exceeds %d byte limit", MaxUploadBytes) } img, gotFormat, err := image.Decode(bytes.NewReader(raw)) if err != nil { return nil, "", fmt.Errorf("not a recognised image: %w", err) } var buf bytes.Buffer switch gotFormat { case "jpeg": format = FormatJPEG if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 88}); err != nil { return nil, "", fmt.Errorf("re-encode jpeg: %w", err) } case "png": format = FormatPNG if err := png.Encode(&buf, img); err != nil { return nil, "", fmt.Errorf("re-encode png: %w", err) } default: return nil, "", fmt.Errorf("unsupported image format %q (jpeg/png only)", gotFormat) } return buf.Bytes(), format, nil } // PeekReader reads up to MaxUploadBytes+1 from r. We add one byte so the // caller can tell "exactly at the limit" from "over the limit" without // pulling the whole body into RAM up front for size-only rejection. func PeekReader(r io.Reader) ([]byte, error) { return io.ReadAll(io.LimitReader(r, MaxUploadBytes+1)) } func randName() string { var b [16]byte _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) }