All checks were successful
check / check (push) Successful in 3m10s
/metrics carried only the inbound HTTP surface, so a destination failing for an hour, a growing retry backlog and a stuck-open circuit breaker were all invisible: the receive side stays healthy in each case because it is. New internal/metrics registers, on the existing default registry that the go-http-metrics recorder and the promhttp handler already share: - webhooker_events_received_total - webhooker_delivery_attempts_total - webhooker_deliveries_succeeded_total - webhooker_deliveries_failed_total - webhooker_delivery_retries_total - webhooker_delivery_duration_seconds - webhooker_deliveries_pending / _retrying - webhooker_circuit_breakers_open The route mounting is untouched. Every delivery metric carries one label, target_type, whose domain is the four target-type constants; anything outside it collapses to "unknown" so no series can be minted from a UUID. Target ids, event ids and entrypoint ids are deliberately not labels. An attempt is counted, and its duration observed, only where one was actually dispatched — the target's own result path, which is also where the DeliveryResult is written. A delivery an open circuit breaker refuses sends nothing and records no result row; counting it would climb the attempts counter with no traffic behind it and pull the duration quantiles down for as long as the breaker stayed open, moving the metric the wrong way during the outage it exists to reveal. The log and database targets now time their own work, so their result rows carry a real duration too. The outcome counters move after the status row is written rather than before, so a transition the database rejected is never reported as an outcome that happened. The queue-depth gauges are counted out of the per-webhook databases by a 30s sampler rather than tracked as deltas, which would need seeding at startup and would drift on any transition that failed to persist. They publish an "unknown" series from registration: deliveries queued against a target that has since been deleted resolve to the empty type and are folded there, because a backlog behind a deleted target is precisely the one nobody is watching. The open-breaker gauge is recounted from the target's breaker registry on every state change. The orphaned-retry terminal path takes the target type as an argument rather than attaching the loaded target to the delivery. That path loads the delivery without its target relation on purpose: a populated Delivery.Target makes GORM's SaveBeforeAssociations upsert the whole target row on the status UPDATE, writing the plaintext target config — the credential, for a slack target — into the per-webhook events database. A test asserts that path leaves the targets table empty.
283 lines
6.9 KiB
Go
283 lines
6.9 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 = 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
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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 = ¶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()
|
|
|
|
// 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"),
|
|
}
|
|
|
|
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
|
|
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)
|
|
|
|
if m, ok := data.(map[string]any); ok {
|
|
m["User"] = userInfo
|
|
m["CSRFToken"] = csrfToken
|
|
s.executeTemplate(w, tmpl, m)
|
|
|
|
return
|
|
}
|
|
|
|
wrapper := templateDataWrapper{
|
|
User: userInfo,
|
|
CSRFToken: csrfToken,
|
|
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,
|
|
)
|
|
}
|
|
}
|