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>
205 lines
5.1 KiB
Go
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
|
|
}
|