favoritter/cmd/favoritter/main.go
Ole-Morten Duesund a456d0096a feat: add CLI commands for user management
Adds subcommands to the binary for admin tasks without needing
the web UI: list users, set passwords, promote/demote roles,
and lock/unlock accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:37:39 +02:00

197 lines
4.8 KiB
Go

// 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/handler/api"
"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 flags and subcommands before starting the server.
if len(os.Args) > 1 {
switch os.Args[1] {
case "-healthcheck":
runHealthCheck()
return
case "-version":
fmt.Printf("favoritter %s (built %s)\n", version, buildDate)
return
case "user":
runUserCommand(os.Args[2:])
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,
})
// Register JSON API routes on the same mux.
apiHandler := api.New(api.Deps{
Config: cfg,
Users: users,
Sessions: sessions,
Faves: faves,
Tags: tags,
})
// Build the middleware chain.
mux := h.Routes()
apiHandler.Routes(mux)
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),
middleware.MustResetPasswordGuard(cfg.BasePath),
)
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)
}
}