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
205
internal/config/config.go
Normal file
205
internal/config/config.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue