1 Commits
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
23 changed files with 697 additions and 196 deletions
+5 -23
View File
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest) h.renderError(w, r, http.StatusBadRequest)
return return
} }
@@ -166,11 +166,7 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password) valid, err := database.VerifyPassword(password, user.Password)
if err != nil { if err != nil {
h.log.Error("failed to verify password", "error", err) h.serverError(w, r, "failed to verify password", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err return user, err
} }
@@ -242,24 +238,14 @@ func (h *Handlers) createAuthenticatedSession(
) error { ) error {
oldSess, err := h.session.Get(r) oldSess, err := h.session.Get(r)
if err != nil { if err != nil {
h.log.Error("failed to get session", "error", err) h.serverError(w, r, "failed to get session", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
sess, err := h.session.Regenerate(r, w, oldSess) sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil { if err != nil {
h.log.Error( h.serverError(w, r, "failed to regenerate session", err)
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
@@ -268,11 +254,7 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess) err = h.session.Save(r, w, sess)
if err != nil { if err != nil {
h.log.Error("failed to save session", "error", err) h.serverError(w, r, "failed to save session", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return 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. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -124,14 +122,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook, webhook database.Webhook,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
@@ -173,7 +171,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"), &original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error ).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return nil, false return nil, false
} }
@@ -195,7 +193,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to count in-flight deliveries", err, w, r, "failed to count in-flight deliveries", err,
) )
return return
@@ -212,7 +210,7 @@ func (h *Handlers) queueReplay(
err = webhookDB. err = webhookDB.
First(&event, "id = ?", original.EventID).Error First(&event, "id = ?", original.EventID).Error
if err != nil { 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 return
} }
@@ -222,7 +220,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to create replay delivery", err, w, r, "failed to create replay delivery", err,
) )
return 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. // steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string, eventID string,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
body, found, err := eventBody(webhookDB, webhook.ID, eventID) body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil { if err != nil {
h.serverError(w, "failed to read event body", err) h.serverError(w, r, "failed to read event body", err)
return return
} }
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the // row and the whole body is served, or it does not and the
// response is a clean 404. // response is a clean 404.
if !found { if !found {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
+8 -8
View File
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest) h.renderError(w, r, http.StatusBadRequest)
return return
} }
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request. // alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(), webhookDB, webhook.ID, eventID.String(),
) )
if err != nil { 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 return
} }
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to // A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed. // another webhook, or never existed.
if !found { if !found {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused. // inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID) targets, err := h.loadActiveTargets(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to query targets", err) h.serverError(w, r, "failed to query targets", err)
return return
} }
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
targets, targets,
) )
if err != nil { if err != nil {
h.serverError(w, "failed to store resubmitted event", err) h.serverError(w, r, "failed to store resubmitted event", err)
return return
} }
+12 -2
View File
@@ -1,9 +1,11 @@
package handlers package handlers
import ( import (
"context"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -63,12 +65,20 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int, page int,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, webhook, nil, page, w, newRequestForTest(), webhook, nil, page,
) )
return views 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 // AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a // the handlers_test package can drive the render path with a
// template of its own. // template of its own.
@@ -122,5 +132,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter, w http.ResponseWriter,
expiry string, expiry string,
) (string, error) { ) (string, error) {
return s.buildDatabaseTargetConfig(w, expiry) return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
} }
+88 -19
View File
@@ -135,6 +135,7 @@ func New(
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"), "source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
@@ -146,6 +147,15 @@ func New(
return s, nil 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( func (s *Handlers) respondJSON(
w http.ResponseWriter, w http.ResponseWriter,
_ *http.Request, _ *http.Request,
@@ -163,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( 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) s.log.Error(msg, "error", err)
http.Error( s.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error", }
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 // UserInfo represents user information for templates
@@ -224,14 +295,17 @@ func (s *Handlers) renderTemplate(
"template not found", "template not found",
"template", pageTemplate, "template", pageTemplate,
) )
http.Error( s.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return 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) userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r) csrfToken := middleware.CSRFToken(r)
@@ -245,19 +319,16 @@ func (s *Handlers) renderTemplate(
m["User"] = userInfo m["User"] = userInfo
m["CSRFToken"] = csrfToken m["CSRFToken"] = csrfToken
m["Version"] = version m["Version"] = version
s.executeTemplate(w, tmpl, m)
return return m
} }
wrapper := templateDataWrapper{ return templateDataWrapper{
User: userInfo, User: userInfo,
CSRFToken: csrfToken, CSRFToken: csrfToken,
Version: version, Version: version,
Data: data, Data: data,
} }
s.executeTemplate(w, tmpl, wrapper)
} }
// executeTemplate renders the template into a buffer and writes to // executeTemplate renders the template into a buffer and writes to
@@ -270,6 +341,7 @@ func (s *Handlers) renderTemplate(
// this reason. // this reason.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
) { ) {
@@ -280,10 +352,7 @@ func (s *Handlers) executeTemplate(
s.log.Error( s.log.Error(
"failed to execute template", "error", err, "failed to execute template", "error", err,
) )
http.Error( s.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
+6 -2
View File
@@ -307,10 +307,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code, t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500", "a failed render must report a 500",
) )
assert.Equal( assert.NotContains(
t, "Internal server error\n", w.Body.String(), t, w.Body.String(), partialPageMarker,
"the response must carry no part of the aborted page", "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) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+16 -28
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest) h.renderError(w, r, http.StatusBadRequest)
return return
} }
successMessage, errorMessage, handled := h.applyPasswordChange( successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w, w,
r,
sessionUsername, sessionUsername,
// PostFormValue, not FormValue: the credential must // PostFormValue, not FormValue: the credential must
// come from the body, never from the query string. // 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, // applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that // persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages // bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes a // to display on the profile page. On an internal failure it writes the
// 500 response itself and returns handled=false, signalling the caller // error page itself and returns handled=false, signalling the caller
// to stop without re-rendering the page. // to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange( func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string, username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) { ) (string, string, bool) {
// This endpoint verifies one password and hashes another, at // 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 // 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 // this path outside it would leave a hole in it. The slot is held
// across both hashes. // across both hashes.
release, ok := h.mw.BeginPasswordVerification(ctx) release, ok := h.mw.BeginPasswordVerification(r.Context())
if !ok { if !ok {
h.log.Warn("password verification capacity exhausted") h.log.Warn("password verification capacity exhausted")
http.Error( h.renderError(w, r, http.StatusServiceUnavailable)
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
return "", "", false return "", "", false
} }
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error ).First(&user).Error
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to load user for password change", err, w, r, "failed to load user for password change", err,
) )
return "", "", false return "", "", false
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password, currentPassword, user.Password,
) )
if err != nil { if err != nil {
h.serverError(w, "failed to verify password", err) h.serverError(w, r, "failed to verify password", err)
return "", "", false return "", "", false
} }
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword) hashedPassword, err := database.HashPassword(newPassword)
if err != nil { if err != nil {
h.serverError(w, "failed to hash new password", err) h.serverError(w, r, "failed to hash new password", err)
return "", "", false return "", "", false
} }
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword, "password", hashedPassword,
).Error ).Error
if err != nil { if err != nil {
h.serverError(w, "failed to update password", err) h.serverError(w, r, "failed to update password", err)
return "", "", false return "", "", false
} }
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) { ) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username") requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" { if requestedUsername == "" {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return "", "", false return "", "", false
} }
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error. // unexpected retrieval error.
sess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err != nil { if err != nil {
h.serverError(w, "failed to get session", err) h.serverError(w, r, "failed to get session", err)
return "", "", false return "", "", false
} }
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess) sessionUsername, ok := h.session.GetUsername(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing username") h.log.Error("authenticated session missing username")
http.Error( h.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess) sessionUserID, ok := h.session.GetUserID(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing user ID") h.log.Error("authenticated session missing user ID")
http.Error( h.renderError(w, r, http.StatusInternalServerError)
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
// Only allow users to act on their own profile. // Only allow users to act on their own profile.
if requestedUsername != sessionUsername { if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden) h.renderError(w, r, http.StatusForbidden)
return "", "", false return "", "", false
} }
+4 -2
View File
@@ -128,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session var sess *session.Session
app := newTestApp(t, &log, &cfg, &sess) var h *handlers.Handlers
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -139,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter() router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) { 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.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) { r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true handlerReached = true
+39 -61
View File
@@ -149,13 +149,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID, "user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error ).Order("created_at DESC").Find(&webhooks).Error
if err != nil { if err != nil {
h.log.Error( h.serverError(w, r, "failed to list webhooks", err)
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
@@ -249,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -311,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook) err := h.commitWebhook(webhook)
if err != nil { if err != nil {
h.serverError(w, "failed to create webhook", err) h.serverError(w, r, "failed to create webhook", err)
return return
} }
@@ -388,7 +380,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -420,7 +412,7 @@ func (h *Handlers) renderSourceDetail(
if h.dbMgr.DBExists(webhook.ID) { if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, "failed to get webhook database", err) h.serverError(w, r, "failed to get webhook database", err)
return return
} }
@@ -429,7 +421,7 @@ func (h *Handlers) renderSourceDetail(
webhookDB, webhook.ID, singleHTTPTargetID(targets), webhookDB, webhook.ID, singleHTTPTargetID(targets),
) )
if err != nil { if err != nil {
h.serverError(w, "failed to load recent events", err) h.serverError(w, r, "failed to load recent events", err)
return return
} }
@@ -482,7 +474,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -517,7 +509,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -526,9 +518,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -582,7 +572,7 @@ func (h *Handlers) applyWebhookEdit(
err := h.db.DB().Save(webhook).Error err := h.db.DB().Save(webhook).Error
if err != nil { if err != nil {
h.serverError(w, "failed to update webhook", err) h.serverError(w, r, "failed to update webhook", err)
return return
} }
@@ -612,7 +602,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -639,7 +629,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered. // be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook) err := h.commitWebhookDeletion(&webhook)
if err != nil { if err != nil {
h.serverError(w, "failed to delete webhook", err) h.serverError(w, r, "failed to delete webhook", err)
return return
} }
@@ -665,7 +655,7 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file // redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it. // needs removing by hand, and the logged error names it.
h.serverError( h.serverError(
w, "failed to delete webhook event database", err, w, r, "failed to delete webhook event database", err,
) )
return return
@@ -809,7 +799,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, false return database.Webhook{}, false
} }
@@ -831,7 +821,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a // Without the map every delivery renders through a
// zero redactor, so failing the page is the only // zero redactor, so failing the page is the only
// safe answer. // safe answer.
h.serverError(w, "failed to load targets", err) h.serverError(w, r, "failed to load targets", err)
return return
} }
@@ -839,7 +829,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r) page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries( evts, total, ok := h.loadEventsWithDeliveries(
w, webhook, targets, page, w, r, webhook, targets, page,
) )
if !ok { if !ok {
return return
@@ -949,6 +939,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further. // caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries( func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
webhook database.Webhook, webhook database.Webhook,
targetMap map[string]eventLogTarget, targetMap map[string]eventLogTarget,
page int, page int,
@@ -962,7 +953,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to get webhook database", err, w, r, "failed to get webhook database", err,
) )
return nil, 0, false return nil, 0, false
@@ -999,7 +990,7 @@ func (h *Handlers) loadEventsWithDeliveries(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to load delivery attempts", err, w, r, "failed to load delivery attempts", err,
) )
return nil, 0, false return nil, 0, false
@@ -1008,7 +999,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs) resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil { if err != nil {
h.serverError( h.serverError(
w, "failed to count event resubmissions", err, w, r, "failed to count event resubmissions", err,
) )
return nil, 0, false return nil, 0, false
@@ -1231,7 +1222,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -1240,9 +1231,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -1258,7 +1247,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error err = h.db.DB().Create(entrypoint).Error
if err != nil { if err != nil {
h.serverError(w, "failed to create entrypoint", err) h.serverError(w, r, "failed to create entrypoint", err)
return return
} }
@@ -1289,7 +1278,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -1298,9 +1287,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -1371,7 +1358,7 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error err = h.db.DB().Create(target).Error
if err != nil { if err != nil {
h.serverError(w, "failed to create target", err) h.serverError(w, r, "failed to create target", err)
return return
} }
@@ -1465,7 +1452,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack: case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL) return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, in.Expiry) return h.buildDatabaseTargetConfig(w, r, in.Expiry)
case database.TargetTypeLog: case database.TargetTypeLog:
return "", nil return "", nil
default: default:
@@ -1515,7 +1502,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err return "", err
} }
return marshalTargetConfig(w, delivery.HTTPTargetConfig{ return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
URL: in.URL, URL: in.URL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
@@ -1537,7 +1524,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err return "", err
} }
return marshalTargetConfig(w, delivery.SlackTargetConfig{ return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
WebhookURL: targetURL, WebhookURL: targetURL,
}) })
} }
@@ -1591,16 +1578,14 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage, // marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot. // writing a 500 itself if it cannot.
func marshalTargetConfig( func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
cfg any, cfg any,
) (string, error) { ) (string, error) {
configBytes, err := json.Marshal(cfg) configBytes, err := json.Marshal(cfg)
if err != nil { if err != nil {
http.Error( h.serverError(w, r, "failed to encode target config", err)
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err return "", err
} }
@@ -1616,6 +1601,7 @@ func marshalTargetConfig(
// expiry yields an empty config (the keep-forever default). // expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig( func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
expiry string, expiry string,
) (string, error) { ) (string, error) {
expiry = strings.TrimSpace(expiry) expiry = strings.TrimSpace(expiry)
@@ -1634,8 +1620,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err return "", err
} }
return marshalTargetConfig( return h.marshalTargetConfig(
w, map[string]any{"expiry": expiry}, w, r, map[string]any{"expiry": expiry},
) )
} }
@@ -1689,7 +1675,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
@@ -1699,11 +1685,7 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID, childID, webhook.ID,
).Delete(model) ).Delete(model)
if result.Error != nil { if result.Error != nil {
h.log.Error(errMsg, "error", result.Error) h.serverError(w, r, errMsg, result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
@@ -1793,18 +1775,14 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return return
} }
err = toggleFn(webhook.ID, childID) err = toggleFn(webhook.ID, childID)
if err != nil { if err != nil {
h.log.Error(errMsg, "error", err) h.serverError(w, r, errMsg, err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
+3 -5
View File
@@ -88,9 +88,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
http.Error( h.renderError(w, r, http.StatusBadRequest)
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -157,7 +155,7 @@ func (h *Handlers) applyTargetEdit(
err = h.db.DB().Save(target).Error err = h.db.DB().Save(target).Error
if err != nil { if err != nil {
h.serverError(w, "failed to update target", err) h.serverError(w, r, "failed to update target", err)
return return
} }
@@ -220,7 +218,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID, chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error ).First(&target).Error
if err != nil { if err != nil {
http.NotFound(w, r) h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, nil, false return database.Webhook{}, nil, false
} }
+16 -3
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header) headersJSON, err := json.Marshal(r.Header)
if err != nil { if err != nil {
h.serverError(w, "failed to serialize headers", err) h.receiverError(w, "failed to serialize headers", err)
return return
} }
targets, err := h.loadActiveTargets(entrypoint.WebhookID) targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil { if err != nil {
h.serverError(w, "failed to query targets", err) h.receiverError(w, "failed to query targets", err)
return return
} }
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets, targets,
) )
if err != nil { if err != nil {
h.serverError(w, "failed to store webhook event", err) h.receiverError(w, "failed to store webhook event", err)
return return
} }
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks) 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 // eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills // receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an // it from a stored event. Both then go through createAndFanOut, so an
+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 // 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 // the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing // 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 // The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session // 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 // Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the // (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request. // 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) { csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route // CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an // 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, "remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r), "reason", csrf.FailureReason(r),
) )
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden) forbidden.ServeHTTP(w, r)
}) })
key := m.session.GetKey() key := m.session.GetKey()
+15 -9
View File
@@ -18,6 +18,12 @@ import (
// csrfCookieName is the gorilla/csrf cookie name. // csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf" 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 // csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies. // and returns the token and cookies.
func csrfGetToken( func csrfGetToken(
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string var gotToken string
handler := m.CSRF()(http.HandlerFunc( handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r) gotToken = middleware.CSRFToken(r)
}, },
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev) m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper() t.Helper()
m, _ := testMiddleware(t, env) m, _ := testMiddleware(t, env)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie // GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc( getHandler := csrfMW(http.HandlerFunc(
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev) m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie // GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc( getHandler := csrfMW(http.HandlerFunc(
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool var called bool
handler := m.CSRF()(http.HandlerFunc( handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) { func(_ http.ResponseWriter, _ *http.Request) {
called = true called = true
}, },
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
t.Helper() t.Helper()
m, _ := testMiddleware(t, env) m, _ := testMiddleware(t, env)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
newReq := func(method string) *http.Request { newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext( r := httptest.NewRequestWithContext(
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF() csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
+3 -1
View File
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
) http.Handler { ) http.Handler {
t.Helper() t.Helper()
return m.CSRF()(unreachable(t)) return m.CSRF(http.HandlerFunc(forbidden))(
unreachable(t),
)
}, },
send: postNoToken, send: postNoToken,
wantStatus: http.StatusForbidden, wantStatus: http.StatusForbidden,
+34 -2
View File
@@ -109,7 +109,8 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// Recoverer returns middleware that turns a handler panic into one // Recoverer returns middleware that turns a handler panic into one
// structured ERROR record and a 500, rather than a dropped // 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 // 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 // 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 // set before panicking, because a request that failed must not hand
// the client a credential; every other header is left to http.Error. // the client a credential; every other header is left to http.Error.
// See https://git.eeqj.de/sneak/webhooker/issues/193. // 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
w http.ResponseWriter, w http.ResponseWriter,
@@ -171,6 +174,14 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
rw.Header().Del("Set-Cookie") rw.Header().Del("Set-Cookie")
if page != nil {
s.servePage(rw, r, page)
}
if rw.committed {
return
}
http.Error( http.Error(
rw, rw,
http.StatusText( 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 // logPanic writes the record. Every field it can grow is truncated to
// a fixed budget, so MaxPanicLogLineBytes holds. // 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. // Logging outside so the recovered 500 is the status it records.
router.Use(chimw.RequestID) router.Use(chimw.RequestID)
router.Use(m.Logging()) router.Use(m.Logging())
router.Use(m.Recoverer()) router.Use(m.Recoverer(nil))
router.Get("/probe", handler) router.Get("/probe", handler)
serverErrors := new(bytes.Buffer) serverErrors := new(bytes.Buffer)
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
m, _ := capturingMiddleware(t) m, _ := capturingMiddleware(t)
handler := m.Recoverer()(http.HandlerFunc( handler := m.Recoverer(nil)(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("chunk")) _, _ = w.Write([]byte("chunk"))
@@ -672,3 +672,59 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "chunk", string(body)) 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)
}
+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" "net/http"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
@@ -101,3 +102,39 @@ func NewRouterWithProbeForTest(
return s.router 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
}
+42 -12
View File
@@ -16,8 +16,8 @@ import (
// submission while preventing abuse from oversized payloads. // submission while preventing abuse from oversized payloads.
// //
// Every route group below installs MaxBodySize(maxFormBodySize) as // Every route group below installs MaxBodySize(maxFormBodySize) as
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both // its first middleware after the recoverer, ahead of both CSRF and
// orderings are deliberate. // RequireAuth. Both orderings are deliberate.
// //
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to // Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs // be installed before anything reads the body, or the parse runs
@@ -46,6 +46,14 @@ const requestTimeout = 60 * time.Second
// server's router. // server's router.
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() 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.setupGlobalMiddleware()
s.setupRoutes() s.setupRoutes()
} }
@@ -69,23 +77,33 @@ func (s *Server) setupGlobalMiddleware() {
// Panic recovery, deliberately here rather than first. It has to // Panic recovery, deliberately here rather than first. It has to
// run inside every middleware that observes the response, so the // run inside every middleware that observes the response, so the
// 500 it writes is the status the access log records and 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 // Repanic option needs something further out to catch what it
// re-raises. chi's own middleware.Recoverer held the first slot // re-raises. chi's own middleware.Recoverer held the first slot
// until it was measured: on a current Go release it crashes // until it was measured: on a current Go release it crashes
// inside its stack pretty-printer instead of recovering, so the // inside its stack pretty-printer instead of recovering, so the
// connection dropped and the original panic was never reported. // connection dropped and the original panic was never reported.
// See https://git.eeqj.de/sneak/webhooker/issues/187. // 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() { if s.sentryEnabled.Load() {
sentryHandler := sentryhttp.New(sentryhttp.Options{ sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true, Repanic: true,
}) })
s.router.Use(sentryHandler.Handle) r.Use(sentryHandler.Handle)
} }
} }
@@ -147,10 +165,13 @@ func (s *Server) setupRoutes() {
func (s *Server) setupPageRoutes() { func (s *Server) setupPageRoutes() {
s.router.Route("/pages", func(r chi.Router) { s.router.Route("/pages", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) 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.NoCache())
// The login POST carries no pre-emptive rate limiter. Behind // The login POST carries no pre-emptive rate limiter. Behind
@@ -169,10 +190,13 @@ func (s *Server) setupPageRoutes() {
func (s *Server) setupUserRoutes() { func (s *Server) setupUserRoutes() {
s.router.Route("/user/{username}", func(r chi.Router) { s.router.Route("/user/{username}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) 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.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile()) r.Get("/", s.h.HandleProfile())
@@ -184,10 +208,13 @@ func (s *Server) setupUserRoutes() {
func (s *Server) setupSourceRoutes() { func (s *Server) setupSourceRoutes() {
s.router.Route("/sources", func(r chi.Router) { s.router.Route("/sources", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) 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.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList()) r.Get("/", s.h.HandleSourceList())
@@ -196,10 +223,13 @@ func (s *Server) setupSourceRoutes() {
}) })
s.router.Route("/source/{sourceID}", func(r chi.Router) { s.router.Route("/source/{sourceID}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) 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.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail()) r.Get("/", s.h.HandleSourceDetail())
+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> </svg>
{{.User.Username}} {{.User.Username}}
</a> </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"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-text">Logout</button>
</form> </form>
{{end}}
{{else}} {{else}}
<a href="/pages/login" class="btn-primary">Login</a> <a href="/pages/login" class="btn-primary">Login</a>
{{end}} {{end}}
@@ -40,10 +44,12 @@
{{if .User}} {{if .User}}
<a href="/sources" class="btn-text w-full text-left">Webhooks</a> <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> <a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button> <button type="submit" class="btn-text w-full text-left">Logout</button>
</form> </form>
{{end}}
{{else}} {{else}}
<a href="/pages/login" class="btn-primary w-full">Login</a> <a href="/pages/login" class="btn-primary w-full">Login</a>
{{end}} {{end}}