// Package handlers wires HTTP routes for Veola. Each file in the package owns // a related cluster of routes; this file holds the shared App container and // helper functions used across all handlers. package handlers import ( "context" "log/slog" "net/http" "strconv" "time" "github.com/a-h/templ" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "veola/internal/apify" "veola/internal/auth" "veola/internal/config" "veola/internal/db" "veola/internal/ntfy" "veola/internal/scheduler" "veola/templates" ) type App struct { Cfg *config.Config Store *db.Store Auth *auth.Manager Apify *apify.Client Ntfy *ntfy.Client Scheduler *scheduler.Scheduler Preview *PreviewCache } func New(cfg *config.Config, store *db.Store, am *auth.Manager, ap *apify.Client, nt *ntfy.Client, sc *scheduler.Scheduler) *App { return &App{ Cfg: cfg, Store: store, Auth: am, Apify: ap, Ntfy: nt, Scheduler: sc, Preview: NewPreviewCache(10 * time.Minute), } } // Routes returns the chi router with everything wired up. func (a *App) Routes() http.Handler { r := chi.NewRouter() // Applied to everything, including static assets and error responses. // RealIP rewrites RemoteAddr from X-Forwarded-For — safe only because // Veola is expected to sit behind a trusted proxy (Traefik) that sets // it; Traefik must be configured to strip client-supplied values. r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(securityHeaders) fs := http.FileServer(http.Dir("./static")) r.Handle("/static/*", http.StripPrefix("/static/", fs)) // All other routes pass through session loading + setup gate. r.Group(func(r chi.Router) { r.Use(a.Auth.Sessions.LoadAndSave) r.Use(a.Auth.LoadUser) r.Use(a.setupGate) // Public auth pages. r.Get("/login", a.GetLogin) r.With(a.Auth.CSRFProtect).Post("/login", a.PostLogin) r.Get("/setup", a.GetSetup) r.With(a.Auth.CSRFProtect).Post("/setup", a.PostSetup) // Authenticated section. r.Group(func(r chi.Router) { r.Use(a.Auth.RequireAuth) r.With(a.Auth.CSRFProtect).Post("/logout", a.PostLogout) r.Get("/", a.GetDashboard) r.Get("/dashboard/refresh", a.GetDashboardRefresh) r.Get("/items", a.GetItems) r.Get("/items/new", a.GetNewItem) r.With(a.Auth.CSRFProtect).Post("/items/preview", a.PostPreview) r.With(a.Auth.CSRFProtect).Post("/items", a.PostCreateItem) r.Get("/items/{id}/edit", a.GetEditItem) r.With(a.Auth.CSRFProtect).Post("/items/{id}", a.PostUpdateItem) r.With(a.Auth.CSRFProtect).Post("/items/{id}/toggle", a.PostToggleItem) r.With(a.Auth.CSRFProtect).Post("/items/{id}/delete", a.PostDeleteItem) r.With(a.Auth.CSRFProtect).Post("/items/{id}/run", a.PostRunItem) r.Get("/items/{id}/error", a.GetItemError) r.Get("/items/{id}/results", a.GetItemResults) r.Get("/results", a.GetGlobalResults) r.Get("/settings", a.GetSettings) r.With(a.Auth.CSRFProtect).Post("/settings", a.PostSettings) r.With(a.Auth.CSRFProtect).Post("/settings/password", a.PostPasswordChange) r.With(a.Auth.CSRFProtect).Post("/settings/test-ntfy", a.PostTestNtfy) r.With(a.Auth.CSRFProtect).Post("/settings/test-apify", a.PostTestApify) r.With(a.Auth.CSRFProtect).Post("/settings/test-ebay", a.PostTestEbay) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users", a.PostCreateUser) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/delete", a.PostDeleteUser) r.With(a.Auth.CSRFProtect, a.Auth.RequireAdmin).Post("/users/{id}/reset-password", a.PostResetPassword) }) }) return r } // setupGate redirects every request to /setup if no users exist. func (a *App) setupGate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/setup" || isStaticPath(r.URL.Path) { next.ServeHTTP(w, r) return } n, err := a.Store.UserCount(r.Context()) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if n == 0 { http.Redirect(w, r, "/setup", http.StatusSeeOther) return } // Once at least one user exists, /setup is a 404. next.ServeHTTP(w, r) }) } // securityHeaders sets defensive response headers on every request. // // Scripts and styles are restricted to 'self': Tailwind is vendored // (static/css/tailwind.css), htmx and Chart.js are vendored, and the only // inline