All checks were successful
check / check (push) Successful in 3m4s
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. 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 or negative value is a 400 that re-renders the form rather than a silently substituted default. 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. 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.
95 lines
3.4 KiB
Go
95 lines
3.4 KiB
Go
package database
|
|
|
|
import (
|
|
"strconv"
|
|
|
|
"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
|
|
)
|
|
|
|
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
|
//
|
|
// The receiver kinds below are deliberately mixed. BeforeSave has to
|
|
// take a pointer because it mutates the record, and GORM only invokes
|
|
// hooks declared that way. RetainsForever and RetentionLabel have to
|
|
// take values because html/template calls them on webhooks held in a
|
|
// template data map, which reflection cannot address; a pointer
|
|
// receiver there fails at render time rather than at compile time.
|
|
//
|
|
//nolint:recvcheck // GORM needs a pointer hook; templates need values.
|
|
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 this webhook's events are kept
|
|
// indefinitely. 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 (w Webhook) RetainsForever() bool {
|
|
return w.RetentionDays <= 0 ||
|
|
w.RetentionDays >= RetentionForeverDays
|
|
}
|
|
|
|
// 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"
|
|
}
|