All checks were successful
check / check (push) Successful in 2m53s
r.FormValue falls back to the query string, so
POST /source/{id}/targets?url=<secret> created a working target from a
value carried on the request line — where proxy logs, browser history
and Referer all record it. Every form read is now r.PostFormValue,
including the login password and both password-change fields, which had
the same defect in a more acute form.
The Sentry leg needed more than the query string. sentryhttp attaches
the whole request to the scope, and ApplyToEvent copies the teed body
into Request.Data with no SendDefaultPII guard — so reading every field
from the body only pointed every credential this change protects at the
one field the first revision did not scrub. Body and query are now
redacted, Cookies and Env cleared, and Headers reduced to an allowlist,
because the SDK's own filter removes four names and would otherwise ship
X-Csrf-Token and the shared secrets senders put on the receiver route.
Also adds json:"-" to Target.Config, APIKey.Key and Setting.Value —
TargetView is the masking barrier for the HTML path only, and the first
handler to marshal a model would serialise a bearer token or the session
encryption key.
Independently reviewed three times. The second review found the Data
leak and proved it with a scratch module; the third disproved the
PR's own claim that BeforeSend gets no request, so the README now
records that redacting unconditionally is a deliberate choice rather
than a limitation — which is what makes #179 cheap to fix.
1433 lines
33 KiB
Go
1433 lines
33 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.PostFormValue("name")
|
|
description := r.PostFormValue("description")
|
|
retentionStr := r.PostFormValue("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.PostFormValue("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.PostFormValue("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.PostFormValue("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.PostFormValue("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.
|
|
//
|
|
// Every field here is read with PostFormValue, not FormValue.
|
|
// FormValue falls back to the query string, which would let
|
|
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
|
// configure a target from a value the request line carries — and
|
|
// the request line, unlike the body, is what logs, proxies,
|
|
// Referer headers and error trackers record.
|
|
name := r.PostFormValue("name")
|
|
targetType := database.TargetType(r.PostFormValue("type"))
|
|
targetURL := r.PostFormValue("url")
|
|
maxRetriesStr := r.PostFormValue("max_retries")
|
|
expiry := r.PostFormValue("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)
|
|
}
|