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>
This commit is contained in:
Ole-Morten Duesund 2026-03-29 15:55:22 +02:00
commit fc1f7259c5
52 changed files with 5459 additions and 0 deletions

154
internal/image/image.go Normal file
View file

@ -0,0 +1,154 @@
// 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
}