// SPDX-License-Identifier: AGPL-3.0-or-later // Package image handles uploaded image validation, processing, and storage. // It strips EXIF metadata by re-encoding images and resizes to a maximum width. package image import ( "fmt" "image" "image/jpeg" "image/png" "io" "mime/multipart" "os" "path/filepath" "strings" "github.com/google/uuid" // Register image format decoders. _ "image/gif" ) const ( MaxWidth = 1920 JPEGQuality = 85 ) // AllowedTypes maps MIME types to file extensions. var AllowedTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", } // ProcessResult holds the result of processing an uploaded image. type ProcessResult struct { Filename string // UUID-based filename with extension Path string // Full path where the image was saved } // Process validates, re-encodes (stripping EXIF), and optionally resizes an // uploaded image. It saves the result to uploadDir with a UUID filename. // // Re-encoding to JPEG or PNG strips all EXIF metadata including GPS coordinates, // which is important for user privacy. func Process(file multipart.File, header *multipart.FileHeader, uploadDir string) (*ProcessResult, error) { // Validate content type. contentType := header.Header.Get("Content-Type") ext, ok := AllowedTypes[contentType] if !ok { return nil, fmt.Errorf("unsupported image type: %s (allowed: JPEG, PNG, GIF, WebP)", contentType) } // Decode the image — this also validates it's a real image. img, format, err := image.Decode(file) if err != nil { return nil, fmt.Errorf("invalid image: %w", err) } // Resize if wider than MaxWidth, maintaining aspect ratio. img = resizeIfNeeded(img) // Generate UUID filename. filename := uuid.New().String() + ext // Ensure upload directory exists. if err := os.MkdirAll(uploadDir, 0750); err != nil { return nil, fmt.Errorf("create upload dir: %w", err) } fullPath := filepath.Join(uploadDir, filename) outFile, err := os.Create(fullPath) if err != nil { return nil, fmt.Errorf("create output file: %w", err) } defer outFile.Close() // Re-encode the image, which strips all EXIF metadata. if err := encode(outFile, img, format, ext); err != nil { os.Remove(fullPath) return nil, fmt.Errorf("encode image: %w", err) } return &ProcessResult{ Filename: filename, Path: fullPath, }, nil } // Delete removes an uploaded image file. func Delete(uploadDir, filename string) error { if filename == "" { return nil } path := filepath.Join(uploadDir, filename) // Only delete if the file is actually inside the upload directory // to prevent path traversal. absPath, err := filepath.Abs(path) if err != nil { return err } absDir, err := filepath.Abs(uploadDir) if err != nil { return err } if !strings.HasPrefix(absPath, absDir+string(filepath.Separator)) { return fmt.Errorf("path traversal detected") } return os.Remove(absPath) } // encode writes the image in the appropriate format. // GIF and WebP are re-encoded as PNG since Go's stdlib can decode but not // encode GIF animations or WebP. This is acceptable — we prioritize EXIF // stripping over format preservation. func encode(w io.Writer, img image.Image, format, ext string) error { switch { case format == "jpeg" || ext == ".jpg": return jpeg.Encode(w, img, &jpeg.Options{Quality: JPEGQuality}) default: // PNG for everything else (png, gif, webp). return png.Encode(w, img) } } // resizeIfNeeded scales the image down if it exceeds MaxWidth. // Uses nearest-neighbor for simplicity — good enough for a favorites app. func resizeIfNeeded(img image.Image) image.Image { bounds := img.Bounds() w := bounds.Dx() h := bounds.Dy() if w <= MaxWidth { return img } newW := MaxWidth newH := h * MaxWidth / w dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) // Simple bilinear-ish downscale by sampling. for y := 0; y < newH; y++ { for x := 0; x < newW; x++ { srcX := x * w / newW srcY := y * h / newH dst.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) } } return dst }