check / check (push) Waiting to run
Each database target on the webhook page has a Download button that streams its archive as gzipped JSON, archive-WEBHOOKNAME-TARGETNAME-TIME.json.gz, with names made safe by delivery.ArchiveFileName's function. The export reads one consistent snapshot through one cursor in a read-only transaction, so archive writes carry on, and holds the rename lock only while it reads the stored names and opens the file. It extends its write deadline as it writes, so a large archive downloads for as long as the client reads; a failure after the response has started aborts the connection so the browser marks the download failed. The request limit is now the service's own middleware, which no longer writes a 504 over a response already started. Model: opus-5-5
342 lines
12 KiB
Go
342 lines
12 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"
|
|
"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.
|
|
//
|
|
// 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
|
|
// 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()
|
|
|
|
// 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(s.mw.SecurityHeaders())
|
|
s.router.Use(s.mw.Logging())
|
|
|
|
// Metrics recording middleware, registered only when the
|
|
// endpoint that exposes what it records is served. The
|
|
// condition is the same MetricsAuthEnabled the /metrics mount
|
|
// in setupRoutes reads.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
s.router.Use(s.mw.Metrics())
|
|
}
|
|
|
|
s.router.Use(s.mw.CORS())
|
|
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, 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.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 (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))
|
|
|
|
if s.sentryEnabled.Load() {
|
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
|
Repanic: true,
|
|
})
|
|
r.Use(sentryHandler.Handle)
|
|
}
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
s.router.Get("/", s.h.HandleIndex())
|
|
|
|
// Static assets answer GET and HEAD only. chi's default 405
|
|
// carries no Allow header, so this group supplies its own.
|
|
staticFiles := http.StripPrefix(
|
|
"/s", http.FileServer(http.FS(static.Static)),
|
|
)
|
|
|
|
s.router.Route("/s", func(r chi.Router) {
|
|
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Allow", "GET, HEAD")
|
|
http.Error(
|
|
w,
|
|
"Method Not Allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
})
|
|
r.Method(http.MethodGet, "/*", staticFiles)
|
|
r.Method(http.MethodHead, "/*", staticFiles)
|
|
})
|
|
|
|
s.router.Route("/api/v1", func(_ chi.Router) {
|
|
// API routes will be added here.
|
|
})
|
|
|
|
s.router.Get(
|
|
"/.well-known/healthcheck",
|
|
s.h.HandleHealthCheck(),
|
|
)
|
|
|
|
// Authenticated /metrics route. The condition is
|
|
// Config.MetricsAuthEnabled and never the username alone: a
|
|
// username with an empty password would otherwise mount the
|
|
// endpoint behind a credential map that accepts an empty
|
|
// password. Config rejects that combination at startup, and
|
|
// this reads the same value the startup log reports, so the
|
|
// two cannot disagree about whether the route exists.
|
|
if s.params.Config.MetricsAuthEnabled() {
|
|
s.router.Group(func(r chi.Router) {
|
|
r.Use(s.mw.MetricsAuth())
|
|
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(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, 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())
|
|
|
|
r.Post("/logout", s.h.HandleLogout())
|
|
})
|
|
}
|
|
|
|
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(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
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(),
|
|
)
|
|
})
|
|
}
|
|
|
|
// 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(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())
|
|
r.Get("/new", s.h.HandleSourceCreate())
|
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
|
})
|
|
|
|
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(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("/events", 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/{eventID}/body",
|
|
s.h.HandleEventBodyDownload(),
|
|
)
|
|
// Replay is the one page action that queues outbound work:
|
|
// it creates a delivery from a stored event and hands it to
|
|
// the delivery engine. The rate limit is what bounds a
|
|
// held-down button or a scripted loop; the handler
|
|
// separately refuses a replay while an earlier one for the
|
|
// same event and target is still in flight. POST only, so
|
|
// the action cannot be taken by a link, a prefetch or an
|
|
// image tag.
|
|
r.With(s.mw.ReplayRateLimit()).Post(
|
|
"/deliveries/{deliveryID}/replay",
|
|
s.h.HandleDeliveryReplay(),
|
|
)
|
|
// Resubmit is the other page action that queues outbound
|
|
// work: it copies a stored event into a new one and fans
|
|
// that out to every currently active target. It is
|
|
// deliberately repeatable, so the rate limit is the only
|
|
// bound on a held-down button; it gets its own bucket so
|
|
// that spending it does not also disable replay. POST
|
|
// only, so the action cannot be taken by a link, a
|
|
// prefetch or an image tag.
|
|
r.With(s.mw.ResubmitRateLimit()).Post(
|
|
"/events/{eventID}/resubmit",
|
|
s.h.HandleEventResubmit(),
|
|
)
|
|
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())
|
|
// 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
|
|
// its RequireAuth and NoCache, which are what keep that
|
|
// exception from reaching an unauthenticated request or a
|
|
// shared cache.
|
|
r.Get(
|
|
"/targets/{targetID}/edit",
|
|
s.h.HandleTargetEdit(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/edit",
|
|
s.h.HandleTargetEditSubmit(),
|
|
)
|
|
r.Get(
|
|
"/targets/{targetID}/download",
|
|
s.h.HandleTargetDownload(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/delete",
|
|
s.h.HandleTargetDelete(),
|
|
)
|
|
r.Post(
|
|
"/targets/{targetID}/toggle",
|
|
s.h.HandleTargetToggle(),
|
|
)
|
|
})
|
|
}
|
|
|
|
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(
|
|
"/h/{uuid}",
|
|
s.h.HandleWebhook(),
|
|
)
|
|
}
|