// Package handlers provides HTTP request handlers for the // webhooker web UI and API. package handlers import ( "context" "encoding/json" "errors" "html/template" "log/slog" "net/http" "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/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 // defaultRetentionDays is the default event retention period. defaultRetentionDays = 30 // paginationPerPage is the number of items per page. paginationPerPage = 25 ) // errInvalidPassword is returned when a password does not match. var errInvalidPassword = errors.New("invalid password") //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 Notifier delivery.Notifier } // 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 notifier delivery.Notifier templates map[string]*template.Template } // 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.notifier = params.Notifier // 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 runs the template and handles errors. func (s *Handlers) executeTemplate( w http.ResponseWriter, tmpl *template.Template, data any, ) { err := tmpl.Execute(w, data) if err != nil { s.log.Error( "failed to execute template", "error", err, ) http.Error( w, "Internal server error", http.StatusInternalServerError, ) } }