check / check (push) Waiting to run
Saving, deleting, activating or deactivating a webhook, entrypoint or target, and signing out, now land on their page with a one-line notice such as "Webhook deleted." or "Signed out.". The redirect carries a fixed code that maps to fixed text; an unknown code shows nothing, so nothing from the URL is ever echoed. One partial in the page layout shows the notice on every page, and replay and resubmit now use the same codes and partial. Error pages show no notice. Model: opus-5-5
389 lines
11 KiB
Go
389 lines
11 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 = 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
|
|
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, navbar and notice 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",
|
|
"notice.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 = 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", "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")
|
|
|
|
// 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
|
|
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, noticeFor(r)))
|
|
}
|
|
|
|
// 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 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,
|
|
)
|
|
}
|
|
}
|