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>
197 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|