Auction handling: - Capture itemEndDate from eBay Browse API and ending_date from ZenMarket (Yahoo JP); plumb through results.ends_at column. Permissive ZenMarket parser (multiple layouts, JST when offset missing). - Per-row "Ends" countdown column + "Ending soon" banner on results pages, live-ticked by flair.js with urgent/critical tinting under 1h/5m. - Backfill ends_at for known auctions when their URL reappears in a poll (dedup hit no longer drops the new end time). - Hide ended auctions from result listings by default via ResultsQuery.ExcludeEnded; rows stay in the DB. Visual flair: - Glassy backdrop-blur v-cards with gradient-mask borders and hover-lift. - htmx swap fade-in via transient .v-just-swapped class. - Count-up animation on dashboard stats. All animations gated behind prefers-reduced-motion. eBay condition + region filters (auctions-style scoping): - items.condition and items.region columns; threaded through item form, CreateItem/UpdateItem, scheduler eBay plan input, and previewKey so cache invalidates when these change. - ebay.SearchParams gains conditionIds and itemLocationCountry filters. Run Now reload + countdown engine: - Run Now now sets HX-Refresh: true (non-htmx fallback: 303 redirect) so the entire results view — best price, chart, badge, last polled — reflects the new poll, instead of swapping just one partial. Pre-launch hardening (P1 set): - auth.EqualizeLoginTiming on no-such-user branch. - (*App).serverError centralizes 500s; replaces err.Error() leaks across results/settings/items/users/dashboard handlers. - main.go server: ReadTimeout 30s / WriteTimeout 60s / IdleTimeout 120s alongside the existing ReadHeaderTimeout. - noListFS wrapper blocks static directory listings. - Credential fields in settings no longer render value=; blank submission preserves the saved value, with per-field "Saved in settings / Set in config.toml / Not set" status indicator. Misc: - -debug flag wires slog to LevelDebug; raw ZenMarket items logged for format diagnosis. - /healthz public endpoint for reverse-proxy probes. - deploy/veola.service systemd unit template (hardening flags, single ReadWritePaths=/var/lib/veola). - handlers_test.go covers /healthz, setup-gate redirect, auth gate, and /login render with httptest + in-memory sqlite. - best_price_currency on items; templates pick the right symbol per row. - .gitignore now excludes *.log / veola-debug.log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
7.5 KiB
Go
228 lines
7.5 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.
|
|
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)
|
|
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 <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),
|
|
}
|
|
}
|
|
|
|
// 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() }
|