Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
+107
-40
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
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"
|
||||
chimw "github.com/go-chi/chi/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
// 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.
|
||||
// The five admin page route groups below (/pages, /user/{username},
|
||||
// /settings, /hooks and /hook/{sourceID}) install
|
||||
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
||||
// reporting, 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
|
||||
@@ -46,12 +48,20 @@ const requestTimeout = 60 * time.Second
|
||||
// server's router.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
// An unknown path gets the error page. Registered before the
|
||||
// global middleware, because chi wraps a not-found handler in the
|
||||
// middleware already on its router, which would then run twice.
|
||||
// The route groups below wrap it in their own middleware the same
|
||||
// way; running theirs twice is harmless.
|
||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
||||
|
||||
s.setupGlobalMiddleware()
|
||||
s.setupRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupGlobalMiddleware() {
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(chimw.RequestID)
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
@@ -64,28 +74,43 @@ func (s *Server) setupGlobalMiddleware() {
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(requestTimeout))
|
||||
s.router.Use(s.mw.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
|
||||
// metrics count, and outside the sentryhttp handler, 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())
|
||||
s.recoverPanics(s.router, nil)
|
||||
}
|
||||
|
||||
// recoverPanics installs on r the recoverer, answering a panic with
|
||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
||||
// reporting and transaction naming (if SENTRY_DSN is set). Repanic is
|
||||
// true so panics still bubble up to the recoverer.
|
||||
//
|
||||
// Each admin page route group installs its own, with the error page,
|
||||
// as its first middleware. A panic there is logged, reported and
|
||||
// answered inside the group and never reaches the global recoverer,
|
||||
// which keeps the plain 500 for every other route.
|
||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
||||
r.Use(s.mw.Recoverer(page))
|
||||
|
||||
// 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.Load() {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
r.Use(sentryHandler.Handle)
|
||||
// Installed with every sentryhttp handler rather than once
|
||||
// globally: in an admin page route group, the group's own
|
||||
// handler finishes the transaction as the group returns, so
|
||||
// a rename further out would come too late.
|
||||
r.Use(nameSentryTransaction)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,35 +155,35 @@ func (s *Server) setupRoutes() {
|
||||
if s.params.Config.MetricsAuthEnabled() {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get(
|
||||
"/metrics",
|
||||
http.HandlerFunc(
|
||||
promhttp.Handler().ServeHTTP,
|
||||
),
|
||||
)
|
||||
r.Get("/metrics", s.h.HandleMetrics())
|
||||
})
|
||||
}
|
||||
|
||||
s.setupPageRoutes()
|
||||
s.setupUserRoutes()
|
||||
s.setupSettingsRoutes()
|
||||
s.setupSourceRoutes()
|
||||
s.setupWebhookRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupPageRoutes() {
|
||||
s.router.Route("/pages", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// 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.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
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.
|
||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
||||
// does not cover it, 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())
|
||||
|
||||
@@ -168,10 +193,13 @@ func (s *Server) setupPageRoutes() {
|
||||
|
||||
func (s *Server) setupUserRoutes() {
|
||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// 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.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
@@ -181,12 +209,33 @@ func (s *Server) setupUserRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/sources", func(r chi.Router) {
|
||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||
// configuration comes from the environment and nothing here changes
|
||||
// it.
|
||||
func (s *Server) setupSettingsRoutes() {
|
||||
s.router.Route("/settings", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// 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.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSettings())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/hooks", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// 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(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceList())
|
||||
@@ -194,25 +243,29 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||
})
|
||||
|
||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||
s.recoverPanics(
|
||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||
)
|
||||
// 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.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||
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("/events", s.h.HandleSourceLogs())
|
||||
r.Get("/events/{eventID}", s.h.HandleEventDetail())
|
||||
// The pages show a body as escaped text and leave a
|
||||
// binary one out, so this is the only route that serves
|
||||
// the stored bytes. 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",
|
||||
"/events/{eventID}/body",
|
||||
s.h.HandleEventBodyDownload(),
|
||||
)
|
||||
// Replay is the one page action that queues outbound work:
|
||||
@@ -243,6 +296,10 @@ func (s *Server) setupSourceRoutes() {
|
||||
"/entrypoints",
|
||||
s.h.HandleEntrypointCreate(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/edit",
|
||||
s.h.HandleEntrypointEdit(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/delete",
|
||||
s.h.HandleEntrypointDelete(),
|
||||
@@ -254,7 +311,7 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/targets", s.h.HandleTargetCreate())
|
||||
// The edit form is the one page that renders a target's
|
||||
// destination URL and header values in full; see
|
||||
// delivery.TargetConfigForm. It belongs to this group for
|
||||
// Handlers.HandleTargetEdit. It belongs to this group for
|
||||
// its RequireAuth and NoCache, which are what keep that
|
||||
// exception from reaching an unauthenticated request or a
|
||||
// shared cache.
|
||||
@@ -266,6 +323,10 @@ func (s *Server) setupSourceRoutes() {
|
||||
"/targets/{targetID}/edit",
|
||||
s.h.HandleTargetEditSubmit(),
|
||||
)
|
||||
r.Get(
|
||||
"/targets/{targetID}/download",
|
||||
s.h.HandleTargetDownload(),
|
||||
)
|
||||
r.Post(
|
||||
"/targets/{targetID}/delete",
|
||||
s.h.HandleTargetDelete(),
|
||||
@@ -278,8 +339,14 @@ func (s *Server) setupSourceRoutes() {
|
||||
}
|
||||
|
||||
func (s *Server) setupWebhookRoutes() {
|
||||
// No MaxBodySize here, unlike the page groups. The receiver's 1 MB
|
||||
// body cap is in Handlers.readWebhookBody, because the handler
|
||||
// owns the response a sender gets for an oversized body and
|
||||
// MaxBodySize would change it. That cap is the only bound on this
|
||||
// unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused
|
||||
// pins it.
|
||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||
"/webhook/{uuid}",
|
||||
middleware.ReceiverRoute,
|
||||
s.h.HandleWebhook(),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user