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:
@@ -13,11 +13,12 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||
sess, err := h.session.Get(r)
|
||||
if err == nil && h.session.IsAuthenticated(sess) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Render login page
|
||||
data := map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"Error": "",
|
||||
}
|
||||
|
||||
@@ -28,10 +29,15 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||
// HandleLoginSubmit handles the login form submission (POST)
|
||||
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Limit request body to prevent memory exhaustion
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
h.log.Error("failed to parse form", "error", err)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,85 +46,159 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
|
||||
// Validate input
|
||||
if username == "" || password == "" {
|
||||
data := map[string]interface{}{
|
||||
"Error": "Username and password are required",
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
h.renderLoginError(
|
||||
w, r,
|
||||
"Username and password are required",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Find user in database
|
||||
var user database.User
|
||||
if err := h.db.DB().Where("username = ?", username).First(&user).Error; err != nil {
|
||||
h.log.Debug("user not found", "username", username)
|
||||
data := map[string]interface{}{
|
||||
"Error": "Invalid username or password",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
valid, err := database.VerifyPassword(password, user.Password)
|
||||
user, err := h.authenticateUser(
|
||||
w, r, username, password,
|
||||
)
|
||||
if err != nil {
|
||||
h.log.Error("failed to verify password", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
h.log.Debug("invalid password", "username", username)
|
||||
data := map[string]interface{}{
|
||||
"Error": "Invalid username or password",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current session (may be pre-existing / attacker-set)
|
||||
oldSess, err := h.session.Get(r)
|
||||
err = h.createAuthenticatedSession(w, r, user)
|
||||
if err != nil {
|
||||
h.log.Error("failed to get session", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Regenerate the session to prevent session fixation attacks.
|
||||
// This destroys the old session ID and creates a new one.
|
||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||
if err != nil {
|
||||
h.log.Error("failed to regenerate session", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set user in session
|
||||
h.session.SetUser(sess, user.ID, user.Username)
|
||||
|
||||
// Save session
|
||||
if err := h.session.Save(r, w, sess); err != nil {
|
||||
h.log.Error("failed to save session", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.log.Info("user logged in", "username", username, "user_id", user.ID)
|
||||
h.log.Info(
|
||||
"user logged in",
|
||||
"username", username,
|
||||
"user_id", user.ID,
|
||||
)
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// renderLoginError renders the login page with an error message.
|
||||
func (h *Handlers) renderLoginError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
msg string,
|
||||
status int,
|
||||
) {
|
||||
data := map[string]any{
|
||||
"Error": msg,
|
||||
}
|
||||
|
||||
w.WriteHeader(status)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
}
|
||||
|
||||
// authenticateUser looks up and verifies a user's credentials.
|
||||
// On failure it writes an HTTP response and returns an error.
|
||||
func (h *Handlers) authenticateUser(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
username, password string,
|
||||
) (database.User, error) {
|
||||
var user database.User
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"username = ?", username,
|
||||
).First(&user).Error
|
||||
if err != nil {
|
||||
h.log.Debug("user not found", "username", username)
|
||||
h.renderLoginError(
|
||||
w, r,
|
||||
"Invalid username or password",
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
valid, err := database.VerifyPassword(password, user.Password)
|
||||
if err != nil {
|
||||
h.log.Error("failed to verify password", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
if !valid {
|
||||
h.log.Debug("invalid password", "username", username)
|
||||
h.renderLoginError(
|
||||
w, r,
|
||||
"Invalid username or password",
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return user, errInvalidPassword
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// createAuthenticatedSession regenerates the session and stores
|
||||
// user info. On failure it writes an HTTP response and returns
|
||||
// an error.
|
||||
func (h *Handlers) createAuthenticatedSession(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
user database.User,
|
||||
) error {
|
||||
oldSess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.log.Error("failed to get session", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to regenerate session", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
h.session.SetUser(sess, user.ID, user.Username)
|
||||
|
||||
err = h.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
h.log.Error("failed to save session", "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleLogout handles user logout
|
||||
func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.log.Error("failed to get session", "error", err)
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,8 +206,12 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
h.session.Destroy(sess)
|
||||
|
||||
// Save the destroyed session
|
||||
if err := h.session.Save(r, w, sess); err != nil {
|
||||
h.log.Error("failed to save destroyed session", "error", err)
|
||||
err = h.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to save destroyed session",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
|
||||
14
internal/handlers/export_test.go
Normal file
14
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// RenderTemplateForTest exposes renderTemplate for use in the
|
||||
// handlers_test package.
|
||||
func (s *Handlers) RenderTemplateForTest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
pageTemplate string,
|
||||
data any,
|
||||
) {
|
||||
s.renderTemplate(w, r, pageTemplate, data)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
package handlers
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// noopNotifier is a no-op delivery.Notifier for tests.
|
||||
type noopNotifier struct{}
|
||||
|
||||
func (n *noopNotifier) Notify([]delivery.DeliveryTask) {}
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
func TestHandleIndex(t *testing.T) {
|
||||
var h *Handlers
|
||||
var sess *session.Session
|
||||
func newTestApp(
|
||||
t *testing.T,
|
||||
targets ...any,
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
app := fxtest.New(
|
||||
return fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
@@ -40,90 +44,99 @@ func TestHandleIndex(t *testing.T) {
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier { return &noopNotifier{} },
|
||||
New,
|
||||
func() delivery.Notifier {
|
||||
return &noopNotifier{}
|
||||
},
|
||||
handlers.New,
|
||||
),
|
||||
fx.Populate(&h, &sess),
|
||||
fx.Populate(targets...),
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleIndex_Unauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
defer app.RequireStop()
|
||||
|
||||
t.Run("unauthenticated redirects to login", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
handler := h.HandleIndex()
|
||||
handler.ServeHTTP(w, req)
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
})
|
||||
handler := h.HandleIndex()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
t.Run("authenticated redirects to sources", func(t *testing.T) {
|
||||
// Create a request, set up an authenticated session, then test
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login", w.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
// Get a session and mark it as authenticated
|
||||
s, err := sess.Get(req)
|
||||
assert.NoError(t, err)
|
||||
sess.SetUser(s, "test-user-id", "testuser")
|
||||
err = sess.Save(req, w, s)
|
||||
assert.NoError(t, err)
|
||||
func TestHandleIndex_Authenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Build a new request with the session cookie from the response
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
for _, cookie := range w.Result().Cookies() {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
w2 := httptest.NewRecorder()
|
||||
var h *handlers.Handlers
|
||||
|
||||
handler := h.HandleIndex()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
var sess *session.Session
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||
assert.Equal(t, "/sources", w2.Header().Get("Location"))
|
||||
})
|
||||
app := newTestApp(t, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s, err := sess.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
sess.SetUser(s, "test-user-id", "testuser")
|
||||
|
||||
err = sess.Save(req, w, s)
|
||||
require.NoError(t, err)
|
||||
|
||||
req2 := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
for _, cookie := range w.Result().Cookies() {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
h.HandleIndex().ServeHTTP(w2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||
assert.Equal(
|
||||
t, "/sources", w2.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestRenderTemplate(t *testing.T) {
|
||||
var h *Handlers
|
||||
t.Parallel()
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config {
|
||||
return &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
},
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier { return &noopNotifier{} },
|
||||
New,
|
||||
),
|
||||
fx.Populate(&h),
|
||||
)
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
defer app.RequireStop()
|
||||
|
||||
t.Run("handles missing templates gracefully", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Version": "1.0.0",
|
||||
}
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// When a non-existent template name is requested, renderTemplate
|
||||
// should return an internal server error
|
||||
h.renderTemplate(w, req, "nonexistent.html", data)
|
||||
data := map[string]any{"Version": "1.0.0"}
|
||||
|
||||
// Should return internal server error when template is not found
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
h.RenderTemplateForTest(
|
||||
w, req, "nonexistent.html", data,
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const httpStatusOK = 200
|
||||
|
||||
// HandleHealthCheck returns an HTTP handler that reports
|
||||
// application health.
|
||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
resp := s.hc.Healthcheck()
|
||||
s.respondJSON(w, req, resp, 200)
|
||||
s.respondJSON(w, req, resp, httpStatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleIndex returns a handler for the root path that redirects based
|
||||
// on authentication state: authenticated users go to /sources (the
|
||||
// dashboard), unauthenticated users go to the login page.
|
||||
// HandleIndex returns a handler for the root path that redirects
|
||||
// based on authentication state: authenticated users go to /sources
|
||||
// (the dashboard), unauthenticated users go to the login page.
|
||||
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.session.Get(r)
|
||||
if err == nil && s.session.IsAuthenticated(sess) {
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
requestedUsername := chi.URLParam(r, "username")
|
||||
if requestedUsername == "" {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
if err != nil || !h.session.IsAuthenticated(sess) {
|
||||
// Redirect to login if not authenticated
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing username")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,17 +39,19 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing user ID")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For now, only allow users to view their own profile
|
||||
if requestedUsername != sessionUsername {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare data for template
|
||||
data := map[string]interface{}{
|
||||
data := map[string]any{
|
||||
"User": &UserInfo{
|
||||
ID: sessionUserID,
|
||||
Username: sessionUsername,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,31 +6,36 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
|
||||
maxWebhookBodySize = 1 << 20
|
||||
// maxWebhookBodySize is the maximum allowed webhook
|
||||
// request body (1 MB).
|
||||
maxWebhookBodySize = 1 << maxBodyShift
|
||||
)
|
||||
|
||||
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
|
||||
// Only POST requests are accepted; all other methods return 405 Method Not Allowed.
|
||||
// Events and deliveries are stored in the per-webhook database. The handler
|
||||
// builds self-contained DeliveryTask structs with all target and event data
|
||||
// so the delivery engine can process them without additional DB reads.
|
||||
// HandleWebhook handles incoming webhook requests at entrypoint
|
||||
// URLs.
|
||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
http.Error(
|
||||
w,
|
||||
"Method Not Allowed",
|
||||
http.StatusMethodNotAllowed,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entrypointUUID := chi.URLParam(r, "uuid")
|
||||
if entrypointUUID == "" {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,152 +45,302 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
|
||||
// Look up entrypoint by path (from main application DB)
|
||||
var entrypoint database.Entrypoint
|
||||
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint)
|
||||
if result.Error != nil {
|
||||
h.log.Debug("entrypoint not found", "path", entrypointUUID)
|
||||
http.NotFound(w, r)
|
||||
entrypoint, ok := h.lookupEntrypoint(
|
||||
w, r, entrypointUUID,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if active
|
||||
if !entrypoint.Active {
|
||||
http.Error(w, "Gone", http.StatusGone)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Read body with size limit
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||
if err != nil {
|
||||
h.log.Error("failed to read request body", "error", err)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > maxWebhookBodySize {
|
||||
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize headers as JSON
|
||||
headersJSON, err := json.Marshal(r.Header)
|
||||
if err != nil {
|
||||
h.log.Error("failed to serialize headers", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Find all active targets for this webhook (from main application DB)
|
||||
var targets []database.Target
|
||||
if targetErr := h.db.DB().Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; targetErr != nil {
|
||||
h.log.Error("failed to query targets", "error", targetErr)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the per-webhook database for event storage
|
||||
webhookDB, err := h.dbMgr.GetDB(entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
h.log.Error("failed to get webhook database",
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create the event and deliveries in a transaction on the per-webhook DB
|
||||
tx := webhookDB.Begin()
|
||||
if tx.Error != nil {
|
||||
h.log.Error("failed to begin transaction", "error", tx.Error)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: entrypoint.WebhookID,
|
||||
EntrypointID: entrypoint.ID,
|
||||
Method: r.Method,
|
||||
Headers: string(headersJSON),
|
||||
Body: string(body),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
}
|
||||
|
||||
if err := tx.Create(event).Error; err != nil {
|
||||
tx.Rollback()
|
||||
h.log.Error("failed to create event", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare body pointer for inline transport (≤16KB bodies are
|
||||
// included in the DeliveryTask so the engine needs no DB read).
|
||||
var bodyPtr *string
|
||||
if len(body) < delivery.MaxInlineBodySize {
|
||||
bodyStr := string(body)
|
||||
bodyPtr = &bodyStr
|
||||
}
|
||||
|
||||
// Create delivery records and build self-contained delivery tasks
|
||||
tasks := make([]delivery.DeliveryTask, 0, len(targets))
|
||||
for i := range targets {
|
||||
dlv := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targets[i].ID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
}
|
||||
if err := tx.Create(dlv).Error; err != nil {
|
||||
tx.Rollback()
|
||||
h.log.Error("failed to create delivery",
|
||||
"target_id", targets[i].ID,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tasks = append(tasks, delivery.DeliveryTask{
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: entrypoint.WebhookID,
|
||||
TargetID: targets[i].ID,
|
||||
TargetName: targets[i].Name,
|
||||
TargetType: targets[i].Type,
|
||||
TargetConfig: targets[i].Config,
|
||||
MaxRetries: targets[i].MaxRetries,
|
||||
Method: event.Method,
|
||||
Headers: event.Headers,
|
||||
ContentType: event.ContentType,
|
||||
Body: bodyPtr,
|
||||
AttemptNum: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
h.log.Error("failed to commit transaction", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Notify the delivery engine with self-contained delivery tasks.
|
||||
// Each task carries all target config and event data inline so
|
||||
// the engine can deliver without touching any database (in the
|
||||
// ≤16KB happy path). The engine only writes to the DB to record
|
||||
// delivery results after each attempt.
|
||||
if len(tasks) > 0 {
|
||||
h.notifier.Notify(tasks)
|
||||
}
|
||||
|
||||
h.log.Info("webhook event created",
|
||||
"event_id", event.ID,
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"entrypoint_id", entrypoint.ID,
|
||||
"target_count", len(targets),
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
|
||||
h.log.Error("failed to write response", "error", err)
|
||||
}
|
||||
h.processWebhookRequest(w, r, entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
// processWebhookRequest reads the body, serializes headers,
|
||||
// loads targets, and delivers the event.
|
||||
func (h *Handlers) processWebhookRequest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypoint database.Entrypoint,
|
||||
) {
|
||||
body, ok := h.readWebhookBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
headersJSON, err := json.Marshal(r.Header)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to serialize headers", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to query targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.createAndDeliverEvent(
|
||||
w, r, entrypoint, body, headersJSON, targets,
|
||||
)
|
||||
}
|
||||
|
||||
// loadActiveTargets returns all active targets for a webhook.
|
||||
func (h *Handlers) loadActiveTargets(
|
||||
webhookID string,
|
||||
) ([]database.Target, error) {
|
||||
var targets []database.Target
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"webhook_id = ? AND active = ?",
|
||||
webhookID, true,
|
||||
).Find(&targets).Error
|
||||
|
||||
return targets, err
|
||||
}
|
||||
|
||||
// lookupEntrypoint finds an entrypoint by UUID path.
|
||||
func (h *Handlers) lookupEntrypoint(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypointUUID string,
|
||||
) (database.Entrypoint, bool) {
|
||||
var entrypoint database.Entrypoint
|
||||
|
||||
result := h.db.DB().Where(
|
||||
"path = ?", entrypointUUID,
|
||||
).First(&entrypoint)
|
||||
if result.Error != nil {
|
||||
h.log.Debug(
|
||||
"entrypoint not found",
|
||||
"path", entrypointUUID,
|
||||
)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return entrypoint, false
|
||||
}
|
||||
|
||||
return entrypoint, true
|
||||
}
|
||||
|
||||
// readWebhookBody reads and validates the request body size.
|
||||
func (h *Handlers) readWebhookBody(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) ([]byte, bool) {
|
||||
body, err := io.ReadAll(
|
||||
io.LimitReader(r.Body, maxWebhookBodySize+1),
|
||||
)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to read request body", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if len(body) > maxWebhookBodySize {
|
||||
http.Error(
|
||||
w,
|
||||
"Request body too large",
|
||||
http.StatusRequestEntityTooLarge,
|
||||
)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return body, true
|
||||
}
|
||||
|
||||
// createAndDeliverEvent creates the event and delivery records
|
||||
// then notifies the delivery engine.
|
||||
func (h *Handlers) createAndDeliverEvent(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypoint database.Entrypoint,
|
||||
body, headersJSON []byte,
|
||||
targets []database.Target,
|
||||
) {
|
||||
tx, err := h.beginWebhookTx(w, entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := h.buildEvent(r, entrypoint, headersJSON, body)
|
||||
|
||||
err = tx.Create(event).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
h.serverError(w, "failed to create event", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bodyPtr := inlineBody(body)
|
||||
|
||||
tasks := h.buildDeliveryTasks(
|
||||
w, tx, event, entrypoint, targets, bodyPtr,
|
||||
)
|
||||
if tasks == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to commit transaction", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||
}
|
||||
|
||||
// beginWebhookTx opens a transaction on the per-webhook DB.
|
||||
func (h *Handlers) beginWebhookTx(
|
||||
w http.ResponseWriter,
|
||||
webhookID string,
|
||||
) (*gorm.DB, error) {
|
||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to get webhook database", err,
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx := webhookDB.Begin()
|
||||
if tx.Error != nil {
|
||||
h.serverError(
|
||||
w, "failed to begin transaction", tx.Error,
|
||||
)
|
||||
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// inlineBody returns a pointer to body as a string if it fits
|
||||
// within the inline size limit, or nil otherwise.
|
||||
func inlineBody(body []byte) *string {
|
||||
if len(body) < delivery.MaxInlineBodySize {
|
||||
s := string(body)
|
||||
|
||||
return &s
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishWebhookResponse notifies the delivery engine, logs the
|
||||
// event, and writes the HTTP response.
|
||||
func (h *Handlers) finishWebhookResponse(
|
||||
w http.ResponseWriter,
|
||||
event *database.Event,
|
||||
entrypoint database.Entrypoint,
|
||||
tasks []delivery.Task,
|
||||
) {
|
||||
if len(tasks) > 0 {
|
||||
h.notifier.Notify(tasks)
|
||||
}
|
||||
|
||||
h.log.Info("webhook event created",
|
||||
"event_id", event.ID,
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"entrypoint_id", entrypoint.ID,
|
||||
"target_count", len(tasks),
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := w.Write([]byte(`{"status":"ok"}`))
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to write response", "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// buildEvent creates a new Event struct from request data.
|
||||
func (h *Handlers) buildEvent(
|
||||
r *http.Request,
|
||||
entrypoint database.Entrypoint,
|
||||
headersJSON, body []byte,
|
||||
) *database.Event {
|
||||
return &database.Event{
|
||||
WebhookID: entrypoint.WebhookID,
|
||||
EntrypointID: entrypoint.ID,
|
||||
Method: r.Method,
|
||||
Headers: string(headersJSON),
|
||||
Body: string(body),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeliveryTasks creates delivery records in the
|
||||
// transaction and returns tasks for the delivery engine.
|
||||
// Returns nil if an error occurred.
|
||||
func (h *Handlers) buildDeliveryTasks(
|
||||
w http.ResponseWriter,
|
||||
tx *gorm.DB,
|
||||
event *database.Event,
|
||||
entrypoint database.Entrypoint,
|
||||
targets []database.Target,
|
||||
bodyPtr *string,
|
||||
) []delivery.Task {
|
||||
tasks := make([]delivery.Task, 0, len(targets))
|
||||
|
||||
for i := range targets {
|
||||
dlv := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targets[i].ID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
}
|
||||
|
||||
err := tx.Create(dlv).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
h.log.Error(
|
||||
"failed to create delivery",
|
||||
"target_id", targets[i].ID,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks = append(tasks, delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: entrypoint.WebhookID,
|
||||
TargetID: targets[i].ID,
|
||||
TargetName: targets[i].Name,
|
||||
TargetType: targets[i].Type,
|
||||
TargetConfig: targets[i].Config,
|
||||
MaxRetries: targets[i].MaxRetries,
|
||||
Method: event.Method,
|
||||
Headers: event.Headers,
|
||||
ContentType: event.ContentType,
|
||||
Body: bodyPtr,
|
||||
AttemptNum: 1,
|
||||
})
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user