Files
webhooker/internal/handlers/source_management.go
clawbot be9e13eea9
All checks were successful
check / check (push) Successful in 3m9s
Serve an event's full stored body over HTTP (closes #157)
Capping the event log page at 8 KB of body per event left no
in-app way to see a larger one: storage keeps it, but no route
served it, so a payload over the cap was reachable only by an
operator with filesystem access. GitHub pull_request and
multi-commit push payloads, expanded Stripe events and Shopify
orders all routinely clear 8 KB, which is exactly when the tool
is supposed to be useful.

GET /source/{sourceID}/logs/{eventID}/body now serves one whole
body, and the truncation marker links to it when — and only
when — a body was actually cut.

The response is deliberately inert. Its bytes are chosen by
whoever can reach the public receiver and it hands them back
inside the operator's own authenticated origin, so it goes out
as application/octet-stream with Content-Disposition: attachment
and nosniff, and the filename is built from a parsed uuid rather
than from anything in the request. The application CSP is no
help on this path: script-src allows 'unsafe-inline' from
'self', so a document served from this origin could run its own
script.

It is also bounded. database/sql exposes no incremental handle
on a SQLite blob, so scanning the column would materialise the
whole body whatever wraps it; instead the body is read in 64 KiB
ranges with substr over a blob cast and each range is written
straight to the ResponseWriter, never through renderTemplate.
Peak resident body bytes is the chunk, not the payload, which is
the memory profile the render cap was introduced to protect.

The ownership check the log page applies is extracted as
ownedWebhook and shared with the download, so the two cannot
drift apart. A webhook owned by someone else and one that does
not exist are the same 404.
2026-08-17 21:13:21 +00:00

