Author SHA1 Message Date
clawbot 2b303fe569 Render admin page errors in the normal layout (closes #382)
check / check (push) Waiting to run
Every 400, 403, 404 and 500 on an admin page now answers with an
error page in the normal layout: one fixed line for the status and a
link back to the webhook list, or to sign-in when nobody is signed
in. The router's handler for unknown paths, the CSRF middleware's
refusal and a panic in an admin page route group use the same page;
each such group has its own recoverer and error reporting for that.
The page always sends Cache-Control: no-store. Status codes are
unchanged. The receiver, the healthcheck and /metrics keep their
plain answers. If the error page cannot render, or panics, the answer
is the same status in plain text.

Model: opus-5-5
2026-10-01 23:16:41 +00:00
clawbot 1cafaeb953 Reword the build-architecture history line in TODO.md (closes #412)
check / check (push) Waiting to run
The architecture is read at run time, so nothing passes it in at build time. The dated history entry for issue 31 in TODO.md now says a build-architecture global was removed, without naming it, so the word no longer appears in the tree.

Model: opus-5-5
2026-10-02 01:08:22 +02:00
38 changed files with 762 additions and 391 deletions
+6 -8
View File
@@ -2883,15 +2883,13 @@ Components are wired via Uber fx in this order:
7. `healthcheck.New` — Health check service
8. `session.New` — Cookie-based session manager (key from database)
9. `handlers.New` — HTTP handlers
10. `metrics.NewRegistry` — The registry `/metrics` serves
11. `metrics.New` — The delivery collectors, registered on that registry
12. `middleware.New` — HTTP middleware
13. `delivery.New` — Event-driven delivery engine
14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
15. `delivery.Engine` → `delivery.Notifier` — interface bridge
16. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
10. `middleware.New` — HTTP middleware
11. `delivery.New` — Event-driven delivery engine
12. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives
13. `delivery.Engine` → `delivery.Notifier` — interface bridge
14. `delivery.Engine` → `delivery.WebhookEvictor` — interface bridge so
deleting a webhook releases its archive writer
17. `server.New` — HTTP server and router
15. `server.New` — HTTP server and router
The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine,
*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which
+1 -1
View File
@@ -387,7 +387,7 @@ point of the branch.
- 2026-03-05 security headers middleware, session regeneration on
login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages
(#32); removed globals.Buildarch (#31)
(#32); removed the build-architecture global (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
delivery engine with bounded worker pool and circuit breaker,
parallel fan-out, per-webhook event databases, management UI (#16)
-5
View File
@@ -16,7 +16,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
@@ -178,10 +177,6 @@ func newApp() *fx.App {
healthcheck.New,
session.New,
handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.New,
// The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot
+5 -6
View File
@@ -148,7 +148,6 @@ type EngineParams struct {
DBManager *database.WebhookDBManager
Logger *logger.Logger
SSRFGuard *Guard
Metrics *metrics.Set
}
// Engine processes queued deliveries in the background
@@ -168,10 +167,10 @@ type Engine struct {
retryCh chan Task
workers int
// mtr is the delivery metric set. Production wires the one
// registered on the registry /metrics serves; a test can
// substitute a set registered on a registry it holds, so it can
// gather what its own deliveries recorded.
// mtr is the delivery metric set. Production wires the
// process-wide one; a test can substitute a set registered on
// a private registry so its assertions are not disturbed by
// deliveries other tests are making at the same time.
mtr *metrics.Set
// targets maps each target type to its implementation.
@@ -205,7 +204,7 @@ func New(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: defaultWorkers,
mtr: params.Metrics,
mtr: metrics.Default(),
}
e.initTargets(&http.Client{
+5 -5
View File
@@ -9,7 +9,6 @@ import (
"net/url"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
@@ -390,7 +389,7 @@ func NewTestEngine(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: workers,
mtr: metrics.New(prometheus.NewRegistry()),
mtr: metrics.Default(),
}
e.initTargets(client)
@@ -405,7 +404,7 @@ func NewTestEngineSmallRetry(
e := &Engine{
log: log,
retryCh: make(chan Task, 1),
mtr: metrics.New(prometheus.NewRegistry()),
mtr: metrics.Default(),
}
e.initTargets(nil)
@@ -428,7 +427,7 @@ func NewTestEngineWithDB(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: workers,
mtr: metrics.New(prometheus.NewRegistry()),
mtr: metrics.Default(),
}
e.initTargets(client)
@@ -436,7 +435,8 @@ func NewTestEngineWithDB(
}
// ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a registry it holds.
// assert on collectors registered on a private registry instead of
// the process-wide ones every other test is also moving.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
e.mtr = mtr
}
+3 -2
View File
@@ -35,8 +35,9 @@ const (
)
// mIsolate gives the setup's engine a metric set registered on a
// registry this test holds, so its exact assertions can gather from
// it.
// private registry. The process-wide collectors are moved by every
// other delivery test running in parallel, so exact assertions are
// only possible against a registry this test owns.
func mIsolate(
t *testing.T, s iSetup,
) *prometheus.Registry {
+5 -23
View File
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -166,11 +166,7 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to verify password", err)
return user, err
}
@@ -242,24 +238,14 @@ func (h *Handlers) createAuthenticatedSession(
) error {
oldSess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to get session", err)
return err
}
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to regenerate session", err)
return err
}
@@ -268,11 +254,7 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to save session", err)
return err
}
+7 -9
View File
@@ -105,9 +105,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -124,14 +122,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook,
) {
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -173,7 +171,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return nil, false
}
@@ -195,7 +193,7 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, "failed to count in-flight deliveries", err,
w, r, "failed to count in-flight deliveries", err,
)
return
@@ -212,7 +210,7 @@ func (h *Handlers) queueReplay(
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, "failed to load event for replay", err)
h.serverError(w, r, "failed to load event for replay", err)
return
}
@@ -222,7 +220,7 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, "failed to create replay delivery", err,
w, r, "failed to create replay delivery", err,
)
return
+53
View File
@@ -0,0 +1,53 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string,
) {
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil {
h.serverError(w, "failed to read event body", err)
h.serverError(w, r, "failed to read event body", err)
return
}
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the
// response is a clean 404.
if !found {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
+8 -8
View File
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(),
)
if err != nil {
h.serverError(w, "failed to load event to resubmit", err)
h.serverError(w, r, "failed to load event to resubmit", err)
return
}
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed.
if !found {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID)
if err != nil {
h.serverError(w, "failed to query targets", err)
h.serverError(w, r, "failed to query targets", err)
return
}
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
targets,
)
if err != nil {
h.serverError(w, "failed to store resubmitted event", err)
h.serverError(w, r, "failed to store resubmitted event", err)
return
}
+12 -2
View File
@@ -1,9 +1,11 @@
package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"net/http/httptest"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -63,12 +65,20 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, webhook, nil, page,
w, newRequestForTest(), webhook, nil, page,
)
return views
}
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a
// template of its own.
@@ -122,5 +132,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string,
) (string, error) {
return s.buildDatabaseTargetConfig(w, expiry)
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
}
+89 -23
View File
@@ -12,7 +12,6 @@ import (
"net/http"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
@@ -62,8 +61,6 @@ type HandlersParams struct {
Notifier delivery.Notifier
Evictor delivery.WebhookEvictor
SSRFGuard *delivery.Guard
Metrics *metrics.Set
Registry *prometheus.Registry
}
// Handlers provides HTTP handler methods for all application
@@ -125,7 +122,7 @@ func New(
s.mw = params.Middleware
s.notifier = params.Notifier
s.evictor = params.Evictor
s.mtr = params.Metrics
s.mtr = metrics.Default()
s.ssrf = params.SSRFGuard
// Parse all page templates once at startup
@@ -138,6 +135,7 @@ func New(
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
}
lc.Append(fx.Hook{
@@ -149,6 +147,15 @@ func New(
return s, nil
}
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths and
// the CSRF middleware for a refused form.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
@@ -166,15 +173,76 @@ func (s *Handlers) respondJSON(
}
}
// serverError logs an error and sends a 500 response.
// serverError logs an error and answers with the 500 error page.
func (s *Handlers) serverError(
w http.ResponseWriter, msg string, err error,
w http.ResponseWriter, r *http.Request, msg string, err error,
) {
s.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
}
// renderError answers with status and the error page: the normal
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
// The page names the signed-in user, and some error pages are
// served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store")
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
})
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
}
// UserInfo represents user information for templates
@@ -227,14 +295,17 @@ func (s *Handlers) renderTemplate(
"template not found",
"template", pageTemplate,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
return
}
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
}
// pageData adds the fields the shared layout renders to a page's own
// data.
func (s *Handlers) pageData(r *http.Request, data any) any {
userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r)
@@ -248,19 +319,16 @@ func (s *Handlers) renderTemplate(
m["User"] = userInfo
m["CSRFToken"] = csrfToken
m["Version"] = version
s.executeTemplate(w, tmpl, m)
return
return m
}
wrapper := templateDataWrapper{
return templateDataWrapper{
User: userInfo,
CSRFToken: csrfToken,
Version: version,
Data: data,
}
s.executeTemplate(w, tmpl, wrapper)
}
// executeTemplate renders the template into a buffer and writes to
@@ -273,6 +341,7 @@ func (s *Handlers) renderTemplate(
// this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data any,
) {
@@ -283,10 +352,7 @@ func (s *Handlers) executeTemplate(
s.log.Error(
"failed to execute template", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
return
}
+6 -5
View File
@@ -20,7 +20,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session"
)
@@ -110,8 +109,6 @@ func newTestApp(
func(r *recordingEvictor) delivery.WebhookEvictor {
return r
},
metrics.NewRegistry,
metrics.New,
middleware.New,
delivery.NewGuard,
handlers.New,
@@ -310,10 +307,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500",
)
assert.Equal(
t, "Internal server error\n", w.Body.String(),
assert.NotContains(
t, w.Body.String(), partialPageMarker,
"the response must carry no part of the aborted page",
)
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
}
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
-21
View File
@@ -1,21 +0,0 @@
package handlers
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// HandleMetrics returns the Prometheus scrape handler for the
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
// runtime and process collectors register on. It is what
// promhttp.Handler builds for the global default registry, including
// the promhttp_metric_handler_* series that count scrapes, pointed at
// that registry instead.
func (s *Handlers) HandleMetrics() http.HandlerFunc {
reg := s.params.Registry
return promhttp.InstrumentMetricHandler(
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
).ServeHTTP
}
+16 -28
View File
@@ -1,7 +1,6 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi"
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w,
r,
sessionUsername,
// PostFormValue, not FormValue: the credential must
// come from the body, never from the query string.
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes a
// 500 response itself and returns handled=false, signalling the caller
// to display on the profile page. On an internal failure it writes the
// error page itself and returns handled=false, signalling the caller
// to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) {
// This endpoint verifies one password and hashes another, at
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held
// across both hashes.
release, ok := h.mw.BeginPasswordVerification(ctx)
release, ok := h.mw.BeginPasswordVerification(r.Context())
if !ok {
h.log.Warn("password verification capacity exhausted")
http.Error(
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
h.renderError(w, r, http.StatusServiceUnavailable)
return "", "", false
}
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error
if err != nil {
h.serverError(
w, "failed to load user for password change", err,
w, r, "failed to load user for password change", err,
)
return "", "", false
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password,
)
if err != nil {
h.serverError(w, "failed to verify password", err)
h.serverError(w, r, "failed to verify password", err)
return "", "", false
}
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword)
if err != nil {
h.serverError(w, "failed to hash new password", err)
h.serverError(w, r, "failed to hash new password", err)
return "", "", false
}
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword,
).Error
if err != nil {
h.serverError(w, "failed to update password", err)
h.serverError(w, r, "failed to update password", err)
return "", "", false
}
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return "", "", false
}
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error.
sess, err := h.session.Get(r)
if err != nil {
h.serverError(w, "failed to get session", err)
h.serverError(w, r, "failed to get session", err)
return "", "", false
}
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess)
if !ok {
h.log.Error("authenticated session missing username")
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.renderError(w, r, http.StatusInternalServerError)
return "", "", false
}
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess)
if !ok {
h.log.Error("authenticated session missing user ID")
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.renderError(w, r, http.StatusInternalServerError)
return "", "", false
}
// Only allow users to act on their own profile.
if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden)
h.renderError(w, r, http.StatusForbidden)
return "", "", false
}
+4 -2
View File
@@ -128,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session
app := newTestApp(t, &log, &cfg, &sess)
var h *handlers.Handlers
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -139,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF())
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true
+39 -61
View File
@@ -149,13 +149,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to list webhooks", err)
return
}
@@ -249,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -311,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook)
if err != nil {
h.serverError(w, "failed to create webhook", err)
h.serverError(w, r, "failed to create webhook", err)
return
}
@@ -388,7 +380,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -420,7 +412,7 @@ func (h *Handlers) renderSourceDetail(
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -429,7 +421,7 @@ func (h *Handlers) renderSourceDetail(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, "failed to load recent events", err)
h.serverError(w, r, "failed to load recent events", err)
return
}
@@ -482,7 +474,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -517,7 +509,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -526,9 +518,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -582,7 +572,7 @@ func (h *Handlers) applyWebhookEdit(
err := h.db.DB().Save(webhook).Error
if err != nil {
h.serverError(w, "failed to update webhook", err)
h.serverError(w, r, "failed to update webhook", err)
return
}
@@ -612,7 +602,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -639,7 +629,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook)
if err != nil {
h.serverError(w, "failed to delete webhook", err)
h.serverError(w, r, "failed to delete webhook", err)
return
}
@@ -665,7 +655,7 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
h.serverError(
w, "failed to delete webhook event database", err,
w, r, "failed to delete webhook event database", err,
)
return
@@ -809,7 +799,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, false
}
@@ -831,7 +821,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a
// zero redactor, so failing the page is the only
// safe answer.
h.serverError(w, "failed to load targets", err)
h.serverError(w, r, "failed to load targets", err)
return
}
@@ -839,7 +829,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries(
w, webhook, targets, page,
w, r, webhook, targets, page,
)
if !ok {
return
@@ -949,6 +939,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
page int,
@@ -962,7 +953,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, "failed to get webhook database", err,
w, r, "failed to get webhook database", err,
)
return nil, 0, false
@@ -999,7 +990,7 @@ func (h *Handlers) loadEventsWithDeliveries(
)
if err != nil {
h.serverError(
w, "failed to load delivery attempts", err,
w, r, "failed to load delivery attempts", err,
)
return nil, 0, false
@@ -1008,7 +999,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, "failed to count event resubmissions", err,
w, r, "failed to count event resubmissions", err,
)
return nil, 0, false
@@ -1231,7 +1222,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1240,9 +1231,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -1258,7 +1247,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, "failed to create entrypoint", err)
h.serverError(w, r, "failed to create entrypoint", err)
return
}
@@ -1289,7 +1278,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1298,9 +1287,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -1371,7 +1358,7 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, "failed to create target", err)
h.serverError(w, r, "failed to create target", err)
return
}
@@ -1465,7 +1452,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, in.Expiry)
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
case database.TargetTypeLog:
return "", nil
default:
@@ -1515,7 +1502,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err
}
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
@@ -1537,7 +1524,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err
}
return marshalTargetConfig(w, delivery.SlackTargetConfig{
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
}
@@ -1591,16 +1578,14 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func marshalTargetConfig(
func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to encode target config", err)
return "", err
}
@@ -1616,6 +1601,7 @@ func marshalTargetConfig(
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry)
@@ -1634,8 +1620,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err
}
return marshalTargetConfig(
w, map[string]any{"expiry": expiry},
return h.marshalTargetConfig(
w, r, map[string]any{"expiry": expiry},
)
}
@@ -1689,7 +1675,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1699,11 +1685,7 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, errMsg, result.Error)
return
}
@@ -1793,18 +1775,14 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
err = toggleFn(webhook.ID, childID)
if err != nil {
h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, errMsg, err)
return
}
+3 -5
View File
@@ -88,9 +88,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -157,7 +155,7 @@ func (h *Handlers) applyTargetEdit(
err = h.db.DB().Save(target).Error
if err != nil {
h.serverError(w, "failed to update target", err)
h.serverError(w, r, "failed to update target", err)
return
}
@@ -220,7 +218,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, nil, false
}
+16 -3
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.serverError(w, "failed to serialize headers", err)
h.receiverError(w, "failed to serialize headers", err)
return
}
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil {
h.serverError(w, "failed to query targets", err)
h.receiverError(w, "failed to query targets", err)
return
}
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets,
)
if err != nil {
h.serverError(w, "failed to store webhook event", err)
h.receiverError(w, "failed to store webhook event", err)
return
}
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks)
}
// receiverError logs an error and answers the sender with a plain-text
// 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses.
func (h *Handlers) receiverError(
w http.ResponseWriter, msg string, err error,
) {
h.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an
+20 -27
View File
@@ -3,18 +3,17 @@
// deliveries are attempted, how they end, how long they take, how
// deep the queues are, and how many circuit breakers are open.
//
// It also builds the registry the authenticated /metrics route
// serves. In production, these collectors, the inbound HTTP metrics
// recorded in internal/middleware, and the Go runtime and process
// collectors all register on that one registry, never on Prometheus's
// global default.
// The inbound HTTP metrics come from the go-http-metrics recorder in
// internal/middleware and land on prometheus.DefaultRegisterer. These
// collectors register there too, so both surfaces are gathered by the
// one promhttp handler mounted on the authenticated /metrics route.
package metrics
import (
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promauto"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -58,31 +57,25 @@ var knownTargetTypes = []database.TargetType{
database.TargetTypeSlack,
}
// NewRegistry returns the registry /metrics serves, carrying the Go
// runtime and process collectors that Prometheus's global default
// registry carries, so the go_* and process_* series stay in the
// scrape.
// defaultSet is the process-wide metric set, registered on the same
// registry the HTTP middleware and the /metrics handler already use.
// It is built on first use rather than in an init so that a test
// binary that never touches metrics never registers them.
//
// A registry of its own, rather than the global default, is what lets
// two dependency graphs in one process — two tests, say — each
// register their collectors without the second registration
// panicking.
func NewRegistry() *prometheus.Registry {
reg := prometheus.NewRegistry()
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(
collectors.ProcessCollectorOpts{},
),
)
//nolint:gochecknoglobals // one process-wide registration, by design
var defaultSet = sync.OnceValue(func() *Set {
return New(prometheus.DefaultRegisterer)
})
return reg
// Default returns the process-wide metric set.
func Default() *Set {
return defaultSet()
}
// Set is one registered group of webhooker's delivery collectors.
// Production builds one on the registry /metrics serves; tests build
// one on a registry of their own so they can gather what their own
// deliveries recorded.
// Production uses the single Default set; tests build their own
// against a private registry so assertions are not disturbed by
// deliveries other tests are making concurrently.
type Set struct {
eventsReceived prometheus.Counter
deliveryAttempts *prometheus.CounterVec
@@ -100,7 +93,7 @@ type Set struct {
// New registers a full set of delivery collectors on reg and returns
// it. It panics if reg already holds them, which is the intended
// behaviour for a duplicate registration.
func New(reg *prometheus.Registry) *Set {
func New(reg prometheus.Registerer) *Set {
factory := promauto.With(reg)
s := &Set{
+5 -3
View File
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
// key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token receive a 403 Forbidden response.
// token are logged and answered by forbidden, which must write the 403.
//
// The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session
@@ -36,7 +36,9 @@ func CSRFToken(r *http.Request) string {
// Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
func (m *Middleware) CSRF(
forbidden http.Handler,
) func(http.Handler) http.Handler {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an
@@ -57,7 +59,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
"remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r),
)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
forbidden.ServeHTTP(w, r)
})
key := m.session.GetKey()
+15 -9
View File
@@ -18,6 +18,12 @@ import (
// csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf"
// forbidden stands in for the error page the server hands CSRF to
// answer a refused request with.
func forbidden(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
// csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies.
func csrfGetToken(
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string
handler := m.CSRF()(http.HandlerFunc(
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r)
},
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool
handler := m.CSRF()(http.HandlerFunc(
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext(
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
+2 -1
View File
@@ -10,7 +10,8 @@ import (
// MetricsMiddlewareForTest builds the metrics recording middleware
// against a caller-supplied recorder, so a test can gather from its
// own Prometheus registry without building a whole Middleware.
// own Prometheus registry rather than the process-wide default one
// that Middleware.Metrics uses.
func MetricsMiddlewareForTest(
rec httpmetrics.Recorder,
) func(http.Handler) http.Handler {
+3 -1
View File
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
) http.Handler {
t.Helper()
return m.CSRF()(unreachable(t))
return m.CSRF(http.HandlerFunc(forbidden))(
unreachable(t),
)
},
send: postNoToken,
wantStatus: http.StatusForbidden,
+8 -7
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi"
httpmetrics "github.com/slok/go-http-metrics/metrics"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
)
@@ -150,17 +151,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
var _ httpmetrics.Recorder = boundedLabelRecorder{}
// Metrics returns middleware that records Prometheus HTTP metrics
// with the Middleware's one recorder, which New builds on the registry
// the /metrics route serves and NewForTest on a registry of its own.
// Every call reuses that recorder, so any number of routers can
// install it.
// Metrics returns middleware that records Prometheus HTTP metrics on
// the default registry, which is the one the /metrics route gathers.
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
return metricsMiddleware(s.metricsRecorder)
return metricsMiddleware(
prommetrics.NewRecorder(prommetrics.Config{}),
)
}
// metricsMiddleware builds the recording middleware against a given
// recorder, so tests can gather from a registry of their own.
// recorder, so tests can gather from a registry of their own instead
// of the process-wide default.
func metricsMiddleware(
rec httpmetrics.Recorder,
) func(http.Handler) http.Handler {
+3 -28
View File
@@ -57,8 +57,9 @@ const (
// Server.setupWebhookRoutes inside it. That ordering is the whole
// defect, so a test that flattens it would prove nothing.
//
// The recorder writes to a registry of the test's own, so each test
// observes only its own traffic.
// The recorder writes to a registry of the test's own rather than the
// process-wide default one, so each test observes only its own
// traffic.
func metricsTestRouter(
t *testing.T,
receiverLimit int,
@@ -454,29 +455,3 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
"the interceptor must still count written bytes",
)
}
// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
// by NewForTest has a recorder of its own: its Metrics() serves a
// request instead of panicking, and a second one does not collide
// with the first.
func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
t.Parallel()
log := slog.New(slog.DiscardHandler)
cfg := &config.Config{Environment: "prod"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(okBody))
})
for range 2 {
h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, okRoute, nil,
)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
}
+4 -19
View File
@@ -13,9 +13,6 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/prometheus/client_golang/prometheus"
httpmetrics "github.com/slok/go-http-metrics/metrics"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals"
@@ -151,11 +148,10 @@ const (
type MiddlewareParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Session *session.Session
Registry *prometheus.Registry
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Session *session.Session
}
// Middleware provides HTTP middleware for logging, CORS, auth, and
@@ -165,14 +161,6 @@ type Middleware struct {
params *MiddlewareParams
session *session.Session
// metricsRecorder records the inbound HTTP metrics. New builds
// it on the registry /metrics serves, NewForTest on a registry
// of its own. Either way it is built once per Middleware and
// Metrics reuses it, because building it registers its
// collectors, and a second registration on the same registry
// panics.
metricsRecorder httpmetrics.Recorder
// loginGuard counts failed credential verifications and bounds
// concurrent password hashing. It is built on first use so that
// every construction path gets one; see guard().
@@ -191,9 +179,6 @@ func New(
s.params = &params
s.log = params.Logger.Get()
s.session = params.Session
s.metricsRecorder = prommetrics.NewRecorder(
prommetrics.Config{Registry: params.Registry},
)
return s, nil
}
+34 -2
View File
@@ -109,7 +109,8 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// Recoverer returns middleware that turns a handler panic into one
// structured ERROR record and a 500, rather than a dropped
// connection.
// connection. The 500 is page when page is not nil, and plain text
// when it is nil or when page panics before writing anything.
//
// It replaces chi's middleware.Recoverer, which does neither on a
// current Go release. chi v1.5.5's pretty-printer scans the stack for
@@ -138,7 +139,9 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// set before panicking, because a request that failed must not hand
// the client a credential; every other header is left to http.Error.
// See https://git.eeqj.de/sneak/webhooker/issues/193.
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
func (s *Middleware) Recoverer(
page http.Handler,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter,
@@ -171,6 +174,14 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
rw.Header().Del("Set-Cookie")
if page != nil {
s.servePage(rw, r, page)
}
if rw.committed {
return
}
http.Error(
rw,
http.StatusText(
@@ -185,6 +196,27 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
}
}
// servePage answers with page. A panic in page itself is logged and
// recovered here, so the Recoverer can still send its plain 500.
func (s *Middleware) servePage(
w http.ResponseWriter,
r *http.Request,
page http.Handler,
) {
defer func() {
rvr := recover()
if rvr != nil {
s.log.Error("error page panic",
"panic", logfield.Truncate(
fmt.Sprint(rvr), maxPanicValueBytes,
),
)
}
}()
page.ServeHTTP(w, r)
}
// logPanic writes the record. Every field it can grow is truncated to
// a fixed budget, so MaxPanicLogLineBytes holds.
//
+58 -2
View File
@@ -76,7 +76,7 @@ func newRecovererProbe(
// Logging outside so the recovered 500 is the status it records.
router.Use(chimw.RequestID)
router.Use(m.Logging())
router.Use(m.Recoverer())
router.Use(m.Recoverer(nil))
router.Get("/probe", handler)
serverErrors := new(bytes.Buffer)
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
m, _ := capturingMiddleware(t)
handler := m.Recoverer()(http.HandlerFunc(
handler := m.Recoverer(nil)(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("chunk"))
@@ -672,3 +672,59 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "chunk", string(body))
}
// TestRecovererAnswersWithThePage covers a recoverer given a page:
// the panic is logged as before, and the 500 is that page.
func TestRecovererAnswersWithThePage(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
page := http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("the error page"))
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "the error page", w.Body.String())
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
assert.Contains(t, logs.String(), panicMarker)
}
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
// before writing anything: both panics are logged, and the client
// still gets the plain 500.
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
const pagePanic = "QQERRORPAGEPANICQQ"
page := http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
panic(pagePanic)
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
assert.Contains(t, logs.String(), panicMarker)
assert.Contains(t, logs.String(), pagePanic)
}
-8
View File
@@ -3,17 +3,12 @@ package middleware
import (
"log/slog"
"github.com/prometheus/client_golang/prometheus"
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/session"
)
// NewForTest creates a Middleware with the minimum dependencies
// needed for testing. This bypasses the fx lifecycle.
//
// Its metrics recorder writes to a fresh registry of its own, so
// Metrics() works on it and two of them never collide.
func NewForTest(
log *slog.Logger,
cfg *config.Config,
@@ -25,8 +20,5 @@ func NewForTest(
Config: cfg,
},
session: sess,
metricsRecorder: prommetrics.NewRecorder(
prommetrics.Config{Registry: prometheus.NewRegistry()},
),
}
}
-3
View File
@@ -24,7 +24,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/session"
@@ -164,8 +163,6 @@ func newServerApp(
session.New,
func() delivery.Notifier { return &noopNotifier{} },
func() delivery.WebhookEvictor { return &noopEvictor{} },
metrics.NewRegistry,
metrics.New,
middleware.New,
delivery.NewGuard,
handlers.New,
+220
View File
@@ -0,0 +1,220 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/server"
)
// The link back the error page offers: to the webhook list for a
// signed-in user, to sign-in for anyone else.
const (
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
`Back to webhooks</a>`
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
`Sign in</a>`
)
// assertErrorPage checks that w is the error page for status, in the
// normal layout, offering link.
func assertErrorPage(
t *testing.T,
w *httptest.ResponseRecorder,
status int,
link string,
) {
t.Helper()
body := w.Body.String()
assert.Equal(t, status, w.Code)
assert.Equal(
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
)
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
assert.Contains(t, body, `<nav class="app-bar"`)
assert.Contains(
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
)
assert.Contains(t, body, link)
}
func TestErrorPage_DeletedWebhook(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Delete(wh).Error)
w := env.get("/source/"+wh.ID, cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_DeletedTarget(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
require.NoError(t, env.db.DB().Delete(tgt).Error)
w := env.get(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_UnknownPath(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
assertErrorPage(
t, env.get("/no-such-page", nil),
http.StatusNotFound, backToSignIn,
)
// Outside every route group there is no form token, so the
// page leaves out the logout form rather than offer one that
// would be refused.
w := env.get("/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
// Inside a route group the page has a token, and logout works.
wh := env.seedWebhook(t, userID)
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
}
func TestErrorPage_BadCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("username", "someone")
form.Set("password", "irrelevant")
form.Set("csrf_token", "not-a-token")
assertErrorPage(
t, env.post("/pages/login", form, nil),
http.StatusForbidden, backToSignIn,
)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
edit := url.Values{}
edit.Set("name", "renamed")
assertErrorPage(
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
http.StatusForbidden, backToWebhooks,
)
}
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
// admin page route group through the real router, with error
// tracking on: the client gets the 500 error page, and the tracker
// still gets the panic, once. The same panic outside the admin page
// route groups keeps the plain 500.
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
transport := &captureTransport{}
opts := server.SentryClientOptionsForTest(
"https://public@sentry.invalid/1", "webhooker-test",
)
opts.Transport = transport
client, err := sentry.NewClient(opts)
require.NoError(t, err)
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
sentry.SetHubOnContext(
context.Background(),
sentry.NewHub(client, sentry.NewScope()),
),
http.MethodGet, path, nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := serve(
server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.PageProbePattern,
)
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
w = serve(
server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.ProbePattern,
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
require.Len(t, transport.events, 2)
for _, event := range transport.events {
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
}
}
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
// the web UI only: a sender posting to an entrypoint that does not
// exist still gets the plain-text answer.
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
t.Parallel()
// newTestEnv leaves the receiver rate limit at zero, which
// refuses every request before it reaches the receiver.
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: 10,
})
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "404 page not found\n", w.Body.String())
}
+37
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware"
@@ -101,3 +102,39 @@ func NewRouterWithProbeForTest(
return s.router
}
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
// probe: inside the /pages route group, the admin page group a
// request reaches without signing in.
const PageProbePattern = "/pages/probe"
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
// probe added to the /pages route group once SetupRoutes has built
// it, so the probe runs behind that group's own middleware exactly as
// the group's real routes do.
func NewRouterWithPageProbeForTest(
log *slog.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
sentryEnabled bool,
probe http.HandlerFunc,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes()
for _, route := range s.router.Routes() {
pages, ok := route.SubRoutes.(chi.Router)
if ok && route.Pattern == "/pages/*" {
pages.Get("/probe", probe)
}
}
return s.router
}
+49 -13
View File
@@ -7,6 +7,7 @@ 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"
"sneak.berlin/go/webhooker/static"
)
@@ -15,8 +16,8 @@ import (
// 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.
// its first middleware after the recoverer, 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
@@ -45,6 +46,14 @@ 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()
}
@@ -68,23 +77,33 @@ func (s *Server) setupGlobalMiddleware() {
// 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 (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)
}
}
@@ -129,7 +148,12 @@ func (s *Server) setupRoutes() {
if s.params.Config.MetricsAuthEnabled() {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", s.h.HandleMetrics())
r.Get(
"/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
),
)
})
}
@@ -141,10 +165,13 @@ func (s *Server) setupRoutes() {
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
@@ -163,10 +190,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())
@@ -178,10 +208,13 @@ func (s *Server) setupUserRoutes() {
func (s *Server) setupSourceRoutes() {
s.router.Route("/sources", 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.HandleSourceList())
@@ -190,10 +223,13 @@ func (s *Server) setupSourceRoutes() {
})
s.router.Route("/source/{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())
-46
View File
@@ -24,7 +24,6 @@ import (
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/server"
"sneak.berlin/go/webhooker/internal/session"
@@ -114,8 +113,6 @@ func newTestEnvWithConfig(
session.New,
func() delivery.Notifier { return &noopNotifier{} },
func() delivery.WebhookEvictor { return &noopEvictor{} },
metrics.NewRegistry,
metrics.New,
middleware.New,
delivery.NewGuard,
handlers.New,
@@ -1030,46 +1027,3 @@ func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
})
}
}
// TestTwoMetricsRoutersInOneProcess pins
// https://git.eeqj.de/sneak/webhooker/issues/227: a second
// metrics-enabled router in one process used to panic, because the
// HTTP metrics registered on Prometheus's global default registry.
// Two routers are built over separate dependency graphs and a third
// over the first graph again, and each must still serve the HTTP,
// delivery, Go runtime and process series, and the series counting
// scrapes of /metrics itself.
func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
t.Parallel()
first := newTestEnvWithConfig(
t, metricsConfig(t, metricsUser, metricsAuthValue),
)
second := newTestEnvWithConfig(
t, metricsConfig(t, metricsUser, metricsAuthValue),
)
third := &testEnv{
router: server.NewRouterForTest(
first.log.Get(), first.cfg, first.mw, first.hnd,
),
}
for _, env := range []*testEnv{first, second, third} {
env.get("/", nil)
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
require.Equal(t, http.StatusOK, scrape.Code)
for _, series := range []string{
"http_request_duration_seconds",
"http_response_size_bytes",
"http_requests_inflight",
"webhooker_events_received_total",
"go_goroutines",
"process_start_time_seconds",
"promhttp_metric_handler_requests_total",
} {
assert.Contains(t, scrape.Body.String(), series)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{{template "base" .}}
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12">
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
<p class="text-gray-600 mb-6">{{.Message}}</p>
{{if .User}}
<a href="/sources" class="btn-secondary">Back to webhooks</a>
{{else}}
<a href="/pages/login" class="btn-primary">Sign in</a>
{{end}}
</div>
{{end}}
+6
View File
@@ -24,10 +24,14 @@
</svg>
{{.User.Username}}
</a>
{{/* An error page can be served before a form token is issued,
and a logout without one is refused. */}}
{{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button>
</form>
{{end}}
{{else}}
<a href="/pages/login" class="btn-primary">Login</a>
{{end}}
@@ -40,10 +44,12 @@
{{if .User}}
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button>
</form>
{{end}}
{{else}}
<a href="/pages/login" class="btn-primary w-full">Login</a>
{{end}}