Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
+191
-66
@@ -12,7 +12,10 @@ import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
@@ -27,15 +30,17 @@ import (
|
||||
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
|
||||
// recentEventLimit is the number of most recent events that a
|
||||
// webhook's page and its event log show.
|
||||
recentEventLimit = 50
|
||||
|
||||
// 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.
|
||||
@@ -51,16 +56,20 @@ var errVerificationBusy = errors.New(
|
||||
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
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
WebhookDBMgr *database.WebhookDBManager
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Session *session.Session
|
||||
Middleware *middleware.Middleware
|
||||
Engine *delivery.Engine
|
||||
Archives archive.Archives
|
||||
CircuitBreakers delivery.CircuitBreakers
|
||||
SSRFGuard *delivery.Guard
|
||||
Metrics *metrics.Set
|
||||
Registry *prometheus.Registry
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handler methods for all application
|
||||
@@ -73,8 +82,9 @@ type Handlers struct {
|
||||
dbMgr *database.WebhookDBManager
|
||||
session *session.Session
|
||||
mw *middleware.Middleware
|
||||
notifier delivery.Notifier
|
||||
evictor delivery.WebhookEvictor
|
||||
engine *delivery.Engine
|
||||
archives archive.Archives
|
||||
breakers delivery.CircuitBreakers
|
||||
mtr *metrics.Set
|
||||
templates map[string]*template.Template
|
||||
|
||||
@@ -91,18 +101,26 @@ type Handlers struct {
|
||||
|
||||
// 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 {
|
||||
// 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.ParseFS(
|
||||
templates.Templates,
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
),
|
||||
template.New(pageFile).ParseFS(templates.Templates, files...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,21 +138,33 @@ func New(
|
||||
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.engine = params.Engine
|
||||
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"),
|
||||
"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"),
|
||||
"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_request.html", "event_body.html",
|
||||
"delivery_row.html", "delivery_attempts.html",
|
||||
),
|
||||
"event_detail.html": parsePageTemplate(
|
||||
"event_detail.html", "event_request.html", "event_body.html",
|
||||
"delivery_row.html", "delivery_attempts.html",
|
||||
),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
"error.html": parsePageTemplate("error.html"),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
@@ -146,6 +176,16 @@ func New(
|
||||
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,
|
||||
@@ -163,15 +203,78 @@ func (s *Handlers) respondJSON(
|
||||
}
|
||||
}
|
||||
|
||||
// serverError logs an error and sends a 500 response.
|
||||
// serverError logs an error and answers with the 500 error page.
|
||||
func (s *Handlers) serverError(
|
||||
w http.ResponseWriter, msg string, err error,
|
||||
w http.ResponseWriter, r *http.Request, msg string, err error,
|
||||
) {
|
||||
s.log.Error(msg, "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
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
|
||||
@@ -185,6 +288,7 @@ type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Version string
|
||||
Notice *notice
|
||||
Data any
|
||||
}
|
||||
|
||||
@@ -211,12 +315,26 @@ func (s *Handlers) getUserInfo(
|
||||
}
|
||||
|
||||
// renderTemplate renders a pre-parsed template with common
|
||||
// data
|
||||
// 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 {
|
||||
@@ -224,14 +342,22 @@ func (s *Handlers) renderTemplate(
|
||||
"template not found",
|
||||
"template", pageTemplate,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -245,33 +371,34 @@ func (s *Handlers) renderTemplate(
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
m["Version"] = version
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
m["Notice"] = pageNotice
|
||||
|
||||
return
|
||||
return m
|
||||
}
|
||||
|
||||
wrapper := templateDataWrapper{
|
||||
return templateDataWrapper{
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Version: version,
|
||||
Notice: pageNotice,
|
||||
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.
|
||||
// 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
|
||||
|
||||
@@ -280,15 +407,13 @@ func (s *Handlers) executeTemplate(
|
||||
s.log.Error(
|
||||
"failed to execute template", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user