Files
webhooker/internal/server/routes.go
clawbot 33e4fa4faa
All checks were successful
check / check (push) Successful in 2m50s
Report handler panics through the logger and answer 500 (closes #187)
2026-08-18 08:33:12 +02:00

225 lines
6.9 KiB
Go

package server
import (
"net/http"
"time"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/webhooker/static"
)
// maxFormBodySize is the maximum allowed request body size (in
// bytes) for form POST endpoints. 1 MB is generous for any form
// submission while preventing abuse from oversized payloads.
//
// Every route group below installs MaxBodySize(maxFormBodySize) as
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
// orderings are deliberate.
//
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs
// under net/http's 10 MB default instead of this one.
//
// Ahead of RequireAuth because an oversize body should be refused
// before the request buys a cookie decrypt, a session load and the
// database read behind it. Rejecting first is the cheaper failure,
// and it is the ordering that keeps an unauthenticated flood from
// choosing how much session work the process does.
//
// What that ordering costs: the 413 branch is reachable
// unauthenticated, at a URL of the client's choosing and of the
// client's chosen length. So is the CSRF rejection, which sits in
// front of RequireAuth for the same reason. Both log that path, so
// both cap it — see the log calls in Middleware.MaxBodySize and
// Middleware.CSRF, which spend the same per-field budget as the
// access log.
const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB
// requestTimeout is the maximum time allowed for a single HTTP
// request.
const requestTimeout = 60 * time.Second
// SetupRoutes configures all HTTP routes and middleware on the
// server's router.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
s.setupGlobalMiddleware()
s.setupRoutes()
}
func (s *Server) setupGlobalMiddleware() {
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging())
// Metrics middleware (only if credentials are configured)
if s.params.Config.MetricsUsername != "" {
s.router.Use(s.mw.Metrics())
}
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(requestTimeout))
// Panic recovery, deliberately here rather than first. It has to
// run inside every middleware that observes the response, so the
// 500 it writes is the status the access log records and the
// metrics count, and outside the sentryhttp handler below, whose
// Repanic option needs something further out to catch what it
// re-raises. chi's own middleware.Recoverer held the first slot
// until it was measured: on a current Go release it crashes
// inside its stack pretty-printer instead of recovering, so the
// connection dropped and the original panic was never reported.
// See https://git.eeqj.de/sneak/webhooker/issues/187.
s.router.Use(s.mw.Recoverer())
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
// true so panics still bubble up to the Recoverer middleware
// registered immediately above.
if s.sentryEnabled {
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
s.router.Use(sentryHandler.Handle)
}
}
func (s *Server) setupRoutes() {
s.router.Get("/", s.h.HandleIndex())
s.router.Mount(
"/s",
http.StripPrefix("/s", http.FileServer(http.FS(static.Static))),
)
s.router.Route("/api/v1", func(_ chi.Router) {
// API routes will be added here.
})
s.router.Get(
"/.well-known/healthcheck",
s.h.HandleHealthCheck(),
)
// set up authenticated /metrics route:
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get(
"/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
),
)
})
}
s.setupPageRoutes()
s.setupUserRoutes()
s.setupSourceRoutes()
s.setupWebhookRoutes()
}
func (s *Server) setupPageRoutes() {
s.router.Route("/pages", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache())
// The login POST carries no pre-emptive rate limiter. Behind
// the reverse proxy production requires, with TRUSTED_PROXIES
// unset, every client shares one bucket, so a limiter spent
// on arrival lets any stranger deny the operator the only
// administrative path. The handler verifies credentials first
// and charges only failures; see Handlers.authenticateUser.
r.Get("/login", s.h.HandleLoginPage())
r.Post("/login", s.h.HandleLoginSubmit())
r.Post("/logout", s.h.HandleLogout())
})
}
func (s *Server) setupUserRoutes() {
s.router.Route("/user/{username}", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile())
r.With(s.mw.PasswordChangeRateLimit()).Post(
"/password", s.h.HandlePasswordChange(),
)
})
}
func (s *Server) setupSourceRoutes() {
s.router.Route("/sources", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList())
r.Get("/new", s.h.HandleSourceCreate())
r.Post("/new", s.h.HandleSourceCreateSubmit())
})
s.router.Route("/source/{sourceID}", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail())
r.Get("/edit", s.h.HandleSourceEdit())
r.Post("/edit", s.h.HandleSourceEditSubmit())
r.Post("/delete", s.h.HandleSourceDelete())
r.Get("/logs", s.h.HandleSourceLogs())
// The log page renders each body only up to its cap, so
// this is the only route that serves a whole one. It
// belongs to this group for its RequireAuth and
// NoCache; see HandleEventBodyDownload for the headers
// that keep the bytes it returns inert.
r.Get(
"/logs/{eventID}/body",
s.h.HandleEventBodyDownload(),
)
r.Post(
"/entrypoints",
s.h.HandleEntrypointCreate(),
)
r.Post(
"/entrypoints/{entrypointID}/delete",
s.h.HandleEntrypointDelete(),
)
r.Post(
"/entrypoints/{entrypointID}/toggle",
s.h.HandleEntrypointToggle(),
)
r.Post("/targets", s.h.HandleTargetCreate())
r.Post(
"/targets/{targetID}/delete",
s.h.HandleTargetDelete(),
)
r.Post(
"/targets/{targetID}/toggle",
s.h.HandleTargetToggle(),
)
})
}
func (s *Server) setupWebhookRoutes() {
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
"/webhook/{uuid}",
s.h.HandleWebhook(),
)
}