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:
commit
fc1f7259c5
52 changed files with 5459 additions and 0 deletions
154
internal/image/image.go
Normal file
154
internal/image/image.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue