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

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"context"
"errors"
"net/http"
"kode.naiv.no/olemd/favoritter/internal/store"
)
const SessionCookieName = "session"
// ClearSessionCookie sets an expired session cookie to remove it from the client.
func ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
}
// SessionLoader loads the user from the session cookie on every request.
// If the session is valid, the user is attached to the request context.
func SessionLoader(sessions *store.SessionStore, users *store.UserStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil {
next.ServeHTTP(w, r)
return
}
session, err := sessions.Validate(cookie.Value)
if err != nil {
if errors.Is(err, store.ErrSessionNotFound) {
ClearSessionCookie(w)
}
next.ServeHTTP(w, r)
return
}
user, err := users.GetByID(session.UserID)
if err != nil || user.Disabled {
sessions.Delete(cookie.Value)
ClearSessionCookie(w)
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"net/http"
"strings"
)
// BasePath strips a configured path prefix from incoming requests so the
// router sees paths without the prefix. This enables deployment at both
// faves.example.com (basePath="") and example.com/faves (basePath="/faves").
func BasePath(prefix string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if prefix == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Strip the prefix from the URL path.
if strings.HasPrefix(r.URL.Path, prefix) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.Path == "" {
r.URL.Path = "/"
}
// Also update RawPath if present.
if r.URL.RawPath != "" {
r.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, prefix)
if r.URL.RawPath == "" {
r.URL.RawPath = "/"
}
}
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"context"
"net/http"
"kode.naiv.no/olemd/favoritter/internal/model"
)
type contextKey string
const (
userKey contextKey = "user"
csrfTokenKey contextKey = "csrf_token"
realIPKey contextKey = "real_ip"
)
// UserFromContext returns the authenticated user from the request context, or nil.
func UserFromContext(ctx context.Context) *model.User {
u, _ := ctx.Value(userKey).(*model.User)
return u
}
// CSRFTokenFromContext returns the CSRF token from the request context.
func CSRFTokenFromContext(ctx context.Context) string {
s, _ := ctx.Value(csrfTokenKey).(string)
return s
}
// RealIPFromContext returns the real client IP from the request context.
func RealIPFromContext(ctx context.Context) string {
s, _ := ctx.Value(realIPKey).(string)
return s
}
// RequireLogin redirects to the login page if no user is authenticated.
func RequireLogin(basePath string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if UserFromContext(r.Context()) == nil {
http.Redirect(w, r, basePath+"/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAdmin returns 403 if the user is not an admin.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || !user.IsAdmin() {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"kode.naiv.no/olemd/favoritter/internal/config"
)
const (
csrfCookieName = "csrf_token"
csrfFormField = "csrf_token"
csrfHeaderName = "X-CSRF-Token"
)
// CSRFProtection implements double-submit cookie pattern for CSRF prevention.
// A token is stored in a cookie and must also be submitted in a form field
// or header on state-changing requests (POST, PUT, DELETE, PATCH).
func CSRFProtection(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read or generate the CSRF token.
token := ""
if cookie, err := r.Cookie(csrfCookieName); err == nil {
token = cookie.Value
}
if token == "" {
token = generateCSRFToken()
secure := IsSecureRequest(r, cfg)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false, // JS needs to read it for HTMX hx-headers
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// Attach token to context for templates.
ctx := context.WithValue(r.Context(), csrfTokenKey, token)
r = r.WithContext(ctx)
// Validate on state-changing methods.
if isStateChangingMethod(r.Method) {
// Skip CSRF check for API routes that use Bearer auth (future).
if !strings.HasPrefix(r.URL.Path, "/api/") {
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted != token {
http.Error(w, "CSRF token mismatch", http.StatusForbidden)
return
}
}
}
next.ServeHTTP(w, r)
})
}
}
func isStateChangingMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch:
return true
}
return false
}
func generateCSRFToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// IsSecureRequest determines if the original client request used HTTPS,
// checking X-Forwarded-Proto from trusted proxies.
func IsSecureRequest(r *http.Request, cfg *config.Config) bool {
if cfg.ExternalURL != "" {
return strings.HasPrefix(cfg.ExternalURL, "https://")
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
return proto == "https"
}
return r.TLS != nil
}

