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.
126 lines
4.7 KiB
Go
126 lines
4.7 KiB
Go
package database
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
// DefaultRetentionDays is the event retention period applied to a
|
|
// webhook created without an explicit retention value. It is the
|
|
// single source of truth for that policy and must stay in sync
|
|
// with the `gorm:"default:30"` column default on
|
|
// Webhook.RetentionDays below; a struct tag cannot reference a
|
|
// constant, so a test asserts the two agree.
|
|
DefaultRetentionDays = 30
|
|
|
|
// RetentionForeverDays is the sentinel RetentionDays value meaning
|
|
// "retain events forever". Users express that intent as 0, which
|
|
// Webhook.BeforeSave rewrites to this value: the column default
|
|
// substitutes DefaultRetentionDays for a zero value at insert
|
|
// time, so a zero can never survive a round trip to the database.
|
|
// Nothing outside this file may hardcode the number.
|
|
RetentionForeverDays = 365 * 1000
|
|
|
|
// MaxFiniteRetentionDays is the largest finite retention period the
|
|
// reaper's cutoff arithmetic can represent, and therefore the
|
|
// largest one a caller may request. It is derived from that
|
|
// arithmetic rather than picked: retentionCutoff computes
|
|
// retentionDays * hoursPerDay * time.Hour, and a time.Duration is
|
|
// an int64 nanosecond count, so math.MaxInt64 nanoseconds divided
|
|
// by an hour and then by a day is the exact ceiling — 106751 days,
|
|
// a little over 292 years.
|
|
//
|
|
// One day more overflows int64, wraps the product negative, and
|
|
// turns the cutoff into a timestamp in the far future that matches
|
|
// every row in the webhook's database. That is why this bound is
|
|
// enforced on input and why retentionCutoff saturates underneath
|
|
// it. Note that RetentionForeverDays deliberately sits above this
|
|
// ceiling: such webhooks are skipped before any cutoff is
|
|
// computed, and never reach the arithmetic at all.
|
|
MaxFiniteRetentionDays = int(
|
|
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
|
)
|
|
)
|
|
|
|
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
|
//
|
|
// Every method below takes a pointer receiver. BeforeSave has to,
|
|
// because it mutates the record and GORM only invokes hooks declared
|
|
// that way; the display helpers follow suit so the receiver kinds do
|
|
// not mix. Handlers therefore put a *Webhook into template data:
|
|
// html/template cannot call a pointer method on a value held in a map,
|
|
// because a map element is not addressable.
|
|
type Webhook struct {
|
|
BaseModel
|
|
|
|
UserID string `gorm:"type:uuid;not null" json:"userId"`
|
|
Name string `gorm:"not null" json:"name"`
|
|
Description string `json:"description"`
|
|
|
|
// RetentionDays is the number of days to retain events. A value of
|
|
// RetentionForeverDays means retain forever. The column default
|
|
// must equal DefaultRetentionDays.
|
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
|
|
|
// Relations
|
|
User User `json:"user,omitzero"`
|
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
|
Targets []Target `json:"targets,omitempty"`
|
|
}
|
|
|
|
// BeforeSave normalises RetentionDays on every insert and update. A
|
|
// non-positive value is the user's way of asking for "retain forever",
|
|
// which is stored as the RetentionForeverDays sentinel.
|
|
//
|
|
// This has to happen in a hook rather than at the call sites. GORM
|
|
// substitutes the column default (DefaultRetentionDays) for a zero
|
|
// value while building the insert statement, which runs after
|
|
// BeforeSave; rewriting any later than this loses that race and the
|
|
// row lands at 30 days. Living on the model also means a future call
|
|
// site — a REST API, a fixture, a migration — cannot bypass it.
|
|
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
|
if w.RetentionDays <= 0 {
|
|
w.RetentionDays = RetentionForeverDays
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// retainsForever reports whether a stored RetentionDays value means
|
|
// "keep events indefinitely". It is the single definition of that
|
|
// question, shared by Webhook.RetainsForever and by the reaper's
|
|
// cutoff computation so the two cannot disagree about which webhooks
|
|
// are exempt from reaping.
|
|
//
|
|
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
|
// and, defensively, the non-positive values that rows written before
|
|
// the sentinel existed may still carry.
|
|
func retainsForever(retentionDays int) bool {
|
|
return retentionDays <= 0 ||
|
|
retentionDays >= RetentionForeverDays
|
|
}
|
|
|
|
// RetainsForever reports whether this webhook's events are kept
|
|
// indefinitely.
|
|
func (w *Webhook) RetainsForever() bool {
|
|
return retainsForever(w.RetentionDays)
|
|
}
|
|
|
|
// RetentionLabel returns the webhook's retention policy as display
|
|
// text, so that no template has to know about the sentinel value.
|
|
func (w *Webhook) RetentionLabel() string {
|
|
if w.RetainsForever() {
|
|
return "forever"
|
|
}
|
|
|
|
if w.RetentionDays == 1 {
|
|
return "1 day"
|
|
}
|
|
|
|
return strconv.Itoa(w.RetentionDays) + " days"
|
|
}
|