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 fails to render, the answer is the same status in plain text; if it panics, the answer is a 500. Model: opus-5-5
377 lines
10 KiB
Go
377 lines
10 KiB
Go
// Package handlers provides HTTP request handlers for the
|
|
// webhooker web UI and API.
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync/atomic"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"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"
|
|
"sneak.berlin/go/webhooker/templates"
|
|
)
|
|
|
|
const (
|
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
|
maxBodyShift = 20
|
|
// recentEventLimit is the number of recent events to show.
|
|
recentEventLimit = 50
|
|
// paginationPerPage is the number of items per page.
|
|
paginationPerPage = 25
|
|
|
|
// tmplKeyError is the template data key for an error message.
|
|
tmplKeyError = "Error"
|
|
// tmplKeyWebhook is the template data key for a webhook.
|
|
tmplKeyWebhook = "Webhook"
|
|
// tmplKeyNext is the template data key for the page to return
|
|
// to after login.
|
|
tmplKeyNext = "Next"
|
|
)
|
|
|
|
// errInvalidPassword is returned when a password does not match.
|
|
var errInvalidPassword = errors.New("invalid password")
|
|
|
|
// errVerificationBusy is returned when no password-verification slot
|
|
// became free before the wait elapsed, so no password was verified.
|
|
var errVerificationBusy = errors.New(
|
|
"password verification capacity exhausted",
|
|
)
|
|
|
|
//nolint:revive // HandlersParams is a standard fx naming convention.
|
|
type HandlersParams struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Database *database.Database
|
|
WebhookDBMgr *database.WebhookDBManager
|
|
Healthcheck *healthcheck.Healthcheck
|
|
Session *session.Session
|
|
Middleware *middleware.Middleware
|
|
Notifier delivery.Notifier
|
|
Evictor delivery.WebhookEvictor
|
|
SSRFGuard *delivery.Guard
|
|
}
|
|
|
|
// Handlers provides HTTP handler methods for all application
|
|
// routes.
|
|
type Handlers struct {
|
|
params *HandlersParams
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
session *session.Session
|
|
mw *middleware.Middleware
|
|
notifier delivery.Notifier
|
|
evictor delivery.WebhookEvictor
|
|
mtr *metrics.Set
|
|
templates map[string]*template.Template
|
|
|
|
// ssrf validates submitted target URLs. It is the same guard
|
|
// the delivery engine dials through, so a URL accepted here
|
|
// is one delivery will actually attempt.
|
|
ssrf *delivery.Guard
|
|
|
|
// dummyVerifications counts the equivalent-cost verifications
|
|
// charged for usernames that do not exist. It exists so a test
|
|
// can prove that path runs without measuring wall-clock time.
|
|
dummyVerifications atomic.Uint64
|
|
}
|
|
|
|
// parsePageTemplate parses a page-specific template set from the
|
|
// embedded FS. Each page template is combined with the shared
|
|
// base, htmlheader, and navbar templates, and with any further files
|
|
// the page includes. The page file must be listed first so that its
|
|
// root action ({{template "base" .}}) becomes the template set's entry
|
|
// point.
|
|
func parsePageTemplate(
|
|
pageFile string, included ...string,
|
|
) *template.Template {
|
|
files := append([]string{
|
|
pageFile,
|
|
"base.html",
|
|
"htmlheader.html",
|
|
"navbar.html",
|
|
}, included...)
|
|
|
|
return template.Must(
|
|
template.ParseFS(templates.Templates, files...),
|
|
)
|
|
}
|
|
|
|
// New creates a Handlers instance, parsing all page templates at
|
|
// startup.
|
|
func New(
|
|
lc fx.Lifecycle,
|
|
params HandlersParams,
|
|
) (*Handlers, error) {
|
|
s := new(Handlers)
|
|
s.params = ¶ms
|
|
s.log = params.Logger.Get()
|
|
s.hc = params.Healthcheck
|
|
s.db = params.Database
|
|
s.dbMgr = params.WebhookDBMgr
|
|
s.session = params.Session
|
|
s.mw = params.Middleware
|
|
s.notifier = params.Notifier
|
|
s.evictor = params.Evictor
|
|
s.mtr = metrics.Default()
|
|
s.ssrf = params.SSRFGuard
|
|
|
|
// Parse all page templates once at startup
|
|
s.templates = map[string]*template.Template{
|
|
"login.html": parsePageTemplate("login.html"),
|
|
"profile.html": parsePageTemplate("profile.html"),
|
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
|
"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{
|
|
OnStart: func(_ context.Context) error {
|
|
return 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, the
|
|
// CSRF middleware for a refused form, and each admin page route
|
|
// group's recoverer for a panic.
|
|
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,
|
|
data any,
|
|
status int,
|
|
) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
|
|
if data != nil {
|
|
err := json.NewEncoder(w).Encode(data)
|
|
if err != nil {
|
|
s.log.Error("json encode error", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverError logs an error and answers with the 500 error page.
|
|
func (s *Handlers) serverError(
|
|
w http.ResponseWriter, r *http.Request, msg string, err error,
|
|
) {
|
|
s.log.Error(msg, "error", err)
|
|
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
|
|
type UserInfo struct {
|
|
ID string
|
|
Username string
|
|
}
|
|
|
|
// templateDataWrapper wraps non-map data with common fields.
|
|
type templateDataWrapper struct {
|
|
User *UserInfo
|
|
CSRFToken string
|
|
Version string
|
|
Data any
|
|
}
|
|
|
|
// getUserInfo extracts user info from the session.
|
|
func (s *Handlers) getUserInfo(
|
|
r *http.Request,
|
|
) *UserInfo {
|
|
sess, err := s.session.Get(r)
|
|
if err != nil || !s.session.IsAuthenticated(sess) {
|
|
return nil
|
|
}
|
|
|
|
username, ok := s.session.GetUsername(sess)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
userID, ok := s.session.GetUserID(sess)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return &UserInfo{ID: userID, Username: username}
|
|
}
|
|
|
|
// renderTemplate renders a pre-parsed template with common
|
|
// data
|
|
func (s *Handlers) renderTemplate(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
pageTemplate string,
|
|
data any,
|
|
) {
|
|
tmpl, ok := s.templates[pageTemplate]
|
|
if !ok {
|
|
s.log.Error(
|
|
"template not found",
|
|
"template", pageTemplate,
|
|
)
|
|
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)
|
|
|
|
// The footer in base.html renders .Version. Every page reaches it
|
|
// through here, so this is the one place that has to supply it;
|
|
// left unset, the footer falls back to its literal "dev" and the
|
|
// UI reports a build that is not the one running.
|
|
version := s.params.Globals.Version
|
|
|
|
if m, ok := data.(map[string]any); ok {
|
|
m["User"] = userInfo
|
|
m["CSRFToken"] = csrfToken
|
|
m["Version"] = version
|
|
|
|
return m
|
|
}
|
|
|
|
return templateDataWrapper{
|
|
User: userInfo,
|
|
CSRFToken: csrfToken,
|
|
Version: version,
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
// executeTemplate renders the template into a buffer and writes to
|
|
// the response only once rendering has fully succeeded. Executing
|
|
// straight into the ResponseWriter commits a partial body and a 200
|
|
// status before a mid-render error can be reported, leaving no way
|
|
// to serve a 500. Buffering makes a page's rendered size resident
|
|
// memory per concurrent viewer, so every page owes it a bound: the
|
|
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
|
// this reason.
|
|
func (s *Handlers) executeTemplate(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
tmpl *template.Template,
|
|
data any,
|
|
) {
|
|
var buf bytes.Buffer
|
|
|
|
err := tmpl.Execute(&buf, data)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"failed to execute template", "error", err,
|
|
)
|
|
s.renderError(w, r, http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
_, err = buf.WriteTo(w)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"failed to write rendered page", "error", err,
|
|
)
|
|
}
|
|
}
|