1426 lines
32 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// WebhookListItem holds data for the webhook list view.
type WebhookListItem struct {
database.Webhook
EntrypointCount int64
TargetCount int64
EventCount int64
}
// errMissingURL signals that a required URL was not provided.
var errMissingURL = errors.New("missing URL")
// errInvalidRetention signals a retention_days form value that is not
// a non-negative whole number.
var errInvalidRetention = errors.New("invalid retention days")
// errRetentionTooLarge signals a retention_days form value that is a
// whole number but larger than the reaper's cutoff arithmetic can
// represent. It is distinguished from errInvalidRetention so the form
// can tell the user the actual ceiling instead of implying their input
// was not a number.
var errRetentionTooLarge = errors.New("retention days out of range")
// retentionErrorMessage returns the message the create and edit forms
// show the user for a rejected retention_days value. Any error other
// than errRetentionTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400 rather than
// an empty alert.
func retentionErrorMessage(err error) string {
if errors.Is(err, errRetentionTooLarge) {
return "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
}
return "Retention must be a whole number of days, or 0 to " +
"retain events forever."
}
// parseRetentionDays interprets a retention_days form value.
//
// An empty value yields fallback, which lets the create path apply the
// default and the edit path leave the stored value unchanged. A value
// of 0 is returned as 0 and is rewritten to the retain-forever
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
// or negative is an error rather than a silently substituted default.
//
// The upper bound is not cosmetic. The reaper computes its cutoff as a
// time.Duration, an int64 nanosecond count, so a day count above
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
// future, and deletes every event the webhook has. A finite value
// above that ceiling is therefore a 400.
//
// A value at or above the retain-forever sentinel is not out of range:
// it is what the edit form pre-fills for a retain-forever webhook, so
// submitting the form back unchanged has to keep meaning "forever"
// rather than being rejected.
func parseRetentionDays(raw string, fallback int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, errInvalidRetention
}
if v >= database.RetentionForeverDays {
return database.RetentionForeverDays, nil
}
if v > database.MaxFiniteRetentionDays {
return 0, errRetentionTooLarge
}
return v, nil
}
// DeliveryView is the display-safe projection of a delivery
// for the event log page. Its target is a TargetView, so the
// stored configuration blob — which holds the target's
// credential — has no path to the template.
type DeliveryView struct {
ID string
Status database.DeliveryStatus
Target delivery.TargetView
}
// HandleSourceList shows a list of user's webhooks.
func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
var webhooks []database.Webhook
err := h.db.DB().Where(
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
items := h.buildWebhookListItems(webhooks)
data := map[string]any{
"Webhooks": items,
}
h.renderTemplate(w, r, "sources_list.html", data)
}
}
// buildWebhookListItems builds list items with counts.
func (h *Handlers) buildWebhookListItems(
webhooks []database.Webhook,
) []WebhookListItem {
items := make([]WebhookListItem, len(webhooks))
for i := range webhooks {
items[i].Webhook = webhooks[i]
h.db.DB().Model(&database.Entrypoint{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].EntrypointCount)
h.db.DB().Model(&database.Target{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].TargetCount)
if h.dbMgr.DBExists(webhooks[i].ID) {
webhookDB, err := h.dbMgr.GetDB(
webhooks[i].ID,
)
if err == nil {
webhookDB.Model(
&database.Event{},
).Count(&items[i].EventCount)
}
}
}
return items
}
// HandleSourceCreate shows the form to create a new webhook.
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData("", "", ""),
)
}
}
// newSourceFormData builds the template data for the webhook creation
// form.
//
// It carries the retention default so the pre-filled value comes from
// database.DefaultRetentionDays rather than being a third hardcoded
// copy of the same policy, and it carries the submitted name and
// description so that re-rendering the form after a validation failure
// gives the user their input back instead of a blank form. The edit
// form already behaves that way; create now matches it.
func newSourceFormData(
errMsg, name, description string,
) map[string]any {
return map[string]any{
tmplKeyError: errMsg,
"Name": name,
"Description": description,
"DefaultRetentionDays": database.DefaultRetentionDays,
}
}
// HandleSourceCreateSubmit handles the webhook creation form
// submission.
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
name := r.FormValue("name")
description := r.FormValue("description")
retentionStr := r.FormValue("retention_days")
if name == "" {
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData(
"Name is required", name, description,
),
)
return
}
retentionDays, retErr := parseRetentionDays(
retentionStr, database.DefaultRetentionDays,
)
if retErr != nil {
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData(
retentionErrorMessage(retErr),
name, description,
),
)
return
}
h.createWebhookWithEntrypoint(
w, r, userID, name, description, retentionDays,
)
}
}
// createWebhookWithEntrypoint creates a webhook and its default
// entrypoint in a transaction.
func (h *Handlers) createWebhookWithEntrypoint(
w http.ResponseWriter,
r *http.Request,
userID, name, description string,
retentionDays int,
) {
webhook := &database.Webhook{
UserID: userID,
Name: name,
Description: description,
RetentionDays: retentionDays,
}
err := h.commitWebhook(webhook)
if err != nil {
h.serverError(w, "failed to create webhook", err)
return
}
err = h.dbMgr.CreateDB(webhook.ID)
if err != nil {
h.log.Error(
"failed to create webhook event database",
"webhook_id", webhook.ID, "error", err,
)
}
h.log.Info("webhook created",
"webhook_id", webhook.ID,
"name", name, "user_id", userID,
)
http.Redirect(
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
// commitWebhook creates a webhook and default entrypoint in
// a transaction. Returns an error on failure (rolls back).
func (h *Handlers) commitWebhook(
webhook *database.Webhook,
) error {
tx := h.db.DB().Begin()
if tx.Error != nil {
return tx.Error
}
err := tx.Create(webhook).Error
if err != nil {
tx.Rollback()
return err
}
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: "Default entrypoint",
Active: true,
}
err = tx.Create(entrypoint).Error
if err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// HandleSourceDetail shows details for a specific webhook.
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
h.renderSourceDetail(w, r, webhook)
}
}
// renderSourceDetail loads and renders a source detail page.
func (h *Handlers) renderSourceDetail(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
var entrypoints []database.Entrypoint
h.db.DB().Where(
"webhook_id = ?", webhook.ID,
).Find(&entrypoints)
var targets []database.Target
h.db.DB().Where(
"webhook_id = ?", webhook.ID,
).Find(&targets)
var events []database.Event
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
if dbErr == nil {
webhookDB.Where(
"webhook_id = ?", webhook.ID,
).Order("created_at DESC").Limit(
recentEventLimit,
).Find(&events)
}
}
host := r.Host
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
scheme = fwdProto
}
// The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a map.
data := map[string]any{
tmplKeyWebhook: &webhook,
"Entrypoints": entrypoints,
// Targets are projected to a display-safe view: the
// stored config blob holds credentials and must never
// reach a template.
"Targets": delivery.NewTargetViews(targets),
"Events": events,
"BaseURL": scheme + "://" + host,
}
h.renderTemplate(w, r, "source_detail.html", data)
}
// HandleSourceEdit shows the form to edit a webhook.
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
data := map[string]any{
tmplKeyWebhook: &webhook,
tmplKeyError: "",
}
h.renderTemplate(w, r, "source_edit.html", data)
}
}
// HandleSourceEditSubmit handles the webhook edit form
// submission.
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
h.applyWebhookEdit(w, r, &webhook)
}
}
// applyWebhookEdit validates and saves webhook edits.
func (h *Handlers) applyWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
) {
// The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
name := r.FormValue("name")
if name == "" {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
webhook.Name = name
webhook.Description = r.FormValue("description")
// An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone.
retentionDays, retErr := parseRetentionDays(
r.FormValue("retention_days"), webhook.RetentionDays,
)
if retErr != nil {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: retentionErrorMessage(retErr),
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
webhook.RetentionDays = retentionDays
err := h.db.DB().Save(webhook).Error
if err != nil {
h.serverError(w, "failed to update webhook", err)
return
}
http.Redirect(
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
// HandleSourceDelete handles webhook deletion.
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
h.deleteWebhookResources(w, r, webhook, userID)
}
}
// deleteWebhookResources soft-deletes config and hard-deletes
// the per-webhook event database.
func (h *Handlers) deleteWebhookResources(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
userID string,
) {
tx := h.db.DB().Begin()
if tx.Error != nil {
h.log.Error(
"failed to begin transaction",
"error", tx.Error,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
tx.Where(
"webhook_id = ?", webhook.ID,
).Delete(&database.Entrypoint{})
tx.Where(
"webhook_id = ?", webhook.ID,
).Delete(&database.Target{})
tx.Delete(&webhook)
err := tx.Commit().Error
if err != nil {
h.log.Error(
"failed to commit deletion", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
// Release the delivery engine's per-webhook archiving state
// so a deleted webhook's archive writer (and any handle open
// within its debounce window) does not linger for the
// process lifetime. The archive file itself is deliberately
// left on disk; see evictArchiveWriter.
h.evictArchiveWriter(webhook.ID)
err = h.dbMgr.DeleteDB(webhook.ID)
if err != nil {
h.log.Error(
"failed to delete webhook event database",
"webhook_id", webhook.ID,
"error", err,
)
}
h.log.Info(
"webhook deleted",
"webhook_id", webhook.ID,
"user_id", userID,
)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
}
// evictArchiveWriter asks the delivery engine to drop its
// cached archive writer for a webhook, closing the archive file
// handle.
//
// The archive database file is NOT deleted. Unlike the event
// database — which is per-webhook working storage and is
// hard-deleted with the webhook — an archive is explicitly
// long-term storage that an operator may want to keep or move
// away for offline retention. Destroying it as a side effect of
// deleting a webhook would be a surprising and unrecoverable
// data loss, so the file is left for the operator to handle.
func (h *Handlers) evictArchiveWriter(webhookID string) {
if h.evictor == nil {
return
}
h.evictor.EvictWebhook(webhookID)
}
// evictArchiveWriterIfUnused releases a webhook's archive
// writer once the webhook has no database target left to feed
// it.
//
// It is called after any child resource of a webhook is
// deleted, and is correct without knowing which kind was: it
// evicts only when no database target remains, so deleting one
// of several database targets — or deleting an unrelated
// target type — leaves a still-needed writer alone. When no
// database target ever existed there is no writer and eviction
// is a no-op. Soft-deleted targets are excluded by GORM's
// default scope, so the row just deleted is not counted.
func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
var remaining int64
err := h.db.DB().
Model(&database.Target{}).
Where(
"webhook_id = ? AND type = ?",
webhookID, database.TargetTypeDatabase,
).
Count(&remaining).Error
if err != nil {
h.log.Error(
"failed to count remaining database targets",
"webhook_id", webhookID,
"error", err,
)
return
}
if remaining > 0 {
return
}
h.evictArchiveWriter(webhookID)
}
// ownedWebhook resolves the request's sourceID parameter to a
// webhook the session's user owns.
//
// Ownership and existence are decided by one query, so a
// webhook belonging to another user is indistinguishable from
// one that does not exist: both are a 404, and neither confirms
// the id. Callers that reach further into a webhook's data —
// the event log page and the event body download — share this
// one check rather than restating it, so the download cannot
// come to authorize differently from the page that links to it.
//
// It reports false once it has written the response, which is a
// redirect to the login page for an unauthenticated request and
// a 404 otherwise. The caller returns without writing more.
func (h *Handlers) ownedWebhook(
w http.ResponseWriter,
r *http.Request,
) (database.Webhook, bool) {
var webhook database.Webhook
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return database.Webhook{}, false
}
sourceID := chi.URLParam(r, "sourceID")
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return database.Webhook{}, false
}
return webhook, true
}
// HandleSourceLogs shows the request/response logs for a
// webhook.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
targets := h.loadTargetMap(webhook.ID)
page := h.parsePage(r)
evts, total := h.loadEventsWithDeliveries(
w, webhook, targets, page,
)
totalPages := int(total) / paginationPerPage
if int(total)%paginationPerPage != 0 {
totalPages++
}
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.renderTemplate(w, r, "source_logs.html", data)
}
}
// loadTargetMap loads targets into a map of display-safe
// views keyed by target ID. The projection happens here so
// that no caller can hand a raw target, configuration blob
// and all, to a template.
func (h *Handlers) loadTargetMap(
webhookID string,
) map[string]delivery.TargetView {
var targets []database.Target
h.db.DB().Where(
"webhook_id = ?", webhookID,
).Find(&targets)
views := delivery.NewTargetViews(targets)
targetMap := make(
map[string]delivery.TargetView, len(views),
)
for _, v := range views {
targetMap[v.ID] = v
}
return targetMap
}
// parsePage extracts a page number from the query string.
func (h *Handlers) parsePage(r *http.Request) int {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
v, err := strconv.Atoi(p)
if err == nil && v > 0 {
page = v
}
}
return page
}
// loadEventsWithDeliveries loads paginated events and their
// deliveries from the per-webhook database. Events come back
// as capped projections rather than database.Event rows: see
// eventLogColumns for why the cut happens in SQL.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
webhook database.Webhook,
targetMap map[string]delivery.TargetView,
page int,
) ([]EventLogView, int64) {
var totalEvents int64
var result []EventLogView
if !h.dbMgr.DBExists(webhook.ID) {
return result, totalEvents
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, "failed to get webhook database", err,
)
return nil, 0
}
webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhook.ID,
).Count(&totalEvents)
offset := (page - 1) * paginationPerPage
var rows []eventLogRow
webhookDB.Model(&database.Event{}).Select(
eventLogColumns, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhook.ID,
).Order("created_at DESC").Offset(offset).Limit(
paginationPerPage,
).Find(&rows)
result = make([]EventLogView, len(rows))
for i := range rows {
result[i] = rows[i].view()
var deliveries []database.Delivery
webhookDB.Where(
"event_id = ?", rows[i].ID,
).Find(&deliveries)
result[i].Deliveries = newDeliveryViews(
deliveries, targetMap,
)
}
return result, totalEvents
}
// newDeliveryViews projects deliveries for rendering,
// resolving each one's target to its display-safe view.
func newDeliveryViews(
deliveries []database.Delivery,
targetMap map[string]delivery.TargetView,
) []DeliveryView {
views := make([]DeliveryView, len(deliveries))
for i := range deliveries {
views[i] = DeliveryView{
ID: deliveries[i].ID,
Status: deliveries[i].Status,
Target: targetMap[deliveries[i].TargetID],
}
}
return views
}
// HandleEntrypointCreate handles adding a new entrypoint.
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
description := r.FormValue("description")
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: description,
Active: true,
}
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, "failed to create entrypoint", err)
return
}
http.Redirect(
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
}
// HandleTargetCreate handles adding a new target to a webhook.
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return
}
h.processTargetCreate(w, r, webhook)
}
}
// processTargetCreate validates and creates a new target.
func (h *Handlers) processTargetCreate(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
// The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
name := r.FormValue("name")
targetType := database.TargetType(r.FormValue("type"))
targetURL := r.FormValue("url")
maxRetriesStr := r.FormValue("max_retries")
expiry := r.FormValue("expiry")
if name == "" {
http.Error(
w, "Name is required", http.StatusBadRequest,
)
return
}
if !isValidTargetType(targetType) {
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return
}
configJSON, err := h.buildTargetConfig(
w, r, targetType, targetURL, expiry,
)
if err != nil {
return
}
maxRetries := parseNonNegativeInt(maxRetriesStr)
target := &database.Target{
WebhookID: webhook.ID,
Name: name,
Type: targetType,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, "failed to create target", err)
return
}
http.Redirect(
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
)
}
// isValidTargetType checks whether the target type is supported.
func isValidTargetType(tt database.TargetType) bool {
switch tt {
case database.TargetTypeHTTP,
database.TargetTypeDatabase,
database.TargetTypeLog,
database.TargetTypeSlack:
return true
default:
return false
}
}
// parseNonNegativeInt parses s as a non-negative integer,
// returning 0 if s is empty or invalid.
func parseNonNegativeInt(s string) int {
if s == "" {
return 0
}
v, err := strconv.Atoi(s)
if err == nil && v >= 0 {
return v
}
return 0
}
// buildTargetConfig builds the JSON config string for a target.
// The expiry form value is read by the caller (which bounds the
// request body) and applies to database targets only.
func (h *Handlers) buildTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetType database.TargetType,
targetURL, expiry string,
) (string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildURLTargetConfig(
w, r, targetURL, "url",
"URL is required for HTTP targets",
)
case database.TargetTypeSlack:
return h.buildURLTargetConfig(
w, r, targetURL, "webhookUrl",
"Webhook URL is required for Slack targets",
)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, expiry)
case database.TargetTypeLog:
return "", nil
default:
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return "", errMissingURL
}
}
// buildURLTargetConfig builds config JSON for a target whose
// configuration is a single SSRF-validated URL stored under
// configKey. missingMsg is the error shown when no URL is given.
func (h *Handlers) buildURLTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetURL, configKey, missingMsg string,
) (string, error) {
if targetURL == "" {
http.Error(
w,
missingMsg,
http.StatusBadRequest,
)
return "", errMissingURL
}
err := delivery.ValidateTargetURL(
r.Context(), targetURL,
)
if err != nil {
// The submitted URL can be a credential (a Slack
// incoming webhook URL is a bearer token), so the log
// records only its scheme and host.
h.log.Warn(
"target URL blocked by SSRF protection",
"url", delivery.MaskURL(targetURL),
"error", err,
)
http.Error(
w,
"Invalid target URL: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
cfg := map[string]any{configKey: targetURL}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err
}
return string(configBytes), nil
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry (a form value read by
// the caller, which bounds the request body) is validated here,
// at creation time, so an unparseable value is rejected with a
// 400 instead of failing every subsequent delivery. An empty
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry)
if expiry == "" {
return "", nil
}
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
http.Error(
w,
"Invalid archive expiry: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
cfg := map[string]any{"expiry": expiry}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err
}
return string(configBytes), nil
}
// HandleEntrypointDelete handles deleting an entrypoint.
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
return h.deleteChildResource(
"entrypointID", &database.Entrypoint{},
"failed to delete entrypoint",
nil,
)
}
// HandleTargetDelete handles deleting a target. Deleting the
// last database target of a webhook leaves its archive writer
// with nothing to write, so the writer is evicted and its
// handle closed; the archive file is left on disk.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
h.evictArchiveWriterIfUnused,
)
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the webhook's id once the
// delete has succeeded, before the redirect.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(webhookID string),
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
childID := chi.URLParam(r, idParam)
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
result := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
if afterDelete != nil {
afterDelete(webhook.ID)
}
http.Redirect(
w, r,
"/source/"+webhook.ID,
http.StatusSeeOther,
)
}
}
// HandleEntrypointToggle handles toggling an entrypoint's
// active state.
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource(
"entrypointID",
func(webhookID, childID string) error {
var ep database.Entrypoint
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&ep).Error
if err != nil {
return err
}
ep.Active = !ep.Active
return h.db.DB().Save(&ep).Error
},
"failed to toggle entrypoint",
)
}
// HandleTargetToggle handles toggling a target's active state.
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return h.toggleChildResource(
"targetID",
func(webhookID, childID string) error {
var tgt database.Target
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&tgt).Error
if err != nil {
return err
}
tgt.Active = !tgt.Active
return h.db.DB().Save(&tgt).Error
},
"failed to toggle target",
)
}
// toggleChildResource returns a handler that toggles the active
// state of a child resource belonging to a webhook.
func (h *Handlers) toggleChildResource(
idParam string,
toggleFn func(webhookID, childID string) error,
errMsg string,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
childID := chi.URLParam(r, idParam)
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
return
}
err = toggleFn(webhook.ID, childID)
if err != nil {
h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
http.Redirect(
w, r,
"/source/"+webhook.ID,
http.StatusSeeOther,
)
}
}
// getUserID extracts the user ID from the session.
func (h *Handlers) getUserID(
r *http.Request,
) (string, bool) {
sess, err := h.session.Get(r)
if err != nil {
return "", false
}
if !h.session.IsAuthenticated(sess) {
return "", false
}
return h.session.GetUserID(sess)
}