diff --git a/README.md b/README.md index 39cbfa8..bc13f49 100644 --- a/README.md +++ b/README.md @@ -1721,8 +1721,11 @@ events should be forwarded. own archive database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term retention, with an optional creation-validated expiry (default: keep - forever). No external delivery and no retries; an archive write - failure fails the delivery. See the database target section under + forever). The new webhook form, the add target form and the target edit + form all offer the same expiries: never, 1h, 12h, 24h, 30d, 90d or 365d. + The target list shows the expiry in plain units, such as "30 days". No + external delivery and no retries; an archive write failure fails the + delivery. See the database target section under "Per-Webhook Event Databases" for the full semantics. - **`log`** — Write the event to the application log (stdout). Useful for debugging. diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go index 42da682..3753e3c 100644 --- a/internal/delivery/target_config_edit.go +++ b/internal/delivery/target_config_edit.go @@ -86,9 +86,9 @@ func NewTargetConfigForm( } // databaseConfigForm parses an archive target's optional expiry. -// An absent or empty configuration is the keep-forever default and -// yields an empty field, so re-saving the form unchanged stores the -// same empty configuration it started with. An expiry that is set +// An absent, empty or never expiry yields an empty expiry, on which +// the edit form starts at never; saving it unchanged stores never, +// which means the same as an empty expiry. An expiry that is set // but not a valid duration is an error, not a blank field. func databaseConfigForm( configJSON string, diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index 1053eac..d6e3a84 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -1,9 +1,9 @@ package delivery import ( - "encoding/json" "fmt" "strconv" + "time" "sneak.berlin/go/webhooker/internal/database" ) @@ -197,37 +197,61 @@ func maxRetriesField(t *database.Target) ConfigField { } } -// databaseConfigFields describes an archive target. Its -// configuration is optional, and an absent or empty expiry -// means the archive is kept forever. An expiry that is set -// but not a valid duration is reported as unavailable rather -// than echoed back. +// databaseConfigFields describes an archive target by its +// expiry in plain units, such as "30 days", or "never" when +// the archive is kept forever. An expiry that is set but not +// a valid duration is reported as unavailable rather than +// echoed back. func databaseConfigFields(configJSON string) []ConfigField { - expiry := archiveExpiryNever + expiry, err := parseArchiveExpiry(configJSON) + if err != nil { + return unavailableConfigFields() + } - if configJSON != "" { - var cfg databaseTargetConfig - - err := json.Unmarshal([]byte(configJSON), &cfg) - if err != nil { - return unavailableConfigFields() - } - - if cfg.Expiry != "" { - if ValidateArchiveExpiry(cfg.Expiry) != nil { - return unavailableConfigFields() - } - - expiry = cfg.Expiry - } + value := archiveExpiryNever + if expiry > 0 { + value = plainDuration(expiry) } return []ConfigField{{ Label: "Archive Expiry", - Value: expiry, + Value: value, }} } +// plainDuration writes a positive duration as a count of the +// largest whole unit it divides into: "30 days", "12 hours", +// "1 minute". A duration with a fraction of a second is +// written as Go writes it. +func plainDuration(d time.Duration) string { + const day = 24 * time.Hour + + units := []struct { + size time.Duration + name string + }{ + {day, "day"}, + {time.Hour, "hour"}, + {time.Minute, "minute"}, + {time.Second, "second"}, + } + + for _, unit := range units { + if d%unit.size != 0 { + continue + } + + count := int64(d / unit.size) + if count == 1 { + return "1 " + unit.name + } + + return fmt.Sprintf("%d %ss", count, unit.name) + } + + return d.String() +} + // MaskedWebhookURL returns the Slack webhook URL reduced to // its scheme and host, with the path, query and any userinfo // elided. The path segments are the credential, so none of diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index c9e58f5..f763d86 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -305,14 +305,20 @@ func TestNewTargetViews_Database(t *testing.T) { }{ "empty config": {config: "", want: viewExpiryNever}, "empty expiry": {config: `{}`, want: viewExpiryNever}, - "explicit": { - config: `{"expiry":"720h"}`, - want: "720h", - }, "never literal": { config: `{"expiry":"` + viewExpiryNever + `"}`, want: viewExpiryNever, }, + "1h": {config: `{"expiry":"1h"}`, want: "1 hour"}, + "12h": {config: `{"expiry":"12h"}`, want: "12 hours"}, + "24h": {config: `{"expiry":"24h"}`, want: "1 day"}, + "720h": {config: `{"expiry":"720h"}`, want: "30 days"}, + "2160h": {config: `{"expiry":"2160h"}`, want: "90 days"}, + "8760h": {config: `{"expiry":"8760h"}`, want: "365 days"}, + "36h": {config: `{"expiry":"36h"}`, want: "36 hours"}, + "1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"}, + "45s": {config: `{"expiry":"45s"}`, want: "45 seconds"}, + "1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"}, } for name, tc := range tests { diff --git a/internal/delivery/target_headers_test.go b/internal/delivery/target_headers_test.go index 3237132..ac72b86 100644 --- a/internal/delivery/target_headers_test.go +++ b/internal/delivery/target_headers_test.go @@ -216,8 +216,9 @@ func TestNewTargetConfigForm(t *testing.T) { assert.Empty(t, form.URL) } -// A keep-forever archive target must pre-fill as an empty field, so -// saving the form back unchanged stores the same empty config. +// A keep-forever archive target yields an empty expiry, so the edit +// form starts on never; saving it unchanged stores never, which means +// the same as an empty expiry. func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) { t.Parallel() diff --git a/internal/handlers/archive_expiry.go b/internal/handlers/archive_expiry.go new file mode 100644 index 0000000..060737d --- /dev/null +++ b/internal/handlers/archive_expiry.go @@ -0,0 +1,58 @@ +package handlers + +const ( + // archiveExpiryNever is the archive expiry that keeps archived + // events forever. A stored empty expiry means the same. + archiveExpiryNever = "never" + + // tmplKeyArchiveExpiryChoices is the template data key for the + // entries of a page's archive expiry select. + tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices" +) + +// archiveExpiryChoice is one entry of a database target's archive +// expiry select: the expiry stored, the label shown, and whether the +// select starts on it. +type archiveExpiryChoice struct { + Value string + Label string + Selected bool +} + +// archiveExpiryChoices lists the archive expiries offered by the new +// webhook page, the add target form and the target edit form. +func archiveExpiryChoices() []archiveExpiryChoice { + return []archiveExpiryChoice{ + {Value: archiveExpiryNever, Label: archiveExpiryNever}, + {Value: "1h", Label: "1h"}, + {Value: "12h", Label: "12h"}, + {Value: "24h", Label: "24h"}, + {Value: "720h", Label: "30d"}, + {Value: "2160h", Label: "90d"}, + {Value: "8760h", Label: "365d"}, + } +} + +// archiveExpiryOptions returns the choices with expiry selected; an +// empty expiry selects never. An expiry that is not one of the +// choices comes first as its own selected entry, so saving the form +// unchanged keeps it. +func archiveExpiryOptions(expiry string) []archiveExpiryChoice { + if expiry == "" { + expiry = archiveExpiryNever + } + + options := archiveExpiryChoices() + + for i := range options { + if options[i].Value == expiry { + options[i].Selected = true + + return options + } + } + + own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true} + + return append([]archiveExpiryChoice{own}, options...) +} diff --git a/internal/handlers/archive_expiry_test.go b/internal/handlers/archive_expiry_test.go new file mode 100644 index 0000000..70bf476 --- /dev/null +++ b/internal/handlers/archive_expiry_test.go @@ -0,0 +1,166 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// expiryNever is the archive expiry that keeps archived events +// forever. +const expiryNever = "never" + +// matched returns what the one group of pattern matched in page, at +// each match. +func matched(pattern, page string) []string { + matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1) + groups := make([]string, 0, len(matches)) + + for _, m := range matches { + groups = append(groups, m[1]) + } + + return groups +} + +// expiryShown returns the archive expiries the webhook page's target +// list shows. +func expiryShown( + t *testing.T, env *sourceTestEnv, webhookID string, +) []string { + t.Helper() + + w := httptest.NewRecorder() + env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest( + t, "/hook/"+webhookID, env.cookies, + map[string]string{sourceIDParam: webhookID}, + )) + require.Equal(t, http.StatusOK, w.Code) + + return matched( + `Archive Expiry:\s*([^<]*)`, w.Body.String(), + ) +} + +// expirySelected returns the target edit page and the expiries its +// select starts on. +func expirySelected( + t *testing.T, env *sourceTestEnv, webhookID, targetID string, +) (string, []string) { + t.Helper() + + w := serveTarget( + env, http.MethodGet, + "/hook/"+webhookID+"/targets/"+targetID+"/edit", nil, + ) + require.Equal(t, http.StatusOK, w.Code) + + page := w.Body.String() + + return page, matched(`