View file

@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"log/slog"
"net/http"
"time"
)
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// RequestLogger logs each HTTP request with method, path, status, and duration.
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
slog.Debug("request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration", time.Since(start),
"ip", RealIPFromContext(r.Context()),
)
})
}

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package middleware provides HTTP middleware for auth, security, and request processing.
package middleware
import "net/http"
// Chain wraps a handler with a stack of middleware, applied in order
// (the last middleware listed is the outermost wrapper).
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for _, m := range middlewares {
h = m(h)
}
return h
}

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"net/http"
"sync"
"time"
)
// RateLimiter implements a simple per-IP token bucket rate limiter for
// protecting auth endpoints from brute-force attacks.
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*bucket
rate int
window time.Duration
}
type bucket struct {
tokens int
lastReset time.Time
}
// NewRateLimiter creates a rate limiter that allows `rate` requests per minute per IP.
func NewRateLimiter(rate int) *RateLimiter {
return &RateLimiter{
visitors: make(map[string]*bucket),
rate: rate,
window: time.Minute,
}
}
// Limit wraps a handler with rate limiting based on the real client IP.
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := RealIPFromContext(r.Context())
if ip == "" {
ip = r.RemoteAddr
}
if !rl.allow(ip) {
http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
b, ok := rl.visitors[ip]
if !ok {
rl.visitors[ip] = &bucket{tokens: rl.rate - 1, lastReset: now}
return true
}
// Reset tokens if the window has passed.
if now.Sub(b.lastReset) >= rl.window {
b.tokens = rl.rate - 1
b.lastReset = now
return true
}
if b.tokens <= 0 {
return false
}
b.tokens--
return true
}
// Cleanup removes stale entries. Call periodically from a goroutine.
func (rl *RateLimiter) Cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for ip, b := range rl.visitors {
if now.Sub(b.lastReset) >= 2*rl.window {
delete(rl.visitors, ip)
}
}
}

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"context"
"net"
"net/http"
"strings"
)
// RealIP extracts the real client IP from X-Forwarded-For, but only if the
// direct connection comes from a trusted proxy. This is essential when Caddy
// runs on a different machine (e.g. connected via WireGuard/Tailscale).
func RealIP(trustedProxies []*net.IPNet) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := extractRealIP(r, trustedProxies)
ctx := context.WithValue(r.Context(), realIPKey, ip)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func extractRealIP(r *http.Request, trusted []*net.IPNet) string {
// Get the direct connection IP.
directIP, _, _ := net.SplitHostPort(r.RemoteAddr)
if directIP == "" {
directIP = r.RemoteAddr
}
// Only trust X-Forwarded-For if the direct connection is from a trusted proxy.
if !isTrusted(directIP, trusted) {
return directIP
}
// Parse X-Forwarded-For: client, proxy1, proxy2
// The rightmost non-trusted IP is the real client.
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return directIP
}
ips := strings.Split(xff, ",")
// Walk from right to left, finding the first non-trusted IP.
for i := len(ips) - 1; i >= 0; i-- {
ip := strings.TrimSpace(ips[i])
if !isTrusted(ip, trusted) {
return ip
}
}
// All IPs in the chain are trusted; use the leftmost.
return strings.TrimSpace(ips[0])
}
func isTrusted(ipStr string, nets []*net.IPNet) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}

View file

@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
)
// Recovery catches panics in HTTP handlers and returns a 500 error
// instead of crashing the server.
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic recovered",
"error", err,
"path", r.URL.Path,
"stack", string(debug.Stack()),
)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import "net/http"
// SecurityHeaders adds security-related HTTP headers to every response.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP allows inline styles from Pico CSS and scripts from self only.
// The 'unsafe-inline' for style-src is needed for Pico CSS.
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}