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
182
cmd/favoritter/main.go
Normal file
182
cmd/favoritter/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue