favoritter/internal/config/config.go
Ole-Morten Duesund fc1f7259c5 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>
2026-03-29 15:55:22 +02:00

205 lines
5.1 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-or-later
// Package config loads application configuration from environment variables.
package config
import (
"log/slog"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration.
type Config struct {
// Database
DBPath string
// Server
Listen string
BasePath string
ExternalURL string
TrustedProxies []*net.IPNet
// Uploads
UploadDir string
MaxUploadSize int64
// Security
SessionLifetime time.Duration
Argon2Memory uint32
Argon2Time uint32
Argon2Parallelism uint8
RateLimit int
// Admin
AdminUsername string
AdminPassword string
// Site
SiteName string
DevMode bool
}
// Load reads configuration from environment variables with sensible defaults.
func Load() *Config {
cfg := &Config{
DBPath: envOr("FAVORITTER_DB_PATH", "./data/favoritter.db"),
Listen: envOr("FAVORITTER_LISTEN", ":8080"),
BasePath: envOr("FAVORITTER_BASE_PATH", "/"),
ExternalURL: os.Getenv("FAVORITTER_EXTERNAL_URL"),
UploadDir: envOr("FAVORITTER_UPLOAD_DIR", "./data/uploads"),
MaxUploadSize: envInt64("FAVORITTER_MAX_UPLOAD_SIZE", 10<<20), // 10MB
SessionLifetime: envDuration("FAVORITTER_SESSION_LIFETIME", 720*time.Hour),
Argon2Memory: uint32(envInt("FAVORITTER_ARGON2_MEMORY", 65536)),
Argon2Time: uint32(envInt("FAVORITTER_ARGON2_TIME", 3)),
Argon2Parallelism: uint8(envInt("FAVORITTER_ARGON2_PARALLELISM", 2)),
RateLimit: envInt("FAVORITTER_RATE_LIMIT", 60),
AdminUsername: os.Getenv("FAVORITTER_ADMIN_USERNAME"),
AdminPassword: os.Getenv("FAVORITTER_ADMIN_PASSWORD"),
SiteName: envOr("FAVORITTER_SITE_NAME", "Favoritter"),
DevMode: envBool("FAVORITTER_DEV_MODE", false),
}
// Normalize base path: ensure it starts with / and doesn't end with /.
cfg.BasePath = normalizePath(cfg.BasePath)
// Normalize external URL: strip trailing slash.
cfg.ExternalURL = strings.TrimRight(cfg.ExternalURL, "/")
// Parse trusted proxies.
cfg.TrustedProxies = parseCIDRs(envOr("FAVORITTER_TRUSTED_PROXIES", "127.0.0.1"))
return cfg
}
// BaseURL returns the external base URL for generating absolute URLs.
// If EXTERNAL_URL is set, it's used directly. Otherwise, falls back to
// constructing from the request (caller should pass the Host header).
func (c *Config) BaseURL(requestHost string) string {
if c.ExternalURL != "" {
return c.ExternalURL
}
// Fallback: construct from request host.
// Assume HTTPS unless we know otherwise.
return "https://" + requestHost + c.BasePath
}
// IsExternalURLConfigured returns true if an explicit external URL was set.
func (c *Config) IsExternalURLConfigured() bool {
return c.ExternalURL != ""
}
// ExternalHostname returns the hostname from the external URL, or empty string.
func (c *Config) ExternalHostname() string {
if c.ExternalURL == "" {
return ""
}
u, err := url.Parse(c.ExternalURL)
if err != nil {
return ""
}
return u.Hostname()
}
func normalizePath(p string) string {
if p == "" || p == "/" {
return ""
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return strings.TrimRight(p, "/")
}
func parseCIDRs(s string) []*net.IPNet {
var nets []*net.IPNet
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// If no CIDR notation, add /32 for IPv4 or /128 for IPv6.
if !strings.Contains(part, "/") {
ip := net.ParseIP(part)
if ip == nil {
slog.Warn("invalid trusted proxy IP, skipping", "ip", part)
continue
}
if ip.To4() != nil {
part += "/32"
} else {
part += "/128"
}
}
_, ipNet, err := net.ParseCIDR(part)
if err != nil {
slog.Warn("invalid trusted proxy CIDR, skipping", "cidr", part, "error", err)
continue
}
nets = append(nets, ipNet)
}
return nets
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
slog.Warn("invalid integer env var, using default", "key", key, "value", v, "default", fallback)
return fallback
}
return n
}
func envInt64(key string, fallback int64) int64 {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
slog.Warn("invalid int64 env var, using default", "key", key, "value", v, "default", fallback)
return fallback
}
return n
}
func envDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
slog.Warn("invalid duration env var, using default", "key", key, "value", v, "default", fallback)
return fallback
}
return d
}
func envBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
slog.Warn("invalid bool env var, using default", "key", key, "value", v, "default", fallback)
return fallback
}
return b
}