All checks were successful
check / check (push) Successful in 2m43s
RetentionDays carried gorm:"default:30", so GORM substituted 30 for a zero value while building the insert. A webhook could therefore never be configured to keep its events indefinitely: the reaper's retain-forever branch existed but was unreachable from the normal create and edit flows. Introduce database.RetentionForeverDays = 365 * 1000 as the sentinel for "retain forever" and a Webhook.BeforeSave hook that rewrites any non-positive RetentionDays to it. The rewrite has to live in the hook rather than at the call sites: GORM applies the column default while converting the model to insert values, which happens after BeforeSave, so anything later loses that race. Putting it on the model also means a future call site, such as the planned REST API, cannot bypass it. The reaper now skips a webhook when Webhook.RetainsForever reports true, which recognises the sentinel and keeps honouring the old <= 0 values for rows written before it existed. Without this the sentinel, being positive, would have produced a cutoff a thousand years in the past and a DELETE matching nothing on every sweep. Bound the finite retention range, which was previously unbounded on the server. The reaper computes its cutoff as a time.Duration, an int64 nanosecond count, so a day count above 106751 overflows, wraps the span negative, and moves the cutoff into the far future — where it matches every row and the sweep deletes every event, delivery, and delivery result the webhook has, including ones created seconds ago. Nothing rejected such a value: parseRetention accepted any v > 0, and max="365" was a client-side attribute a direct POST ignored, so the wipe was already reachable on main and removing that attribute would have made it reachable by ordinary use. The bound is database.MaxFiniteRetentionDays, derived from the arithmetic itself as math.MaxInt64 / time.Hour / hoursPerDay rather than picked as a round number, and a finite value above it is now a 400 that names the ceiling. retentionCutoff additionally clamps the day count it is given and reports whether any cutoff applies at all, so a row written by an older version, a migration, or a future call site cannot reach the overflow either. A value at or above the retain-forever sentinel stays accepted, because that is what the edit form pre-fills for a retain-forever webhook. Form handling is shared by create and edit through parseRetentionDays so the two cannot drift: an empty field keeps the previous behaviour (default on create, unchanged on edit), 0 is honoured, and an unparseable, negative, or out-of-range value is a 400 that re-renders the form rather than a silently substituted default. The two rejection reasons are distinct sentinel errors so the message can name the ceiling, and the create form now carries the submitted name and description back into the re-rendered inputs, which the edit form already did. The retention inputs drop max="365". That cap was not cosmetic: the edit form pre-fills the stored value, so a retain-forever webhook rendered 365000 into an input capped at 365 and browser validation would have blocked saving any edit to it. min becomes 0 with a hint explaining what 0 does, and the list and detail views render a RetentionLabel of "forever" instead of a raw day count. All three Webhook methods take pointer receivers, so there is no receiver mix and no lint suppression: BeforeSave must take a pointer to mutate the record, and the handlers hand templates a *Webhook because html/template cannot call a pointer method on a value held in a map. The 30-day default is consolidated into database.DefaultRetentionDays, referenced from the handler and from the create form's pre-filled value, with a test asserting it agrees with the struct tag that cannot reference it.
1300 lines
27 KiB
Go
1300 lines
27 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 []database.Delivery
|
|
}
|
|
|
|
// HandleSourceList shows a list of user's webhooks.
|
|
func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
var webhooks []database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"user_id = ?", userID,
|
|
).Order("created_at DESC").Find(&webhooks).Error
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to list webhooks", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
items := h.buildWebhookListItems(webhooks)
|
|
|
|
data := map[string]any{
|
|
"Webhooks": items,
|
|
}
|
|
|
|
h.renderTemplate(w, r, "sources_list.html", data)
|
|
}
|
|
}
|
|
|
|
// buildWebhookListItems builds list items with counts.
|
|
func (h *Handlers) buildWebhookListItems(
|
|
webhooks []database.Webhook,
|
|
) []WebhookListItem {
|
|
items := make([]WebhookListItem, len(webhooks))
|
|
|
|
for i := range webhooks {
|
|
items[i].Webhook = webhooks[i]
|
|
|
|
h.db.DB().Model(&database.Entrypoint{}).Where(
|
|
"webhook_id = ?", webhooks[i].ID,
|
|
).Count(&items[i].EntrypointCount)
|
|
|
|
h.db.DB().Model(&database.Target{}).Where(
|
|
"webhook_id = ?", webhooks[i].ID,
|
|
).Count(&items[i].TargetCount)
|
|
|
|
if h.dbMgr.DBExists(webhooks[i].ID) {
|
|
webhookDB, err := h.dbMgr.GetDB(
|
|
webhooks[i].ID,
|
|
)
|
|
if err == nil {
|
|
webhookDB.Model(
|
|
&database.Event{},
|
|
).Count(&items[i].EventCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// HandleSourceCreate shows the form to create a new webhook.
|
|
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(
|
|
w, r.Body, 1<<maxBodyShift,
|
|
)
|
|
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
name := r.FormValue("name")
|
|
description := r.FormValue("description")
|
|
retentionStr := r.FormValue("retention_days")
|
|
|
|
if name == "" {
|
|
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": 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
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(
|
|
w, r.Body, 1<<maxBodyShift,
|
|
)
|
|
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.applyWebhookEdit(w, r, &webhook)
|
|
}
|
|
}
|
|
|
|
// applyWebhookEdit validates and saves webhook edits.
|
|
func (h *Handlers) applyWebhookEdit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook *database.Webhook,
|
|
) {
|
|
r.Body = http.MaxBytesReader(
|
|
w, r.Body, 1<<maxBodyShift,
|
|
)
|
|
|
|
name := r.FormValue("name")
|
|
if name == "" {
|
|
data := map[string]any{
|
|
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
|
|
}
|
|
|
|
err = h.dbMgr.DeleteDB(webhook.ID)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to delete webhook event database",
|
|
"webhook_id", webhook.ID,
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
h.log.Info(
|
|
"webhook deleted",
|
|
"webhook_id", webhook.ID,
|
|
"user_id", userID,
|
|
)
|
|
|
|
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleSourceLogs shows the request/response logs for a
|
|
// webhook.
|
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
targets := h.loadTargetMap(webhook.ID)
|
|
page := h.parsePage(r)
|
|
|
|
evts, total := h.loadEventsWithDeliveries(
|
|
w, webhook, targets, page,
|
|
)
|
|
|
|
totalPages := int(total) / paginationPerPage
|
|
if int(total)%paginationPerPage != 0 {
|
|
totalPages++
|
|
}
|
|
|
|
data := map[string]any{
|
|
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 keyed by target ID.
|
|
func (h *Handlers) loadTargetMap(
|
|
webhookID string,
|
|
) map[string]database.Target {
|
|
var targets []database.Target
|
|
|
|
h.db.DB().Where(
|
|
"webhook_id = ?", webhookID,
|
|
).Find(&targets)
|
|
|
|
targetMap := make(
|
|
map[string]database.Target, len(targets),
|
|
)
|
|
|
|
for _, t := range targets {
|
|
targetMap[t.ID] = t
|
|
}
|
|
|
|
return targetMap
|
|
}
|
|
|
|
// parsePage extracts a page number from the query string.
|
|
func (h *Handlers) parsePage(r *http.Request) int {
|
|
page := 1
|
|
|
|
if p := r.URL.Query().Get("page"); p != "" {
|
|
v, err := strconv.Atoi(p)
|
|
if err == nil && v > 0 {
|
|
page = v
|
|
}
|
|
}
|
|
|
|
return page
|
|
}
|
|
|
|
// loadEventsWithDeliveries loads paginated events and their
|
|
// deliveries from the per-webhook database.
|
|
func (h *Handlers) loadEventsWithDeliveries(
|
|
w http.ResponseWriter,
|
|
webhook database.Webhook,
|
|
targetMap map[string]database.Target,
|
|
page int,
|
|
) ([]EventWithDeliveries, int64) {
|
|
var totalEvents int64
|
|
|
|
var result []EventWithDeliveries
|
|
|
|
if !h.dbMgr.DBExists(webhook.ID) {
|
|
return result, totalEvents
|
|
}
|
|
|
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
|
if err != nil {
|
|
h.serverError(
|
|
w, "failed to get webhook database", err,
|
|
)
|
|
|
|
return nil, 0
|
|
}
|
|
|
|
webhookDB.Model(&database.Event{}).Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Count(&totalEvents)
|
|
|
|
offset := (page - 1) * paginationPerPage
|
|
|
|
var events []database.Event
|
|
|
|
webhookDB.Where(
|
|
"webhook_id = ?", webhook.ID,
|
|
).Order("created_at DESC").Offset(offset).Limit(
|
|
paginationPerPage,
|
|
).Find(&events)
|
|
|
|
result = make([]EventWithDeliveries, len(events))
|
|
|
|
for i := range events {
|
|
result[i].Event = events[i]
|
|
|
|
webhookDB.Where(
|
|
"event_id = ?", events[i].ID,
|
|
).Find(&result[i].Deliveries)
|
|
|
|
for j := range result[i].Deliveries {
|
|
tid := result[i].Deliveries[j].TargetID
|
|
|
|
if target, ok := targetMap[tid]; ok {
|
|
result[i].Deliveries[j].Target = target
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, totalEvents
|
|
}
|
|
|
|
// HandleEntrypointCreate handles adding a new entrypoint.
|
|
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(
|
|
w, r.Body, 1<<maxBodyShift,
|
|
)
|
|
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
description := r.FormValue("description")
|
|
|
|
entrypoint := &database.Entrypoint{
|
|
WebhookID: webhook.ID,
|
|
Path: uuid.New().String(),
|
|
Description: description,
|
|
Active: true,
|
|
}
|
|
|
|
err = h.db.DB().Create(entrypoint).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to create entrypoint", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleTargetCreate handles adding a new target to a webhook.
|
|
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(
|
|
w, r.Body, 1<<maxBodyShift,
|
|
)
|
|
|
|
err = r.ParseForm()
|
|
if err != nil {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.processTargetCreate(w, r, webhook)
|
|
}
|
|
}
|
|
|
|
// processTargetCreate validates and creates a new target.
|
|
func (h *Handlers) processTargetCreate(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
) {
|
|
r.Body = http.MaxBytesReader(
|
|
w, r.Body, 1<<maxBodyShift,
|
|
)
|
|
|
|
name := r.FormValue("name")
|
|
targetType := database.TargetType(r.FormValue("type"))
|
|
targetURL := r.FormValue("url")
|
|
maxRetriesStr := r.FormValue("max_retries")
|
|
expiry := r.FormValue("expiry")
|
|
|
|
if name == "" {
|
|
http.Error(
|
|
w, "Name is required", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if !isValidTargetType(targetType) {
|
|
http.Error(
|
|
w, "Invalid target type",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
configJSON, err := h.buildTargetConfig(
|
|
w, r, targetType, targetURL, expiry,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
maxRetries := parseNonNegativeInt(maxRetriesStr)
|
|
|
|
target := &database.Target{
|
|
WebhookID: webhook.ID,
|
|
Name: name,
|
|
Type: targetType,
|
|
Active: true,
|
|
Config: configJSON,
|
|
MaxRetries: maxRetries,
|
|
}
|
|
|
|
err = h.db.DB().Create(target).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to create target", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// isValidTargetType checks whether the target type is supported.
|
|
func isValidTargetType(tt database.TargetType) bool {
|
|
switch tt {
|
|
case database.TargetTypeHTTP,
|
|
database.TargetTypeDatabase,
|
|
database.TargetTypeLog,
|
|
database.TargetTypeSlack:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// parseNonNegativeInt parses s as a non-negative integer,
|
|
// returning 0 if s is empty or invalid.
|
|
func parseNonNegativeInt(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
|
|
v, err := strconv.Atoi(s)
|
|
if err == nil && v >= 0 {
|
|
return v
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// buildTargetConfig builds the JSON config string for a target.
|
|
// The expiry form value is read by the caller (which bounds the
|
|
// request body) and applies to database targets only.
|
|
func (h *Handlers) buildTargetConfig(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
targetType database.TargetType,
|
|
targetURL, expiry string,
|
|
) (string, error) {
|
|
switch targetType {
|
|
case database.TargetTypeHTTP:
|
|
return h.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 {
|
|
h.log.Warn(
|
|
"target URL blocked by SSRF protection",
|
|
"url", targetURL,
|
|
"error", err,
|
|
)
|
|
http.Error(
|
|
w,
|
|
"Invalid target URL: "+err.Error(),
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return "", err
|
|
}
|
|
|
|
cfg := map[string]any{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",
|
|
)
|
|
}
|
|
|
|
// HandleTargetDelete handles deleting a target.
|
|
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
|
return h.deleteChildResource(
|
|
"targetID", &database.Target{},
|
|
"failed to delete target",
|
|
)
|
|
}
|
|
|
|
// deleteChildResource returns a handler that deletes a child
|
|
// resource (entrypoint or target) belonging to a webhook.
|
|
func (h *Handlers) deleteChildResource(
|
|
idParam string,
|
|
model any,
|
|
errMsg string,
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
childID := chi.URLParam(r, idParam)
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
result := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
childID, webhook.ID,
|
|
).Delete(model)
|
|
if result.Error != nil {
|
|
h.log.Error(errMsg, "error", result.Error)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r,
|
|
"/source/"+webhook.ID,
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleEntrypointToggle handles toggling an entrypoint's
|
|
// active state.
|
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|
return h.toggleChildResource(
|
|
"entrypointID",
|
|
func(webhookID, childID string) error {
|
|
var ep database.Entrypoint
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
childID, webhookID,
|
|
).First(&ep).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ep.Active = !ep.Active
|
|
|
|
return h.db.DB().Save(&ep).Error
|
|
},
|
|
"failed to toggle entrypoint",
|
|
)
|
|
}
|
|
|
|
// HandleTargetToggle handles toggling a target's active state.
|
|
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|
return h.toggleChildResource(
|
|
"targetID",
|
|
func(webhookID, childID string) error {
|
|
var tgt database.Target
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
childID, webhookID,
|
|
).First(&tgt).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tgt.Active = !tgt.Active
|
|
|
|
return h.db.DB().Save(&tgt).Error
|
|
},
|
|
"failed to toggle target",
|
|
)
|
|
}
|
|
|
|
// toggleChildResource returns a handler that toggles the active
|
|
// state of a child resource belonging to a webhook.
|
|
func (h *Handlers) toggleChildResource(
|
|
idParam string,
|
|
toggleFn func(webhookID, childID string) error,
|
|
errMsg string,
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.getUserID(r)
|
|
if !ok {
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sourceID := chi.URLParam(r, "sourceID")
|
|
childID := chi.URLParam(r, idParam)
|
|
|
|
var webhook database.Webhook
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND user_id = ?", sourceID, userID,
|
|
).First(&webhook).Error
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
err = toggleFn(webhook.ID, childID)
|
|
if err != nil {
|
|
h.log.Error(errMsg, "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r,
|
|
"/source/"+webhook.ID,
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// getUserID extracts the user ID from the session.
|
|
func (h *Handlers) getUserID(
|
|
r *http.Request,
|
|
) (string, bool) {
|
|
sess, err := h.session.Get(r)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
if !h.session.IsAuthenticated(sess) {
|
|
return "", false
|
|
}
|
|
|
|
return h.session.GetUserID(sess)
|
|
}
|