favoritter/internal/image/image.go

154 lines
4 KiB
Go
Raw Normal View History

feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation Go backend with server-rendered HTML/HTMX frontend, SQLite database, and filesystem image storage. Self-hostable single-binary architecture. Phase 1 — Authentication & project foundation: - Argon2id password hashing with timing-attack prevention - Session management with cookie-based auth and periodic cleanup - Login, signup (open/requests/closed modes), logout, forced password reset - CSRF double-submit cookie pattern with HTMX auto-inclusion - Proxy-aware real IP extraction (WireGuard/Tailscale support) - Configurable base path for subdomain and subpath deployment - Rate limiting on auth endpoints with background cleanup - Security headers (CSP, X-Frame-Options, Referrer-Policy) - Structured logging with slog, graceful shutdown - Pico CSS + HTMX vendored and embedded via go:embed Phase 2 — Faves CRUD with tags and images: - Full CRUD for favorites with ownership checks - Image upload with EXIF stripping, resize to 1920px, UUID filenames - Tag system with HTMX autocomplete (prefix search, popularity-sorted) - Privacy controls (public/private per fave, user-configurable default) - Tag browsing, pagination, batch tag loading (avoids N+1) - OpenGraph meta tags on public fave detail pages Includes code quality pass: extracted shared helpers, fixed signup request persistence bug, plugged rate limiter memory leak, removed dead code, and logged previously-swallowed errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:55:22 +02:00
// 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
}