Files
webhooker/internal/handlers/source_management.go
clawbot ee7c626071
All checks were successful
check / check (push) Successful in 4s
Implement the database archiving target (closes #43) (#84)
Implements the `databaseTarget` as a real archiving target, replacing the always-successful stub. Delivering to a `database` target now writes the full event into a per-webhook archive SQLite file for long-term storage.

## Archive-writer semantics

- **Separate file:** each webhook's full events are written as rows into `archive-{webhookID}.db` under the data dir, distinct from the per-webhook event DB (`events-{webhookID}.db`). The file and its schema are created on first write if missing. Each row carries the full event: body, headers, method, content type, webhook id, entrypoint id, event id, and an archived-at timestamp.
- **Close/reopen with debounce:** after each write the archive handle is closed and reopened, unless the last (re)open was less than one second ago. This lets an operator move the archive file away for offline archiving while bounding file churn under load. A per-webhook `archiveWriter` owns this debounce state and serialises writes.
- **Auto-recreate:** the file is opened create-if-missing (`mode=rwc`) and its schema re-migrated on every open, so if the archive was moved or removed since the last open, the next write recreates it. The writer also detects a missing file before writing and reopens first, so a moved-away file is recreated rather than lost.
- **Optional expiry, validated at creation:** an optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is validated when the target is created (`ValidateArchiveExpiry`; bad values are rejected with a 400 at the add-target form, the Slack URL precedent). The default (missing, empty, or `"never"`) keeps rows forever with no pruning. When a positive duration is set, rows older than it (measured from each row's archived-at time) are pruned on every (re)open; because the file is reopened after writes, prune-on-open keeps the archive swept without a separate background sweeper. A set-but-invalid expiry in a stored config (unparseable, zero, or negative) is an error at delivery time too — never a silent default.
- **No-retry, fail-loud:** the target performs a single attempt with no retries. On success it records one successful attempt and marks the delivery delivered. If the archive write fails, the attempt is recorded as failed with the error and the delivery is marked failed — archiving errors never report success.

## Scope

- `internal/delivery/target_database.go` — the `databaseTarget` (no-retry) archives via a per-webhook writer registry; an archive error records a failed attempt and marks the delivery failed.
- `internal/delivery/target_database_archive.go` (new) — the `archiveWriter`, the archived-row model, config/expiry parsing (fail-loud on set-but-invalid values), `ValidateArchiveExpiry`, and prune-on-open.
- `internal/handlers/source_management.go` — database targets get a creation-validated `expiry` config (`buildDatabaseTargetConfig`); the expiry form value is read where the request body is bounded and bad values are rejected with a 400 at target creation.
- `templates/source_detail.html` — the add-target form shows an expiry field for database targets.
- `README.md` — the database-target documentation describes the archiving semantics.
- `internal/delivery/export_test.go`, `internal/delivery/target_database_test.go`, `internal/handlers` tests — tests and their exported shims.

No changes to the `Target` interface or other targets.

## Tests

- a row is archived (both at the writer level and end-to-end through `Deliver`)
- a forced archive failure (bad stored expiry config) yields a `Failed` delivery with a non-success `DeliveryResult` carrying the error and no archive file created
- the file is recreated after removal, with only the post-removal row
- the one-second reopen debounce (rapid writes reopen once; a write after the window reopens again)
- expiry pruning removes rows older than the configured expiry
- expiry config parsing (empty / `never` / duration accepted; unparseable, zero, and negative values error)
- expiry validation at target creation (`TestValidateArchiveExpiry`; valid values build the config, bad values get a 400)

## Validation

`docker build .` exits 0 (fmt-check, lint, test, build all pass).

Closes #43

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #84
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 22:50:08 +02:00

1244 lines
24 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")
// EventWithDeliveries holds an event and its deliveries.
type EventWithDeliveries struct {
database.Event
Deliveries []database.Delivery
}
// 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) {
data := map[string]any{
"Error": "",
}
h.renderTemplate(w, r, "sources_new.html", data)
}
}
// 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
}
r.Body = http.MaxBytesReader(
w, r.Body, 1<<maxBodyShift,
)
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 == "" {
data := map[string]any{
"Error": "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "sources_new.html", data)
return
}
retentionDays := defaultRetentionDays
if retentionStr != "" {
v, convErr := strconv.Atoi(retentionStr)
if convErr == nil && v > 0 {
retentionDays = v
}
}
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
}
data := map[string]any{
"Webhook": webhook,
"Entrypoints": entrypoints,
"Targets": 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{
"Webhook": webhook,
"Error": "",
}
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
}
r.Body = http.MaxBytesReader(
w, r.Body, 1<<maxBodyShift,
)
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,
) {
r.Body = http.MaxBytesReader(
w, r.Body, 1<<maxBodyShift,
)
name := r.FormValue("name")
if name == "" {
data := map[string]any{
"Webhook": *webhook,
"Error": "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
webhook.Name = name
webhook.Description = r.FormValue("description")
h.parseRetention(r, webhook)
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,
)
}
// parseRetention parses and applies retention_days from the
// form.
func (h *Handlers) parseRetention(
r *http.Request,
webhook *database.Webhook,
) {
retStr := r.FormValue("retention_days")
if retStr == "" {
return
}
v, err := strconv.Atoi(retStr)
if err == nil && v > 0 {
webhook.RetentionDays = v
}
}
// 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
}
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)
}
// HandleSourceLogs shows the request/response logs for a
// webhook.
func (h *Handlers) HandleSourceLogs() 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
}
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{
"Webhook": 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 keyed by target ID.
func (h *Handlers) loadTargetMap(
webhookID string,
) map[string]database.Target {
var targets []database.Target
h.db.DB().Where(
"webhook_id = ?", webhookID,
).Find(&targets)
targetMap := make(
map[string]database.Target, len(targets),
)
for _, t := range targets {
targetMap[t.ID] = t
}
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.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
webhook database.Webhook,
targetMap map[string]database.Target,
page int,
) ([]EventWithDeliveries, int64) {
var totalEvents int64
var result []EventWithDeliveries
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 events []database.Event
webhookDB.Where(
"webhook_id = ?", webhook.ID,
).Order("created_at DESC").Offset(offset).Limit(
paginationPerPage,
).Find(&events)
result = make([]EventWithDeliveries, len(events))
for i := range events {
result[i].Event = events[i]
webhookDB.Where(
"event_id = ?", events[i].ID,
).Find(&result[i].Deliveries)
for j := range result[i].Deliveries {
tid := result[i].Deliveries[j].TargetID
if target, ok := targetMap[tid]; ok {
result[i].Deliveries[j].Target = target
}
}
}
return result, totalEvents
}
// 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
}
r.Body = http.MaxBytesReader(
w, r.Body, 1<<maxBodyShift,
)
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
}
r.Body = http.MaxBytesReader(
w, r.Body, 1<<maxBodyShift,
)
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,
) {
r.Body = http.MaxBytesReader(
w, r.Body, 1<<maxBodyShift,
)
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.buildHTTPTargetConfig(w, r, targetURL)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, targetURL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, expiry)
case database.TargetTypeLog:
return "", nil
default:
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return "", errMissingURL
}
}
// buildHTTPTargetConfig builds config JSON for an HTTP target.
func (h *Handlers) buildHTTPTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, error) {
if targetURL == "" {
http.Error(
w,
"URL is required for HTTP targets",
http.StatusBadRequest,
)
return "", errMissingURL
}
err := delivery.ValidateTargetURL(
r.Context(), targetURL,
)
if err != nil {
h.log.Warn(
"target URL blocked by SSRF protection",
"url", targetURL,
"error", err,
)
http.Error(
w,
"Invalid target URL: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
cfg := map[string]any{"url": targetURL}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err
}
return string(configBytes), nil
}
// buildSlackTargetConfig builds config JSON for a Slack target.
func (h *Handlers) buildSlackTargetConfig(
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, error) {
if targetURL == "" {
http.Error(
w,
"Webhook URL is required for Slack targets",
http.StatusBadRequest,
)
return "", errMissingURL
}
err := delivery.ValidateTargetURL(
r.Context(), targetURL,
)
if err != nil {
h.log.Warn(
"target URL blocked by SSRF protection",
"url", targetURL,
"error", err,
)
http.Error(
w,
"Invalid target URL: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
cfg := map[string]any{"webhookUrl": 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",
)
}
// HandleTargetDelete handles deleting a target.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
)
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
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
}
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
}
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)
}