Files
webhooker/internal/handlers/handlers.go
T
clawbot ef8e8cf398
check / check (push) Successful in 4m2s
Serve /metrics from a registry of its own (closes #227)
The HTTP metrics recorder, the delivery collectors and the Go and
process collectors now register on one prometheus.Registry that fx
provides, instead of Prometheus's global default registry, and
/metrics serves that registry. A second metrics-enabled router in one
process, or the server tests run with -count=2, no longer panics on a
duplicate registration.

The middleware builds its recorder once, in New, so installing
Metrics() on more than one router over the same graph is also safe.
The scrape keeps the same series and labels, including go_*,
process_* and promhttp_metric_handler_*.

Model: opus-5-5
2026-09-29 09:20:29 +00:00

303 lines
7.7 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"
"github.com/prometheus/client_golang/prometheus"
"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 = 20
// 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"
)
// 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
Metrics *metrics.Set
Registry *prometheus.Registry
}
// 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. 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) *template.Template {
return template.Must(
template.ParseFS(
templates.Templates,
pageFile,
"base.html",
"htmlheader.html",
"navbar.html",
),
)
}
// 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 = &params
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 = params.Metrics
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"),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"),
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
return nil
},
})
return s, nil
}
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 sends a 500 response.
func (s *Handlers) serverError(
w http.ResponseWriter, msg string, err error,
) {
s.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// 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,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
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
s.executeTemplate(w, tmpl, m)
return
}
wrapper := templateDataWrapper{
User: userInfo,
CSRFToken: csrfToken,
Version: version,
Data: data,
}
s.executeTemplate(w, tmpl, wrapper)
}
// 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,
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,
)
http.Error(
w, "Internal server error",
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,
)
}
}