All checks were successful
check / check (push) Successful in 1m37s
Refactor Dockerfile to use a separate lint stage with a pinned golangci-lint v2.11.3 Docker image instead of installing golangci-lint via curl in the builder stage. This follows the pattern used by sneak/pixa. Changes: - Dockerfile: separate lint stage using golangci/golangci-lint:v2.11.3 (Debian-based, pinned by sha256) with COPY --from=lint dependency - Bump Go from 1.24 to 1.26.1 (golang:1.26.1-bookworm, pinned) - Bump golangci-lint from v1.64.8 to v2.11.3 - Migrate .golangci.yml from v1 to v2 format (same linters, format only) - All Docker images pinned by sha256 digest - Fix all lint issues from the v2 linter upgrade: - Add package comments to all packages - Add doc comments to all exported types, functions, and methods - Fix unchecked errors (errcheck) - Fix unused parameters (revive) - Fix gosec warnings (MaxBytesReader for form parsing) - Fix staticcheck suggestions (fmt.Fprintf instead of WriteString) - Rename DeliveryTask to Task to avoid stutter (delivery.Task) - Rename shadowed builtin 'max' parameter - Update README.md version requirements
347 lines
7.0 KiB
Go
347 lines
7.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"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 << maxBodyShift
|
|
)
|
|
|
|
// 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,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
entrypointUUID := chi.URLParam(r, "uuid")
|
|
if entrypointUUID == "" {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
h.log.Info("webhook request received",
|
|
"entrypoint_uuid", entrypointUUID,
|
|
"method", r.Method,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
|
|
entrypoint, ok := h.lookupEntrypoint(
|
|
w, r, entrypointUUID,
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if !entrypoint.Active {
|
|
http.Error(w, "Gone", http.StatusGone)
|
|
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|