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>
154 lines
4 KiB
Go
154 lines
4 KiB
Go
// 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
|
|
}
|