All checks were successful
check / check (push) Successful in 9s
Go embeds the request URL in *url.Error, so any transport failure — DNS, TLS, refused, timeout, SSRF dial block — persisted the full Slack webhook URL into the per-webhook SQLite database via DeliveryResult.Error. That field is tagged json:"error,omitempty", so a future REST API would have served it. maskURLError rebuilds the error preserving Op and the wrapped cause, so DNS vs TLS vs timeout still read differently and errors.Is/As and Timeout() keep working; only path, query and userinfo are dropped. Applied where the errors are born, which covers both the Slack and HTTP targets. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment. The SSRF rejection log now logs the masked URL, and source_logs.html receives view types rather than raw rows, so no config blob is reachable from that template. MaskURL is now the single masker for the whole tree.
1403 lines
31 KiB
Go
1403 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
|
|
}
|
|
|
|
// EventWithDeliveries holds an event and its deliveries.
|
|
type EventWithDeliveries struct {
|
|
database.Event
|
|
|
|
Deliveries []DeliveryView
|
|
}
|
|
|
|
// 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.
|
|
func (h *Handlers) loadEventsWithDeliveries(
|
|
w http.ResponseWriter,
|
|
webhook database.Webhook,
|
|
targetMap map[string]delivery.TargetView,
|
|
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]
|
|
|
|
var deliveries []database.Delivery
|
|
|
|
webhookDB.Where(
|
|
"event_id = ?", events[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)
|
|
}
|