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

182
cmd/favoritter/main.go Normal file
View file

@ -0,0 +1,182 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"kode.naiv.no/olemd/favoritter/internal/config"
"kode.naiv.no/olemd/favoritter/internal/database"
"kode.naiv.no/olemd/favoritter/internal/handler"
"kode.naiv.no/olemd/favoritter/internal/middleware"
"kode.naiv.no/olemd/favoritter/internal/render"
"kode.naiv.no/olemd/favoritter/internal/store"
)
var (
version = "dev"
buildDate = "unknown"
)
func main() {
// Handle -healthcheck flag for container health checks.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
runHealthCheck()
return
}
// Handle -version flag.
if len(os.Args) > 1 && os.Args[1] == "-version" {
fmt.Printf("favoritter %s (built %s)\n", version, buildDate)
return
}
cfg := config.Load()
// Set up structured logging.
logLevel := slog.LevelInfo
if cfg.DevMode {
logLevel = slog.LevelDebug
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
slog.SetDefault(logger)
slog.Info("starting favoritter", "version", version, "build_date", buildDate)
// Open database and run migrations.
db, err := database.Open(cfg.DBPath)
if err != nil {
slog.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
if err := database.Migrate(db); err != nil {
slog.Error("failed to run migrations", "error", err)
os.Exit(1)
}
// Initialize stores.
users := store.NewUserStore(db)
sessions := store.NewSessionStore(db)
settings := store.NewSettingsStore(db)
faves := store.NewFaveStore(db)
tags := store.NewTagStore(db)
signupRequests := store.NewSignupRequestStore(db)
sessions.SetLifetime(cfg.SessionLifetime)
// Ensure initial admin user exists.
if err := users.EnsureAdmin(cfg.AdminUsername, cfg.AdminPassword); err != nil {
slog.Error("failed to ensure admin user", "error", err)
os.Exit(1)
}
// Initialize template renderer.
renderer, err := render.New(cfg)
if err != nil {
slog.Error("failed to initialize templates", "error", err)
os.Exit(1)
}
// Configure Argon2 parameters from config.
store.Argon2Memory = cfg.Argon2Memory
store.Argon2Time = cfg.Argon2Time
store.Argon2Parallelism = cfg.Argon2Parallelism
// Build the handler with all dependencies.
h := handler.New(handler.Deps{
Config: cfg,
Users: users,
Sessions: sessions,
Settings: settings,
Faves: faves,
Tags: tags,
SignupRequests: signupRequests,
Renderer: renderer,
})
// Build the middleware chain.
mux := h.Routes()
chain := middleware.Chain(
mux,
middleware.Recovery,
middleware.SecurityHeaders,
middleware.BasePath(cfg.BasePath),
middleware.RealIP(cfg.TrustedProxies),
middleware.RequestLogger,
middleware.SessionLoader(sessions, users),
middleware.CSRFProtection(cfg),
)
srv := &http.Server{
Addr: cfg.Listen,
Handler: chain,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start background cleanup goroutines.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sessions.CleanupLoop(ctx, 1*time.Hour)
go h.RateLimiterCleanupLoop(ctx, 5*time.Minute)
// Start the server in a goroutine.
errCh := make(chan error, 1)
go func() {
slog.Info("listening", "addr", cfg.Listen, "base_path", cfg.BasePath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
// Wait for interrupt signal or server error.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-quit:
slog.Info("shutting down", "signal", sig)
case err := <-errCh:
slog.Error("server error", "error", err)
}
// Graceful shutdown with 10-second timeout.
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "error", err)
}
slog.Info("stopped")
}
// runHealthCheck performs a health check by hitting the local health endpoint.
// It reads the configured listen address to determine the correct port.
func runHealthCheck() {
cfg := config.Load()
addr := cfg.Listen
// If addr is just ":port", use localhost.
if len(addr) > 0 && addr[0] == ':' {
addr = "127.0.0.1" + addr
}
resp, err := http.Get("http://" + addr + cfg.BasePath + "/health")
if err != nil {
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
os.Exit(1)
}
}