// 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 = 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" ) // 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 } // 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 = ¶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() 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"), "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 and // the CSRF middleware for a refused form. 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") data := s.pageData(r, map[string]any{ "Status": status, "StatusText": http.StatusText(status), "Message": errorPageText(status), }) 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 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)) } // pageData adds the fields the shared layout renders to a page's own // data. func (s *Handlers) pageData(r *http.Request, data any) 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 return m } return templateDataWrapper{ User: userInfo, CSRFToken: csrfToken, Version: version, 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, ) } }