refactor: use pinned golangci-lint Docker image for linting (#55)
All checks were successful
check / check (push) Successful in 5s
All checks were successful
check / check (push) Successful in 5s
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>
This commit was merged in pull request #55.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
// 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"
|
||||
@@ -18,9 +21,24 @@ import (
|
||||
"sneak.berlin/go/webhooker/templates"
|
||||
)
|
||||
|
||||
// nolint:revive // HandlersParams is a standard fx naming convention
|
||||
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
|
||||
@@ -30,6 +48,8 @@ type HandlersParams struct {
|
||||
Notifier delivery.Notifier
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handler methods for all application
|
||||
// routes.
|
||||
type Handlers struct {
|
||||
params *HandlersParams
|
||||
log *slog.Logger
|
||||
@@ -41,19 +61,29 @@ type Handlers struct {
|
||||
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. If a shared partial (e.g. htmlheader.html)
|
||||
// is listed first, its {{define}} block becomes the root — which is empty — and
|
||||
// Execute() produces no output.
|
||||
// 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"),
|
||||
template.ParseFS(
|
||||
templates.Templates,
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||
// 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()
|
||||
@@ -75,17 +105,23 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
OnStart: func(_ context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
//nolint:unparam // r parameter will be used in the future for request context
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
||||
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 {
|
||||
@@ -94,9 +130,15 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data inte
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unparam,unused // will be used for handling JSON requests
|
||||
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
// 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
|
||||
@@ -105,48 +147,66 @@ type UserInfo struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
// renderTemplate renders a pre-parsed template with common data
|
||||
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
|
||||
// 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)
|
||||
s.log.Error(
|
||||
"template not found",
|
||||
"template", pageTemplate,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from session if available
|
||||
var userInfo *UserInfo
|
||||
sess, err := s.session.Get(r)
|
||||
if err == nil && s.session.IsAuthenticated(sess) {
|
||||
if username, ok := s.session.GetUsername(sess); ok {
|
||||
if userID, ok := s.session.GetUserID(sess); ok {
|
||||
userInfo = &UserInfo{
|
||||
ID: userID,
|
||||
Username: username,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get CSRF token from request context (set by CSRF middleware)
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
// If data is a map, merge user info and CSRF token into it
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
if err := tmpl.Execute(w, m); err != nil {
|
||||
s.log.Error("failed to execute template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
|
||||
// Wrap data with base template data
|
||||
type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Data interface{}
|
||||
return
|
||||
}
|
||||
|
||||
wrapper := templateDataWrapper{
|
||||
@@ -155,8 +215,23 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTe
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, wrapper); err != nil {
|
||||
s.log.Error("failed to execute template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user