Allow retention_days of 0 to mean retain forever (closes #79)
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.
This commit is contained in:
clawbot
2026-08-09 02:32:34 +00:00
committed by sneak
parent 4f5ecb18e5
commit 13de7cd290
14 changed files with 1261 additions and 61 deletions

View File

@@ -25,6 +25,73 @@ type WebhookListItem struct {
// 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
@@ -106,11 +173,30 @@ func (h *Handlers) buildWebhookListItems(
// HandleSourceCreate shows the form to create a new webhook.
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
tmplKeyError: "",
}
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData("", "", ""),
)
}
}
h.renderTemplate(w, r, "sources_new.html", data)
// 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,
}
}
@@ -145,23 +231,31 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
retentionStr := r.FormValue("retention_days")
if name == "" {
data := map[string]any{
tmplKeyError: "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "sources_new.html", data)
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData(
"Name is required", name, description,
),
)
return
}
retentionDays := defaultRetentionDays
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,
),
)
if retentionStr != "" {
v, convErr := strconv.Atoi(retentionStr)
if convErr == nil && v > 0 {
retentionDays = v
}
return
}
h.createWebhookWithEntrypoint(
@@ -315,8 +409,10 @@ func (h *Handlers) renderSourceDetail(
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,
tmplKeyWebhook: &webhook,
"Entrypoints": entrypoints,
"Targets": targets,
"Events": events,
@@ -352,7 +448,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
}
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyWebhook: &webhook,
tmplKeyError: "",
}
@@ -416,7 +512,7 @@ func (h *Handlers) applyWebhookEdit(
name := r.FormValue("name")
if name == "" {
data := map[string]any{
tmplKeyWebhook: *webhook,
tmplKeyWebhook: webhook,
tmplKeyError: "Name is required",
}
@@ -428,7 +524,25 @@ func (h *Handlers) applyWebhookEdit(
webhook.Name = name
webhook.Description = r.FormValue("description")
h.parseRetention(r, webhook)
// 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 {
@@ -442,23 +556,6 @@ func (h *Handlers) applyWebhookEdit(
)
}
// parseRetention parses and applies retention_days from the
// form.
func (h *Handlers) parseRetention(
r *http.Request,
webhook *database.Webhook,
) {
retStr := r.FormValue("retention_days")
if retStr == "" {
return
}
v, err := strconv.Atoi(retStr)
if err == nil && v > 0 {
webhook.RetentionDays = v
}
}
// HandleSourceDelete handles webhook deletion.
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -590,7 +687,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
}
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyWebhook: &webhook,
"Events": evts,
"Page": page,
"TotalPages": totalPages,