check / check (push) Successful in 3m31s
A database target's rotation (none, monthly, daily or hourly) puts the UTC period of each event's receive time in its archive file name, so each file holds exactly its period's events. It is on the new webhook page, the add target form and the target edit form, and shown in the target list. Renames move every one of a target's files and move them back if one fails. The sweep prunes every file, one at a time under the target's lock, and deletes a rotated file it leaves empty. Download lists the files, then opens one at a time, oldest first, finding each again under the target's current names, and gives each row its period. The target list names the current file and totals the size of all of them. Model: opus-5-5
435 lines
12 KiB
Go
435 lines
12 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"
|
|
"sync/atomic"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"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
|
|
Config *config.Config
|
|
Database *database.Database
|
|
WebhookDBMgr *database.WebhookDBManager
|
|
Healthcheck *healthcheck.Healthcheck
|
|
Session *session.Session
|
|
Middleware *middleware.Middleware
|
|
Notifier delivery.Notifier
|
|
Archives delivery.Archives
|
|
CircuitBreakers delivery.CircuitBreakers
|
|
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
|
|
archives delivery.Archives
|
|
breakers delivery.CircuitBreakers
|
|
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
|
|
|
|
// renameMu makes the webhook edit, the target edit and target
|
|
// creation run one at a time, each held from loading the stored
|
|
// names through the archive rename, the save and any move back.
|
|
// Interleaved, one could rename an archive between another's
|
|
// rename and save, leaving the file named for one edit and the
|
|
// stored names from the other. An archive download holds it while
|
|
// it reads the stored names and lists the files they give, and
|
|
// again for each file while it finds the file under the names
|
|
// stored then and opens it.
|
|
renameMu sync.Mutex
|
|
|
|
// 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, navbar and notice templates, and with any further
|
|
// files the page includes. The set is named after the page file, so
|
|
// the page's root action ({{template "base" .}}) is its entry point.
|
|
//
|
|
// The page file is parsed last because a later definition of a name
|
|
// replaces an earlier one: the page's {{define "title"}} must replace
|
|
// the {{block "title"}} fallback in htmlheader.html.
|
|
func parsePageTemplate(
|
|
pageFile string, included ...string,
|
|
) *template.Template {
|
|
files := append([]string{
|
|
"base.html",
|
|
"htmlheader.html",
|
|
"navbar.html",
|
|
"notice.html",
|
|
}, included...)
|
|
files = append(files, pageFile)
|
|
|
|
return template.Must(
|
|
template.New(pageFile).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.archives = params.Archives
|
|
s.breakers = params.CircuitBreakers
|
|
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"),
|
|
"settings.html": parsePageTemplate("settings.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", "event_body.html",
|
|
),
|
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
|
"source_logs.html": parsePageTemplate(
|
|
"source_logs.html", "event_body.html", "delivery_attempts.html",
|
|
),
|
|
"event_detail.html": parsePageTemplate(
|
|
"event_detail.html", "event_body.html", "delivery_attempts.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")
|
|
|
|
// No notice: one would say an action worked above a page saying
|
|
// the request failed.
|
|
data := s.pageData(r, map[string]any{
|
|
"Status": status,
|
|
"StatusText": http.StatusText(status),
|
|
"Message": errorPageText(status),
|
|
}, nil)
|
|
|
|
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
|
|
Notice *notice
|
|
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 and answers 200.
|
|
func (s *Handlers) renderTemplate(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
pageTemplate string,
|
|
data any,
|
|
) {
|
|
s.renderTemplateStatus(w, r, pageTemplate, data, http.StatusOK)
|
|
}
|
|
|
|
// renderTemplateStatus is renderTemplate answering with status, for a
|
|
// form shown again with an error. Call it instead of WriteHeader
|
|
// followed by renderTemplate: the status is written only once the page
|
|
// has rendered, so a failed render can still answer 500.
|
|
func (s *Handlers) renderTemplateStatus(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
pageTemplate string,
|
|
data any,
|
|
status int,
|
|
) {
|
|
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, noticeFor(r)), status,
|
|
)
|
|
}
|
|
|
|
// pageData adds the fields the shared layout renders to a page's own
|
|
// data. The layout shows the notice, when there is one, above the
|
|
// page.
|
|
func (s *Handlers) pageData(
|
|
r *http.Request, data any, pageNotice *notice,
|
|
) 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
|
|
m["Notice"] = pageNotice
|
|
|
|
return m
|
|
}
|
|
|
|
return templateDataWrapper{
|
|
User: userInfo,
|
|
CSRFToken: csrfToken,
|
|
Version: version,
|
|
Notice: pageNotice,
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
// executeTemplate renders the template into a buffer and writes status
|
|
// and the page to the response only once rendering has fully
|
|
// succeeded. Executing straight into the ResponseWriter commits a
|
|
// partial body and the 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 lists of events cap each stored body at
|
|
// maxRenderedBodyBytes for exactly this reason.
|
|
func (s *Handlers) executeTemplate(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
tmpl *template.Template,
|
|
data any,
|
|
status int,
|
|
) {
|
|
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")
|
|
w.WriteHeader(status)
|
|
|
|
_, err = buf.WriteTo(w)
|
|
if err != nil {
|
|
s.log.Error(
|
|
"failed to write rendered page", "error", err,
|
|
)
|
|
}
|
|
}
|