All checks were successful
check / check (push) Successful in 3m0s
The event log rendered stored bodies untruncated. Since buffered rendering landed (#123) that became resident memory per concurrent viewer, up to tens of MB, driven by payloads unauthenticated clients supply to the public receiver. Bound in the query rather than the template, via substr(cast(body as blob), 1, ?) plus length(cast(body as blob)), so an oversized body never becomes a Go string at all. Adds an EventLogView projection carrying the true byte count, and trims a partial UTF-8 tail without rewriting bodies that are merely invalid UTF-8. Independently reviewed. The generated SQL was dumped under GORM DryRun to confirm the cap is a bound parameter, both casts are present, and no other path selects the full column; soft-delete scope, ordering and pagination are unchanged. Correction to the PR body: its quoted mutation output was produced by removing the bound from eventLogColumns, not by raising the cap to 1<<30 as the text claimed. The reviewer reproduced the real mutation and confirmed the tests do catch removal of the bound. Follow-up #157 restores in-app retrieval of bodies above the cap.
1400 lines
31 KiB
Go
1400 lines
31 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)
|
|
}
|
|
|
|
// 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{
|
|
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)
|
|
}
|