Files
webhooker/internal/handlers/handlers.go
clawbot afe88c601a
All checks were successful
check / check (push) Successful in 5s
refactor: use pinned golangci-lint Docker image for linting (#55)
Closes [issue #50](#50)

## Summary

Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage.

## Changes

### Dockerfile
- **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage
- **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes
- **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256)
- **golangci-lint bumped** from v1.64.8 to v2.11.3
- All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest
- Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t)

### Linter Config (.golangci.yml)
- Migrated from v1 to v2 format (`version: "2"` added)
- Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2)
- Same set of linters enabled — no rules weakened

### Code Fixes (all lint issues from v2 upgrade)
- Added package comments to all packages
- Added doc comments to all exported types, functions, and methods
- Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint)
- Fixed unused parameters flagged by `revive` (renamed to `_`)
- Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls
- Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf`
- Fixed `staticcheck` QF1003: converted if/else chain to tagged switch
- Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`)
- Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt`
- Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores)

### README.md
- Updated version requirements: Go 1.26+, golangci-lint v2.11+
- Updated Dockerfile description in project structure

## Verification

`docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed.

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #55
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 02:16:38 +01:00

238 lines
5.5 KiB
Go

// 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 = &params
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,
)
}
}