Files
veola/internal/handlers/handlers.go
prosolis feea126c5e Add per-user item ownership and isolation
Items are now private to their owner. Add items.user_id (migrated in
place, existing items backfilled to the first admin) and scope every
item, results, and dashboard view to the signed-in user via owner-scoped
queries plus an ownedItem 404 guard. Admins get strict isolation too;
elevation stays limited to settings and user management.

Alert routing follows ownership: deal emails go only to the item owner
and the weekly digest is built per-recipient from their own items. The
scheduler still polls every active item; the Apify/eBay budget stays a
shared pool visible to all.

Add TestItemsArePrivatePerUser and seed owners in db tests.
2026-06-20 13:39:19 -07:00

247 lines
8.4 KiB
Go

// 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"
"os"
"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.
// CaptureDirectIP must run before RealIP: forward-auth trusts identity
// headers only from the real connecting peer, which RealIP would otherwise
// overwrite with the proxy-supplied X-Forwarded-For.
r.Use(auth.CaptureDirectIP)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
// noListFS denies directory requests, so http.FileServer can't render
// an index listing of static/ if an index.html is ever absent.
fs := http.FileServer(noListFS{http.Dir("./static")})
r.Handle("/static/*", http.StripPrefix("/static/", fs))
// Health check for reverse-proxy/uptime probes. No session, no setup
// gate, no auth — just a 200 to confirm the process is serving.
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// 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)
// ForwardAuth runs after LoadUser so a trusted proxy's identity headers
// supersede any stale local session. No-op when forward-auth is off or
// the peer is untrusted.
r.Use(a.Auth.ForwardAuth)
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).Post("/settings/test-resend", a.PostTestResend)
r.With(a.Auth.CSRFProtect).Post("/settings/email", a.PostEmailPrefs)
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 <script> (the price chart) was externalized to static/js. The sole
// remote origins are Google Fonts (stylesheet + font files), per the agreed
// deviation to keep fonts on a CDN.
//
// img-src additionally allows any https: origin: listing thumbnails come from
// an open-ended set of marketplace CDNs (eBay, Amazon, Mercari, Yahoo, ...)
// that cannot be enumerated. Images execute no code, so this is a low-risk
// relaxation while script/style/connect stay locked to 'self'.
//
// HSTS is intentionally omitted: it belongs at the TLS-terminating proxy.
func securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.gstatic.com; " +
"style-src 'self' https://fonts.googleapis.com; " +
"script-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
next.ServeHTTP(w, r)
})
}
func isStaticPath(p string) bool {
return len(p) >= 8 && p[:8] == "/static/"
}
func (a *App) page(r *http.Request, title, active string) templates.Page {
return templates.Page{
Title: title,
Active: active,
CSRFToken: a.Auth.CSRFToken(r.Context()),
CurrentUser: auth.CurrentUserFromRequest(r),
}
}
// userID returns the signed-in user's id. Handlers in this group all run under
// RequireAuth, so a user is always present; 0 is only returned defensively.
func (a *App) userID(r *http.Request) int64 {
if u := auth.CurrentUserFromRequest(r); u != nil {
return u.ID
}
return 0
}
// noListFS wraps an http.FileSystem and refuses to open directories, which
// stops http.FileServer from emitting an auto-generated directory listing.
type noListFS struct{ fs http.FileSystem }
func (n noListFS) Open(name string) (http.File, error) {
f, err := n.fs.Open(name)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if info.IsDir() {
f.Close()
return nil, os.ErrNotExist
}
return f, nil
}
// serverError logs the underlying error and returns a generic 500 to the
// client, so internal details (DB errors, file paths) never reach the browser.
func (a *App) serverError(w http.ResponseWriter, r *http.Request, err error) {
slog.Error("handler error", "path", r.URL.Path, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := c.Render(r.Context(), w); err != nil {
slog.Error("render failed", "err", err)
}
}
func parseInt64(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
func intParam(r *http.Request, key string) int64 {
return parseInt64(chi.URLParam(r, key))
}
func ctxBg() context.Context { return context.Background() }