Compare commits

1 Commits
Author SHA1 Message Date
sneak 56f963c800 Event log: an event's ID can be selected without toggling it (closes #348)
check / check (push) Successful in 3m13s
An event's row in the event log was a button element, whose text a browser
does not let be selected. The row is now an element with the button role:
focusable, toggled by Enter and Space, and saying whether it is expanded. A
click that ends a text selection leaves the event as it is, and a single
click toggles only after 500 ms, which the second click of a double- or
triple-click cancels, so the event does not move under the pointer while its
ID is selected. The browser test now also clicks the caret, selects the ID by
dragging and by a triple-click, and uses the keyboard.

Model: opus-5-5
2026-10-02 22:37:02 +00:00
24 changed files with 371 additions and 1066 deletions
+25 -30
View File
@@ -1350,31 +1350,29 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never `static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`. `x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page, its edit pages and A browser test in `internal/server` loads the webhook page and the event log
the event log under the real policy and checks that: the add entrypoint form under the real policy and checks that: the add entrypoint form stays hidden
stays hidden until Add is clicked; for every target type, the targets section's until Add is clicked; for every target type, the targets section's Add shows
Add shows only a choice of type with Next and Cancel, Next shows only that only a choice of type with Next and Cancel, Next shows only that type's fields
type's fields (no url field for `database` or `log`), Cancel at either step (no url field for `database` or `log`), Cancel at either step closes the form,
closes the form, and saving adds the target; a refused target comes back with and saving adds the target; a refused target comes back with its form open, the
its form open, the values entered and the reason, and after Cancel the next Add values entered and the reason, and after Cancel the next Add starts with an
starts with an empty form and no reason; a refused save on the target edit page empty form and no reason; the Copy button beside an entrypoint URL reads
and on the webhook edit page comes back with the reason and every value "Copied" once clicked; an entrypoint's Edit button shows its edit form in place
entered; the Copy button beside an entrypoint URL reads "Copied" once clicked; of its description and hides until the form closes, Cancel hides the form and
an entrypoint's Edit button shows its edit form in place of its description and drops what was typed, as does leaving the page and going back to it, and Save
hides until the form closes, Cancel hides the form and drops what was typed, as changes the description; of the recent events on the webhook page only the
does leaving the page and going back to it, and Save changes the description; newest starts expanded, each expands and collapses, and Open leads to the
of the recent events on the webhook page only the newest starts expanded, each event's own page; an event in the event log expands and collapses when its
expands and collapses, and Open leads to the event's own page; an event in the row's caret or its ID is clicked, and from the keyboard, but not when its ID is
event log expands and collapses when its row's caret or its ID is clicked, and selected with the mouse, and a delivery's attempts inside it expand and
from the keyboard, but not when its ID is selected with the mouse, and a collapse; and at phone width the menu button opens and closes the mobile menu.
delivery's attempts inside it expand and collapse; and at phone width the menu It also fails if the browser reports a console warning or error, an uncaught
button opens and closes the mobile menu. It also fails if the browser reports a exception, or anything the policy refused. `make check` and the image build lint
console warning or error, an uncaught exception, or anything the policy refused. it but do not run it, and `make test` leaves it out (its file is built only with
`make check` and the image build lint it but do not run it, and `make test` the `browser` build tag). Run it with `make test-browser` after changing
leaves it out (its file is built only with the `browser` build tag). Run it with `templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
`make test-browser` after changing `templates/` or `static/js/`: that builds test in a digest-pinned headless browser image, so the host needs no browser.
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -1722,11 +1720,8 @@ events should be forwarded.
own archive database own archive database
(`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term
retention, with an optional creation-validated expiry (default: keep retention, with an optional creation-validated expiry (default: keep
forever). The new webhook form, the add target form and the target edit forever). No external delivery and no retries; an archive write
form all offer the same expiries: never, 1h, 12h, 24h, 30d, 90d or 365d. failure fails the delivery. See the database target section under
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. "Per-Webhook Event Databases" for the full semantics.
- **`log`** — Write the event to the application log (stdout). Useful - **`log`** — Write the event to the application log (stdout). Useful
for debugging. for debugging.
+3 -3
View File
@@ -86,9 +86,9 @@ func NewTargetConfigForm(
} }
// databaseConfigForm parses an archive target's optional expiry. // databaseConfigForm parses an archive target's optional expiry.
// An absent, empty or never expiry yields an empty expiry, on which // An absent or empty configuration is the keep-forever default and
// the edit form starts at never; saving it unchanged stores never, // yields an empty field, so re-saving the form unchanged stores the
// which means the same as an empty expiry. An expiry that is set // same empty configuration it started with. An expiry that is set
// but not a valid duration is an error, not a blank field. // but not a valid duration is an error, not a blank field.
func databaseConfigForm( func databaseConfigForm(
configJSON string, configJSON string,
+23 -47
View File
@@ -1,9 +1,9 @@
package delivery package delivery
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -197,61 +197,37 @@ func maxRetriesField(t *database.Target) ConfigField {
} }
} }
// databaseConfigFields describes an archive target by its // databaseConfigFields describes an archive target. Its
// expiry in plain units, such as "30 days", or "never" when // configuration is optional, and an absent or empty expiry
// the archive is kept forever. An expiry that is set but not // means the archive is kept forever. An expiry that is set
// a valid duration is reported as unavailable rather than // but not a valid duration is reported as unavailable rather
// echoed back. // than echoed back.
func databaseConfigFields(configJSON string) []ConfigField { func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON) expiry := archiveExpiryNever
if err != nil {
return unavailableConfigFields()
}
value := archiveExpiryNever if configJSON != "" {
if expiry > 0 { var cfg databaseTargetConfig
value = plainDuration(expiry)
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return unavailableConfigFields()
}
if cfg.Expiry != "" {
if ValidateArchiveExpiry(cfg.Expiry) != nil {
return unavailableConfigFields()
}
expiry = cfg.Expiry
}
} }
return []ConfigField{{ return []ConfigField{{
Label: "Archive Expiry", Label: "Archive Expiry",
Value: value, Value: expiry,
}} }}
} }
// 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 // MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo // its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of // elided. The path segments are the credential, so none of
+4 -10
View File
@@ -305,20 +305,14 @@ func TestNewTargetViews_Database(t *testing.T) {
}{ }{
"empty config": {config: "", want: viewExpiryNever}, "empty config": {config: "", want: viewExpiryNever},
"empty expiry": {config: `{}`, want: viewExpiryNever}, "empty expiry": {config: `{}`, want: viewExpiryNever},
"explicit": {
config: `{"expiry":"720h"}`,
want: "720h",
},
"never literal": { "never literal": {
config: `{"expiry":"` + viewExpiryNever + `"}`, config: `{"expiry":"` + viewExpiryNever + `"}`,
want: 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 { for name, tc := range tests {
+2 -3
View File
@@ -216,9 +216,8 @@ func TestNewTargetConfigForm(t *testing.T) {
assert.Empty(t, form.URL) assert.Empty(t, form.URL)
} }
// A keep-forever archive target yields an empty expiry, so the edit // A keep-forever archive target must pre-fill as an empty field, so
// form starts on never; saving it unchanged stores never, which means // saving the form back unchanged stores the same empty config.
// the same as an empty expiry.
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) { func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
t.Parallel() t.Parallel()
-58
View File
@@ -1,58 +0,0 @@
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...)
}
-166
View File
@@ -1,166 +0,0 @@
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:</span>\s*<span>([^<]*)</span>`, 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(`<option value="([^"]*)" selected>`, page)
}
// TestArchiveExpiryChoices adds a database target with each archive
// expiry the forms offer, and checks that it is stored as chosen,
// shown in plain units in the target list, and that the target edit
// form starts on it.
func TestArchiveExpiryChoices(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
choices := []struct{ value, shown string }{
{expiryNever, expiryNever},
{"1h", "1 hour"},
{"12h", "12 hours"},
{"24h", "1 day"},
{"720h", "30 days"},
{"2160h", "90 days"},
{"8760h", "365 days"},
}
for _, choice := range choices {
t.Run(choice.value, func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form := url.Values{}
form.Set("name", "archive")
form.Set("type", string(database.TargetTypeDatabase))
form.Set("expiry", choice.value)
w := serveTarget(
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.JSONEq(
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
)
assert.Equal(
t, []string{choice.shown},
expiryShown(t, env, webhook.ID),
)
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
assert.Equal(t, []string{choice.value}, selected)
})
}
}
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
// database target whose stored expiry is empty, which selects never,
// and of one whose expiry is not one of the choices, which is listed
// first as its own selected entry and saved unchanged.
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
empty := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
)
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
assert.Equal(t, []string{expiryNever}, selected)
webhook = seedWebhookWithRetention(t, env.db, 30)
unlisted := seedConfiguredTarget(
t, env.db, webhook.ID, database.TargetTypeDatabase,
`{"expiry":"36h"}`,
)
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
assert.Equal(t, []string{"36h"}, selected)
assert.Regexp(
t,
`<select id="expiry" name="expiry" class="input">\s*`+
`<option value="36h" selected>36h</option>\s*`+
`<option value="never">never</option>`,
page,
)
assert.Contains(t, page, `<option value="8760h">365d</option>`)
form := url.Values{}
form.Set("name", unlisted.Name)
form.Set("expiry", "36h")
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.JSONEq(
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
)
}
+5 -8
View File
@@ -110,25 +110,22 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the // parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared // embedded FS. Each page template is combined with the shared
// base, htmlheader, navbar and notice templates, and with any further // base, htmlheader, navbar and notice templates, and with any further
// files the page includes. The set is named after the page file, so // files the page includes. The page file must be listed first so that
// the page's root action ({{template "base" .}}) is its entry point. // its root action ({{template "base" .}}) becomes the template set's
// // entry point.
// The page file is parsed last because a later definition of a name
// replaces an earlier one: the page's {{define "title"}} must replace
// the {{block "title"}} fallback in htmlheader.html.
func parsePageTemplate( func parsePageTemplate(
pageFile string, included ...string, pageFile string, included ...string,
) *template.Template { ) *template.Template {
files := append([]string{ files := append([]string{
pageFile,
"base.html", "base.html",
"htmlheader.html", "htmlheader.html",
"navbar.html", "navbar.html",
"notice.html", "notice.html",
}, included...) }, included...)
files = append(files, pageFile)
return template.Must( return template.Must(
template.New(pageFile).ParseFS(templates.Templates, files...), template.ParseFS(templates.Templates, files...),
) )
} }
-107
View File
@@ -1,107 +0,0 @@
package handlers_test
import (
"html/template"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates"
)
// TestEveryPageRendersItsOwnTitle renders each page template and checks
// the browser tab title is the one the page declares, not the
// "Webhooker" fallback in htmlheader.html. A page that fails to render
// shows the error page's title instead, and fails here too.
func TestEveryPageRendersItsOwnTitle(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: some pages call
// Webhook.RetentionLabel, a pointer method.
webhook := &database.Webhook{Name: "orders", RetentionDays: 14}
webhook.ID = testWebhookID
pages := []struct {
page string
data map[string]any
title string
}{
{"login.html", map[string]any{}, "Login - Webhooker"},
{"profile.html", map[string]any{}, "Profile - Webhooker"},
{"settings.html", map[string]any{}, "Settings - Webhooker"},
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
{"sources_new.html", map[string]any{}, "New Webhook - Webhooker"},
{
"source_detail.html",
map[string]any{dataKeyWebhook: webhook},
"orders - Webhooker",
},
{
"source_edit.html",
map[string]any{dataKeyWebhook: webhook},
"Edit orders - Webhooker",
},
{
"source_logs.html",
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
"Full Event Log - orders - Webhooker",
},
{
"event_detail.html",
map[string]any{dataKeyWebhook: webhook},
"Event - orders - Webhooker",
},
{
"target_edit.html",
map[string]any{
dataKeyWebhook: webhook,
"Target": map[string]any{"Name": "alerts", "Type": "slack"},
},
"Edit alerts - Webhooker",
},
{
"error.html",
map[string]any{"StatusText": http.StatusText(http.StatusNotFound)},
"Not Found - Webhooker",
},
}
for _, p := range pages {
body := renderPage(t, h, sess, p.page, p.data)
_, afterOpen, _ := strings.Cut(body, "<title>")
title, _, _ := strings.Cut(afterOpen, "</title>")
assert.Equal(t, p.title, title, p.page)
}
}
// TestTitleFallbackIsWebhooker checks the title htmlheader.html gives a
// page that declares none. Every page declares one, so it is checked on
// htmlheader.html alone.
func TestTitleFallbackIsWebhooker(t *testing.T) {
t.Parallel()
header := template.Must(
template.ParseFS(templates.Templates, "htmlheader.html"),
)
var buf strings.Builder
require.NoError(t, header.ExecuteTemplate(&buf, "htmlheader", nil))
assert.Contains(t, buf.String(), "<title>Webhooker</title>")
}
+1 -1
View File
@@ -234,7 +234,7 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.NotContains(t, body, "sekrit") assert.NotContains(t, body, "sekrit")
assert.Contains(t, body, "Archive Expiry") assert.Contains(t, body, "Archive Expiry")
assert.Contains(t, body, "30 days") assert.Contains(t, body, "720h")
// An unknown type gets the neutral placeholder, never the // An unknown type gets the neutral placeholder, never the
// stored blob. // stored blob.
+80 -116
View File
@@ -312,9 +312,6 @@ func newSourceFormData(
tmplKeyError: errMsg, tmplKeyError: errMsg,
"Form": in, "Form": in,
"DefaultRetentionDays": database.DefaultRetentionDays, "DefaultRetentionDays": database.DefaultRetentionDays,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
} }
} }
@@ -619,16 +616,13 @@ func (h *Handlers) renderSourceDetail(
// Targets are projected to a display-safe view: a // Targets are projected to a display-safe view: a
// target's stored config blob holds a credential, and it // target's stored config blob holds a credential, and it
// must never reach a template. // must never reach a template.
"Entrypoints": entrypointViews, "Entrypoints": entrypointViews,
"Targets": h.targetRows(&webhook, targets), "Targets": h.targetRows(&webhook, targets),
"Events": events, "Events": events,
"BaseURL": baseURL, "BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets), "Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
tmplKeyTargetForm: targetForm, "TargetForm": targetForm,
"TargetError": targetErr, "TargetError": targetErr,
// The add target form's select starts on its expiry
// through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
} }
status := http.StatusOK status := http.StatusOK
@@ -664,12 +658,12 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return return
} }
h.renderWebhookEdit( data := map[string]any{
w, r, &webhook, tmplKeyWebhook: &webhook,
webhook.Name, webhook.Description, tmplKeyError: "",
strconv.Itoa(webhook.RetentionDays), }
"", http.StatusOK,
) h.renderTemplate(w, r, "source_edit.html", data)
} }
} }
@@ -715,8 +709,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
} }
} }
// applyWebhookEdit validates and saves webhook edits. A refused save // applyWebhookEdit validates and saves webhook edits.
// shows the edit form again with the values submitted and the reason.
func (h *Handlers) applyWebhookEdit( func (h *Handlers) applyWebhookEdit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
@@ -725,52 +718,52 @@ func (h *Handlers) applyWebhookEdit(
// The body size cap is enforced by the MaxBodySize middleware, // The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form. // which runs before CSRF parses the form.
name := r.PostFormValue("name") name := r.PostFormValue("name")
description := r.PostFormValue("description")
retention := r.PostFormValue("retention_days")
if name == "" { if name == "" {
h.renderWebhookEdit( data := map[string]any{
w, r, webhook, name, description, retention, tmplKeyWebhook: webhook,
"Name is required", http.StatusBadRequest, tmplKeyError: "Name is required",
) }
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
return return
} }
oldName := webhook.Name
webhook.Name = name
webhook.Description = r.PostFormValue("description")
// An empty field falls back to the stored value, so submitting the // An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone. // form without touching retention leaves the policy alone.
retentionDays, errMsg := parseRetentionDays( retentionDays, errMsg := parseRetentionDays(
retention, webhook.RetentionDays, r.PostFormValue("retention_days"), webhook.RetentionDays,
) )
if errMsg != "" { if errMsg != "" {
h.renderWebhookEdit( data := map[string]any{
w, r, webhook, name, description, retention, tmplKeyWebhook: webhook,
errMsg, http.StatusBadRequest, tmplKeyError: errMsg,
) }
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
return return
} }
// edited is the webhook as the submission leaves it; webhook stays webhook.RetentionDays = retentionDays
// as stored, for the page shown again when the save is refused.
edited := *webhook
edited.Name = name
edited.Description = description
edited.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see // A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, the same targets' // delivery.Engine.Rename). If either step fails, the same targets'
// archives go back to the name that is still stored, without // archives go back to the name that is still stored, without
// reading the main database again. // reading the main database again.
targets, err := h.renameWebhookArchives( targets, err := h.renameWebhookArchives(
webhook.ID, webhook.Name, edited.Name, webhook.ID, oldName, webhook.Name,
) )
if err == nil { if err == nil {
err = h.db.DB().Save(&edited).Error err = h.db.DB().Save(webhook).Error
} }
if err != nil { if err != nil {
restoreErr := h.renameArchives(targets, webhook.Name) restoreErr := h.renameArchives(targets, oldName)
if restoreErr != nil { if restoreErr != nil {
h.log.Error( h.log.Error(
"failed to rename archives back", "failed to rename archives back",
@@ -780,14 +773,15 @@ func (h *Handlers) applyWebhookEdit(
} }
if errors.Is(err, delivery.ErrArchiveNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderWebhookEdit( data := map[string]any{
w, r, webhook, name, description, retention, tmplKeyWebhook: webhook,
"Not saved: "+err.Error()+ tmplKeyError: "Not saved: " + err.Error() +
". Move that archive out of the data directory, "+ ". Move that archive out of the data directory, " +
"its .db together with any -wal and -shm beside "+ "its .db together with any -wal and -shm beside " +
"it, then save again.", "it, then save again.",
http.StatusConflict, }
)
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
return return
} }
@@ -803,27 +797,6 @@ func (h *Handlers) applyWebhookEdit(
) )
} }
// renderWebhookEdit renders the webhook edit page for the webhook as
// stored, its form showing name, description and retentionDays, with
// an optional error message above it.
func (h *Handlers) renderWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
name, description, retentionDays, errMsg string,
status int,
) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: errMsg,
"Name": name,
"Description": description,
"RetentionDays": retentionDays,
}
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
}
// HandleSourceDelete handles webhook deletion. // HandleSourceDelete handles webhook deletion.
func (h *Handlers) HandleSourceDelete() http.HandlerFunc { func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -1695,57 +1668,49 @@ func (h *Handlers) newTarget(
webhookID string, webhookID string,
in targetFormInput, in targetFormInput,
) (*database.Target, string, error) { ) (*database.Target, string, error) {
target := &database.Target{ if in.Name == "" {
WebhookID: webhookID, return nil, "Name is required", nil
Type: in.Type,
Active: true,
} }
errMsg, err := h.setTargetFromForm(ctx, target, in) if !isValidTargetType(in.Type) {
return nil, "Invalid target type", nil
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
if err != nil || errMsg != "" { if err != nil || errMsg != "" {
return nil, errMsg, err return nil, errMsg, err
} }
return target, "", nil // A new target has no stored retry count, so an absent field
// takes the fire-and-forget default. A field the operator filled
// in with something invalid is refused rather than becoming
// that default.
maxRetries, err := parseMaxRetries(in.MaxRetries, 0)
if err != nil {
return nil, "Invalid max retries: " + retriesErrorMessage(err), nil
}
return &database.Target{
WebhookID: webhookID,
Name: in.Name,
Type: in.Type,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}, "", nil
} }
// setTargetFromForm validates a target form against the target's type // isValidTargetType checks whether the target type is supported.
// and, when it accepts it, sets the target's name, configuration and func isValidTargetType(tt database.TargetType) bool {
// retry count from it. It returns the message the form shows for switch tt {
// anything it refuses, an unknown type among them, and then leaves the case database.TargetTypeHTTP,
// target unchanged; an error is the server's fault, as for newTarget. database.TargetTypeDatabase,
// The add target form and the target edit form both go through here, database.TargetTypeLog,
// so the two cannot come to disagree about what a target may be. database.TargetTypeSlack:
func (h *Handlers) setTargetFromForm( return true
ctx context.Context, default:
target *database.Target, return false
in targetFormInput,
) (string, error) {
if in.Name == "" {
return "Name is required", nil
} }
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
if err != nil || errMsg != "" {
return errMsg, err
}
// An empty max_retries keeps the target's count: the
// fire-and-forget default of 0 for a new target, and the stored
// count for an edited one, since the forms for target types that
// do not retry have no such field. A value that is filled in but
// invalid is refused rather than becoming that count, so a typo
// cannot destroy the count a target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil {
return "Invalid max retries: " + retriesErrorMessage(err), nil
}
target.Name = in.Name
target.Config = configJSON
target.MaxRetries = maxRetries
return "", nil
} }
// pageOrFirst parses a paginated page number, answering 1 for // pageOrFirst parses a paginated page number, answering 1 for
@@ -1767,10 +1732,9 @@ func pageOrFirst(s string) int {
} }
// targetFormInput carries the raw values of a target form. Both the // targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to setTargetFromForm, // create and the edit path fill one and hand it to buildTargetConfig,
// so neither can come to validate a target differently from the // so neither can come to validate a destination differently from the
// other. Both forms are filled from one: the edit form with the // other. A refused add target form is shown again from it.
// stored values, and a refused form with the values submitted.
type targetFormInput struct { type targetFormInput struct {
// Name is the target's name. // Name is the target's name.
Name string Name string
@@ -509,51 +509,6 @@ func TestHandleSourceEditSubmit_InvalidRetentionIsRejected(
) )
} }
// TestHandleSourceEditSubmit_RefusedFormComesBack refuses an edit for
// each reason the form can give and checks that the form comes back
// with the reason and the name, description and retention submitted,
// that the page still reports the stored retention, and that nothing
// is saved.
func TestHandleSourceEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
refused := func(name, retention, reason string) {
t.Helper()
wh := seedWebhookWithRetention(t, env.db, 30)
submitted := wh
submitted.Name = name
submitted.Description = "a description worth keeping"
w := submitEdit(t, env, submitted, retention)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+reason)
assert.Contains(t, page, `name="name" value="`+name+`"`)
assert.Contains(t, page, ">a description worth keeping</textarea>")
assert.Contains(
t, page, `name="retention_days" value="`+retention+`"`,
)
assert.Contains(t, page, "Currently 30 days.")
var stored database.Webhook
require.NoError(
t, env.db.DB().First(&stored, "id = ?", wh.ID).Error,
)
assert.Equal(t, wh.Name, stored.Name)
assert.Empty(t, stored.Description)
assert.Equal(t, 30, stored.RetentionDays)
}
refused("", "45", "Name is required")
refused("kept-name", "nonsense", "Retention must be")
}
func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged( func TestHandleSourceEditSubmit_EmptyRetentionLeavesValueUnchanged(
t *testing.T, t *testing.T,
) { ) {
@@ -854,10 +809,6 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) {
w := submitEdit(t, env, wh, "") w := submitEdit(t, env, wh, "")
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db") assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="`+renamedWebhookName+`"`,
"the form comes back with the name submitted",
)
var stored database.Webhook var stored database.Webhook
+68 -63
View File
@@ -3,7 +3,6 @@ package handlers
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -14,13 +13,10 @@ import (
const targetEditTemplate = "target_edit.html" const targetEditTemplate = "target_edit.html"
// tmplKeyTarget is the template data key for the target being // tmplKeyTarget is the template data key for the target being
// edited, tmplKeyTargetForm for the values its form shows, and // edited, and tmplKeyMaxTimeout for the timeout ceiling the form
// tmplKeyMaxTimeout for the timeout ceiling the form tells the user // tells the user about.
// about. The add target form on the webhook page takes its values
// under the same key as the edit form.
const ( const (
tmplKeyTarget = "Target" tmplKeyTarget = "Target"
tmplKeyTargetForm = "TargetForm"
tmplKeyMaxTimeout = "MaxTimeout" tmplKeyMaxTimeout = "MaxTimeout"
) )
@@ -32,19 +28,20 @@ const configUnreadableMessage = "The stored configuration for this " +
"target could not be read. Enter the values below; saving " + "target could not be read. Enter the values below; saving " +
"replaces the stored configuration." "replaces the stored configuration."
// targetEditView is the display model for the target edit page: the // targetEditView is the display model for the target edit page.
// target's row fields as stored. The values the form shows, the
// UNMASKED configuration among them, come separately, as a
// targetFormInput.
// //
// It deliberately omits database.Target's raw Config blob: the form // It carries the target's row fields alongside its UNMASKED
// renders named fields, and giving the template the blob as well // configuration, and deliberately omits database.Target's raw
// would put an unreviewed second path to the credential on the page. // Config blob: the form renders named fields, and giving the
// template the blob as well would put an unreviewed second path to
// the credential on the page.
type targetEditView struct { type targetEditView struct {
ID string ID string
Name string Name string
Type database.TargetType Type database.TargetType
Active bool Active bool
MaxRetries int
Config delivery.TargetConfigForm
} }
// HandleTargetEdit shows the form to edit a target. // HandleTargetEdit shows the form to edit a target.
@@ -76,18 +73,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
msg = configUnreadableMessage msg = configUnreadableMessage
} }
form := targetFormInput{ h.renderTargetEdit(w, r, webhook, target, cfg, msg)
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
}
h.renderTargetEdit(
w, r, webhook, target, form, msg, http.StatusOK,
)
} }
} }
@@ -115,12 +101,11 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
} }
} }
// applyTargetEdit validates and saves target edits. A refused save // applyTargetEdit validates and saves target edits.
// shows the edit form again with the values submitted and the reason.
// //
// The submission goes through setTargetFromForm, as a new target // The submitted configuration goes through buildTargetConfig, the
// does, so an edited destination is SSRF-validated exactly as a new // same builder the create path uses, so an edited destination is
// one is. // SSRF-validated exactly as a new one is.
// //
// The target's type is not editable. Each type stores a different // The target's type is not editable. Each type stores a different
// configuration shape and its delivery history is recorded against // configuration shape and its delivery history is recorded against
@@ -133,13 +118,16 @@ func (h *Handlers) applyTargetEdit(
webhook database.Webhook, webhook database.Webhook,
target *database.Target, target *database.Target,
) { ) {
in := targetFormInputFrom(r) name := r.PostFormValue("name")
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
// edited is the target as the submission leaves it; target stays return
// as stored, for the page shown again when the save is refused. }
edited := *target
errMsg, err := h.setTargetFromForm(r.Context(), &edited, in) configJSON, errMsg, err := h.buildTargetConfig(
r.Context(), target.Type, targetFormInputFrom(r),
)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err) h.serverError(w, r, "failed to encode target config", err)
@@ -147,26 +135,45 @@ func (h *Handlers) applyTargetEdit(
} }
if errMsg != "" { if errMsg != "" {
h.renderTargetEdit( http.Error(w, errMsg, http.StatusBadRequest)
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
)
return return
} }
// Retries are offered only by the forms for target types that
// retry, so an absent field means "this form does not edit
// retries" rather than "set them to zero". Reading it
// unconditionally would silently disable retries on any target
// saved from a form that does not render the input.
//
// A field that IS submitted but does not parse is a 400, through
// the same validator the create path uses. It is rejected before
// anything is written, so a typo cannot destroy the retry count
// the target is already delivering with.
if r.PostForm.Has("max_retries") {
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
if !ok {
return
}
target.MaxRetries = retries
}
oldName := target.Name
target.Name = name
target.Config = configJSON
// A new name renames the archive file before it is saved (see // A new name renames the archive file before it is saved (see
// delivery.Engine.Rename). If either step fails, it goes back to // delivery.Engine.Rename). If either step fails, it goes back to
// the name that is still stored. // the name that is still stored.
err = h.renameTargetArchive( err = h.renameTargetArchive(target, webhook.Name, oldName, name)
target, webhook.Name, target.Name, edited.Name,
)
if err == nil { if err == nil {
err = h.db.DB().Save(&edited).Error err = h.db.DB().Save(target).Error
} }
if err != nil { if err != nil {
restoreErr := h.renameTargetArchive( restoreErr := h.renameTargetArchive(
target, webhook.Name, edited.Name, target.Name, target, webhook.Name, name, oldName,
) )
if restoreErr != nil { if restoreErr != nil {
h.log.Error( h.log.Error(
@@ -177,8 +184,8 @@ func (h *Handlers) applyTargetEdit(
} }
if errors.Is(err, delivery.ErrArchiveNameTaken) { if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderTargetEdit( http.Error(
w, r, webhook, target, in, w,
"Not saved: "+err.Error()+ "Not saved: "+err.Error()+
". Move that archive out of the data directory, "+ ". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+ "its .db together with any -wal and -shm beside "+
@@ -215,17 +222,15 @@ func (h *Handlers) renameTargetArchive(
return h.archives.Rename(target.ID, webhookName, newName) return h.archives.Rename(target.ID, webhookName, newName)
} }
// renderTargetEdit renders the target edit page for the target as // renderTargetEdit renders the target edit page with an optional
// stored, its form showing form's values, with an optional error // error message.
// message above it.
func (h *Handlers) renderTargetEdit( func (h *Handlers) renderTargetEdit(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
target *database.Target, target *database.Target,
form targetFormInput, cfg delivery.TargetConfigForm,
errMsg string, errMsg string,
status int,
) { ) {
// The template calls Webhook methods, which take pointer // The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a // receivers; html/template cannot address a value stored in a
@@ -233,18 +238,18 @@ func (h *Handlers) renderTargetEdit(
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: &webhook, tmplKeyWebhook: &webhook,
tmplKeyTarget: targetEditView{ tmplKeyTarget: targetEditView{
ID: target.ID, ID: target.ID,
Name: target.Name, Name: target.Name,
Type: target.Type, Type: target.Type,
Active: target.Active, Active: target.Active,
MaxRetries: target.MaxRetries,
Config: cfg,
}, },
tmplKeyTargetForm: form, tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds, tmplKeyError: errMsg,
tmplKeyError: errMsg,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
} }
h.renderTemplateStatus(w, r, targetEditTemplate, data, status) h.renderTemplate(w, r, targetEditTemplate, data)
} }
// ownedTarget resolves the request's sourceID and targetID // ownedTarget resolves the request's sourceID and targetID
-75
View File
@@ -565,77 +565,6 @@ func assertEditRejectsTimeout(
) )
} }
// TestHandleTargetEditSubmit_RefusedFormComesBack refuses an edit of
// a target of each type and checks that the edit form comes back with
// the reason and every value submitted, and that nothing is saved.
func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is what the operator submitted, as a query string.
cases := []struct {
targetType database.TargetType
fields string
reason string
}{
{
database.TargetTypeHTTP,
"name=edited&url=" + editBlockedURL +
"&headers=X-Edited:+kept&timeout=12&max_retries=3",
"Invalid target URL",
},
{
database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid max retries",
},
{
database.TargetTypeDatabase, "name=edited&expiry=7d",
"Invalid archive expiry",
},
{database.TargetTypeLog, "name=", "Name is required"},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, tc.targetType)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
w := submitTargetEdit(env, webhook.ID, target.ID, form)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(t, page, `class="alert-error">`+tc.reason)
// headers is the form's one textarea and expiry its one
// select; every other field is an input.
for field := range form {
shown := `name="` + field + `" value="` + form.Get(field) + `"`
switch field {
case "headers":
shown = ">" + form.Get(field) + "</textarea>"
case "expiry":
shown = `<option value="` + form.Get(field) + `" selected>`
}
assert.Contains(t, page, shown)
}
assert.Equal(
t, target.Name, storedTarget(t, env, target.ID).Name,
"a refused edit must save nothing",
)
})
}
}
// TestHandleTargetEdit_Scoping keeps the edit routes scoped the way // TestHandleTargetEdit_Scoping keeps the edit routes scoped the way
// the delete and toggle routes are: ownership is decided by the // the delete and toggle routes are: ownership is decided by the
// webhook, and the target is then scoped to it. // webhook, and the target is then scoped to it.
@@ -771,10 +700,6 @@ func TestHandleTargetEditSubmit_RenamesArchive(t *testing.T) {
w = submitTargetEdit(env, wh.ID, archive.ID, again) w = submitTargetEdit(env, wh.ID, archive.ID, again)
require.Equal(t, http.StatusConflict, w.Code) require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "archive-taken.db") assert.Contains(t, w.Body.String(), "archive-taken.db")
assert.Contains(
t, w.Body.String(), `name="name" value="Again"`,
"the form comes back with the name submitted",
)
assert.Equal( assert.Equal(
t, renamedTargetName, storedTarget(t, env, archive.ID).Name, t, renamedTargetName, storedTarget(t, env, archive.ID).Name,
) )
@@ -44,9 +44,9 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
form.Set("type", string(targetType)) form.Set("type", string(targetType))
form.Set("url", editBlockedURL) form.Set("url", editBlockedURL)
// A refused add shows the webhook page again, and a // A refused add shows the webhook page again, where
// refused edit the edit page, where the hint is // the hint is HTML-escaped; a refused edit answers in
// HTML-escaped. // plain text.
added := serveTarget( added := serveTarget(
env, http.MethodPost, targetsPath, form, env, http.MethodPost, targetsPath, form,
) )
@@ -76,8 +76,7 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
) )
assert.Equal(t, http.StatusBadRequest, edited.Code) assert.Equal(t, http.StatusBadRequest, edited.Code)
assert.Contains( assert.Contains(
t, edited.Body.String(), t, edited.Body.String(), privateRefusalHint,
html.EscapeString(privateRefusalHint),
) )
}) })
} }
+30
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"net/http"
"strconv" "strconv"
"strings" "strings"
) )
@@ -88,3 +89,32 @@ func retriesErrorMessage(err error) string {
return errRetriesInvalid.Error() + return errRetriesInvalid.Error() +
", or 0 for fire-and-forget" ", or 0 for fire-and-forget"
} }
// targetMaxRetries reads and validates max_retries from a target edit
// submission, answering the request with a 400 and reporting false
// when the value is set but invalid.
//
// It and the create path (newTarget) both use parseMaxRetries and
// retriesErrorMessage, so the two cannot come to disagree about what a
// valid retry count is. The wording matches the timeout control on
// the same submission.
func targetMaxRetries(
w http.ResponseWriter,
r *http.Request,
fallback int,
) (int, bool) {
retries, err := parseMaxRetries(
r.PostFormValue("max_retries"), fallback,
)
if err != nil {
http.Error(
w,
"Invalid max retries: "+retriesErrorMessage(err),
http.StatusBadRequest,
)
return 0, false
}
return retries, true
}
+11 -11
View File
@@ -396,21 +396,21 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
) )
// A slack target exercises the same max_retries field while needing // A slack target exercises the same max_retries field while needing
// only a URL from the edit template, so the test data stays // only Config.URL from the edit template, so the test data stays
// minimal. The Target and TargetForm keys mirror the field names // minimal. The Target key mirrors the field names the template reads
// the template reads off the handler's values. // off the handler's view value.
editBody := renderPage( editBody := renderPage(
t, h, sess, "target_edit.html", map[string]any{ t, h, sess, "target_edit.html", map[string]any{
dataKeyWebhook: webhook, dataKeyWebhook: webhook,
"Target": map[string]any{ "Target": map[string]any{
"ID": "tg-1", "ID": "tg-1",
"Name": "t", "Name": "t",
"Type": "slack", "Type": "slack",
"Active": true, "Active": true,
}, "MaxRetries": 3,
"TargetForm": map[string]any{ "Config": map[string]any{
"URL": "https://hooks.slack.com/services/x", "URL": "https://hooks.slack.com/services/x",
"MaxRetries": "3", },
}, },
dataKeyError: "", dataKeyError: "",
}, },
+88 -269
View File
@@ -48,11 +48,6 @@ const (
shortWidth = 1024 shortWidth = 1024
shortHeight = 450 shortHeight = 450
// A person's double- or triple-click: each press is held a tenth of a
// second, and the next press comes a quarter second after the release.
pressHeld = 100 * time.Millisecond
betweenClicks = 250 * time.Millisecond
// olderBody is the body of the event received before the newest. // olderBody is the body of the event received before the newest.
olderBody = "the older event" olderBody = "the older event"
) )
@@ -72,45 +67,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password") userID, _ := env.seedUser(t, "browser", "browser-password")
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
// The checks share one browser tab, so they run one at a time, in
// this order. A new check is one more line here.
checkAddEntrypoint(ctx, t, page)
checkAddEachTargetType(ctx, t, page)
checkArchiveExpiry(ctx, t, page)
checkRefusedTarget(ctx, t, page)
checkTargetDeliveries(ctx, t, page, target.Name,
"0 in total, 0 in the last 24 hours",
"1 in total, 1 in the last 24 hours")
checkRefusedEdits(ctx, t, page, target.ID)
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
}
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
// userID: an entrypoint, two events, and a target whose delivery of the
// newer event failed once with a 502. It returns the webhook, the older
// and the newer event, and the target.
func seedBrowserWebhook(
t *testing.T, env *testEnv, userID string,
) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
t.Helper()
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create( require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{ &database.Entrypoint{
@@ -134,7 +90,56 @@ func seedBrowserWebhook(
}, },
).Error) ).Error)
return webhook, older, event, target require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
page := srv.URL + "/hook/" + webhook.ID
checkAddEntrypoint(ctx, t, page)
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
)
}
checkRefusedTarget(ctx, t, page)
checkTargetDeliveries(ctx, t, page, target.Name,
"0 in total, 0 in the last 24 hours",
"1 in total, 1 in the last 24 hours")
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems")
} }
// startBrowser starts a headless browser for one test. It returns the // startBrowser starts a headless browser for one test. It returns the
@@ -313,40 +318,6 @@ const (
document.querySelector('form[action$="/targets"]')).keys()]` document.querySelector('form[action$="/targets"]')).keys()]`
) )
// checkAddEachTargetType runs checkAddTarget on a webhook page for each
// target type, in page order.
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
t.Helper()
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values,
)
}
}
// checkAddTarget loads a webhook page and walks the add target form for // checkAddTarget loads a webhook page and walks the add target form for
// one target type. The form shows nothing until Add is clicked; Add // one target type. The form shows nothing until Add is clicked; Add
// shows only the type choice; Cancel there closes it; Next shows the // shows only the type choice; Cancel there closes it; Next shows the
@@ -435,43 +406,6 @@ func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
"%s: Add still shows while the form is open", targetType) "%s: Add still shows while the form is open", targetType)
} }
// checkArchiveExpiry loads a webhook page and checks that the add
// target form's archive expiry starts on never, that the database
// target checkAddTarget added with 720h is listed as 30 days, and that
// its edit form starts on 720h.
func checkArchiveExpiry(ctx context.Context, t *testing.T, url string) {
t.Helper()
const expiry = `form[action$="/targets"] select[name="expiry"]`
row := `//span[text()="added-database"]/ancestor::div[@class="p-4"][1]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
chooseTargetType(ctx, t, "database")
var start, edited string
require.NoError(t, chromedp.Run(
ctx, chromedp.Value(expiry, &start, chromedp.ByQuery),
))
assert.Equal(t, "never", start,
"the add target form's archive expiry does not start on never")
assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+
`/following-sibling::span[text()="30 days"]`),
"a database target added with 720h is not listed as 30 days")
click(ctx, t, row+`//a[text()="Edit"]`)
require.NoError(t, chromedp.Run(
ctx,
chromedp.WaitReady("#expiry", chromedp.ByQuery),
chromedp.Value("#expiry", &edited, chromedp.ByQuery),
))
assert.Equal(t, "720h", edited,
"the edit form does not start on the stored archive expiry")
}
// checkRefusedTarget submits an http target the server refuses, a // checkRefusedTarget submits an http target the server refuses, a
// loopback destination, and checks that the page comes back with the // loopback destination, and checks that the page comes back with the
// form open on the http fields, the values entered and the reason, and // form open on the http fields, the values entered and the reason, and
@@ -555,64 +489,6 @@ func checkTargetDeliveries(
"the row of %s does not show %q failed", name, failed) "the row of %s does not show %q failed", name, failed)
} }
// checkRefusedEdits fills in the target edit page and the webhook edit
// page of a webhook page with values the server refuses, a loopback
// destination and a retention above the longest finite one, which the
// browser lets through. It saves each and checks that the page comes
// back with the reason and every value still in its field. The values
// are keyed by the id of their field.
func checkRefusedEdits(
ctx context.Context, t *testing.T, page, targetID string,
) {
t.Helper()
const reason = `//div[@class="alert-error"]`
edits := []struct {
url string
values map[string]string
}{
{page + "/targets/" + targetID + "/edit", map[string]string{
"#name": "edited-target",
"#url": "http://127.0.0.1/hook",
"#headers": "X-Edited: kept",
"#timeout": "12",
"#max_retries": "3",
}},
{page + "/edit", map[string]string{
"#name": "edited-webhook",
"#description": "kept description",
"#retention_days": "200000",
}},
}
for _, edit := range edits {
require.NoError(t, chromedp.Run(ctx, loadPage(edit.url)))
for field, value := range edit.values {
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(field, value, chromedp.ByQuery),
))
}
click(ctx, t, `//button[text()="Save Changes"]`)
assert.Truef(t, shown(ctx, reason),
"%s: a refused save does not show the reason", edit.url)
for field, value := range edit.values {
var kept string
require.NoError(t, chromedp.Run(
ctx, chromedp.Value(field, &kept, chromedp.ByQuery),
))
assert.Equalf(t, value, kept,
"%s: a refused save does not keep the %s entered",
edit.url, field)
}
}
}
// checkCopy loads a webhook page and checks that the Copy control beside // checkCopy loads a webhook page and checks that the Copy control beside
// its entrypoint's URL is a button, and that clicking it copies the URL // its entrypoint's URL is a button, and that clicking it copies the URL
// and says so: the button reads "Copied" only once the copy succeeded. // and says so: the button reads "Copied" only once the copy succeeded.
@@ -837,13 +713,11 @@ func checkEventLog(
// checkEventSelection loads the event log in a short window and checks // checkEventSelection loads the event log in a short window and checks
// that selecting the ID of its last event, eventID, with the mouse leaves // that selecting the ID of its last event, eventID, with the mouse leaves
// the event as it was, and that its caret toggles it at once. Dragging // the event as it was: dragging over the ID leaves it collapsed, the
// over the ID leaves the event collapsed, and the caret's click expands // caret's next click still expands it, and with the page then scrolled to
// it at once. With the page then scrolled to its end, a double-click on // its end, a triple-click on the ID leaves it expanded and selects that
// the ID that goes on to drag along it, and a triple-click on it, each // ID. Had the triple-click's first click collapsed the event, the page
// leave the event expanded and select that ID. Had a click there // would have got shorter and moved under the pointer.
// collapsed the event, the page would have got shorter and moved under
// the pointer before the next click.
func checkEventSelection( func checkEventSelection(
ctx context.Context, t *testing.T, url, eventID string, ctx context.Context, t *testing.T, url, eventID string,
) { ) {
@@ -852,6 +726,7 @@ func checkEventSelection(
id := `//span[text()="` + eventID + `"]` id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]` row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]` caret := row + `//*[local-name()="svg"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var ( var (
selected, state string selected, state string
@@ -859,38 +734,31 @@ func checkEventSelection(
scrolled float64 scrolled float64
) )
// What is selected, and whether the event's row says it is expanded. // A single click toggles the event half a second later, so this waits
// a second before reading the selection and whether it is expanded.
read := chromedp.Tasks{ read := chromedp.Tasks{
chromedp.Sleep(time.Second),
chromedp.Evaluate(`window.getSelection().toString()`, &selected), chromedp.Evaluate(`window.getSelection().toString()`, &selected),
chromedp.AttributeValue( chromedp.AttributeValue(
row, "aria-expanded", &state, &hasState, chromedp.BySearch, row, "aria-expanded", &state, &hasState, chromedp.BySearch,
), ),
} }
// A click on the ID toggles the event half a second after it, so a
// check that selecting the ID did not toggle it waits a second first.
settle := chromedp.Sleep(time.Second)
// The double-click and the triple-click each start with nothing
// selected, so that their first click waits to toggle the event.
clearSelection := chromedp.Evaluate(
`window.getSelection().removeAllRanges()`, nil,
)
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url), ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
)) ))
selectText(ctx, t, id) selectText(ctx, t, id)
require.NoError(t, chromedp.Run(ctx, settle, read)) require.NoError(t, chromedp.Run(ctx, read))
assert.Equal(t, eventID, selected, "the event's ID cannot be selected") assert.Equal(t, eventID, selected, "the event's ID cannot be selected")
require.True(t, hasState, "the event's row does not say if it is expanded") require.True(t, hasState, "the event's row does not say if it is expanded")
assert.Equal(t, "false", state, "selecting the event's ID expands it") assert.Equal(t, "false", state, "selecting the event's ID expands it")
// The caret's click also clears the selection, so the triple-click's
// first click finds nothing selected, as a person's would.
click(ctx, t, caret) click(ctx, t, caret)
require.NoError(t, chromedp.Run(ctx, read)) assert.True(t, shown(ctx, expanded),
assert.Equal(t, "true", state, "clicking the caret after selecting the ID does not expand the event")
"clicking the caret does not expand the event at once")
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate( require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`, `window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
@@ -898,17 +766,8 @@ func checkEventSelection(
))) )))
require.Positive(t, scrolled, "the event log does not scroll") require.Positive(t, scrolled, "the event log does not scroll")
require.NoError(t, chromedp.Run(ctx, clearSelection))
doubleClickAndDrag(ctx, t, id)
require.NoError(t, chromedp.Run(ctx, settle, read))
assert.Contains(t, selected, eventID,
"a double-click and drag does not select the event's ID")
assert.Equal(t, "true", state,
"a double-click and drag over the event's ID collapses it")
require.NoError(t, chromedp.Run(ctx, clearSelection))
tripleClick(ctx, t, id) tripleClick(ctx, t, id)
require.NoError(t, chromedp.Run(ctx, settle, read)) require.NoError(t, chromedp.Run(ctx, read))
assert.Contains(t, selected, eventID, assert.Contains(t, selected, eventID,
"a triple-click does not select the event's ID") "a triple-click does not select the event's ID")
assert.Equal(t, "true", state, assert.Equal(t, "true", state,
@@ -951,61 +810,34 @@ func checkEventKeyboard(
func selectText(ctx context.Context, t *testing.T, xpath string) { func selectText(ctx context.Context, t *testing.T, xpath string) {
t.Helper() t.Helper()
left, right, y := textEnds(ctx, t, xpath) var box *dom.BoxModel
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, press(left, y, 1), drag(right, y), release(right, y, 1), ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
)) ))
}
// doubleClickAndDrag double-clicks the start of the text of the element // The content box's corners, clockwise from its top left.
// matching an XPath expression, which selects its first word, and keeps left, right := box.Content[0]+1, box.Content[2]-1
// the button down to drag to the text's end, which selects it word by middle := (box.Content[1] + box.Content[5]) / 2
// word. It holds the button for a second, longer than a single click on
// an event's row waits before it toggles the event.
func doubleClickAndDrag(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
left, right, y := textEnds(ctx, t, xpath)
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
press(left, y, 1), chromedp.Sleep(pressHeld), release(left, y, 1), input.DispatchMouseEvent(input.MousePressed, left, middle).
chromedp.Sleep(betweenClicks), WithButton(input.Left).WithButtons(1).WithClickCount(1),
press(left, y, 2), drag(right, y), chromedp.Sleep(time.Second), input.DispatchMouseEvent(input.MouseMoved, right, middle).
release(right, y, 2), WithButton(input.Left).WithButtons(1),
input.DispatchMouseEvent(input.MouseReleased, right, middle).
WithButton(input.Left).WithClickCount(1),
)) ))
} }
// tripleClick clicks three times in the middle of the text of the // tripleClick clicks three times in a row in the middle of the element
// element matching an XPath expression, as a person does to select a // matching an XPath expression, as a person does to select a whole line
// whole line of text. The browser selects a word on the second click and // of text. The browser selects a word on the second click and the whole
// the whole paragraph on the third. // paragraph on the third.
func tripleClick(ctx context.Context, t *testing.T, xpath string) { func tripleClick(ctx context.Context, t *testing.T, xpath string) {
t.Helper() t.Helper()
left, right, y := textEnds(ctx, t, xpath)
x := (left + right) / 2
require.NoError(t, chromedp.Run(
ctx,
press(x, y, 1), chromedp.Sleep(pressHeld), release(x, y, 1),
chromedp.Sleep(betweenClicks),
press(x, y, 2), chromedp.Sleep(pressHeld), release(x, y, 2),
chromedp.Sleep(betweenClicks),
press(x, y, 3), chromedp.Sleep(pressHeld), release(x, y, 3),
))
}
// textEnds returns where on screen the text of the element matching an
// XPath expression starts and ends, just inside its left and right
// edges, and the height of its middle: in that order, the x of its
// start, the x of its end, and the y of both.
func textEnds(
ctx context.Context, t *testing.T, xpath string,
) (float64, float64, float64) {
t.Helper()
var box *dom.BoxModel var box *dom.BoxModel
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
@@ -1013,28 +845,15 @@ func textEnds(
)) ))
// The content box's corners, clockwise from its top left. // The content box's corners, clockwise from its top left.
return box.Content[0] + 1, box.Content[2] - 1, x := (box.Content[0] + box.Content[2]) / 2
(box.Content[1] + box.Content[5]) / 2 y := (box.Content[1] + box.Content[5]) / 2
}
// press presses the left mouse button at x, y, as the nth click of a require.NoError(t, chromedp.Run(
// double- or triple-click. ctx,
func press(x, y float64, nth int64) *input.DispatchMouseEventParams { chromedp.MouseClickXY(x, y, chromedp.ClickCount(1)),
return input.DispatchMouseEvent(input.MousePressed, x, y). chromedp.MouseClickXY(x, y, chromedp.ClickCount(2)),
WithButton(input.Left).WithButtons(1).WithClickCount(nth) chromedp.MouseClickXY(x, y, chromedp.ClickCount(3)),
} ))
// drag moves the pointer to x, y with the left mouse button down.
func drag(x, y float64) *input.DispatchMouseEventParams {
return input.DispatchMouseEvent(input.MouseMoved, x, y).
WithButton(input.Left).WithButtons(1)
}
// release releases the left mouse button at x, y, as the nth click of a
// double- or triple-click.
func release(x, y float64, nth int64) *input.DispatchMouseEventParams {
return input.DispatchMouseEvent(input.MouseReleased, x, y).
WithButton(input.Left).WithClickCount(nth)
} }
// The parts of the new webhook page the checks below find and click. // The parts of the new webhook page the checks below find and click.
+4 -14
View File
@@ -88,23 +88,13 @@ document.addEventListener("alpine:init", function () {
// Toggles on a click, except one that selects text, such as // Toggles on a click, except one that selects text, such as
// selecting an event's ID to copy it. A single click toggles // selecting an event's ID to copy it. A single click toggles
// only after 500 ms, the usual double-click interval, and the // only after 500 ms, the usual double-click interval, and the
// second press of a double- or triple-click cancels that (see // second click of a double- or triple-click cancels that, so
// cancelPendingToggle), so nothing moves under the pointer // nothing moves under the pointer while it selects text.
// while it selects text.
toggleUnlessSelecting(event) { toggleUnlessSelecting(event) {
if (
event.detail === 1 &&
window.getSelection().toString() === ""
) {
this.pendingToggle = setTimeout(() => this.toggle(), 500);
}
},
// Runs when the mouse button goes down, so the second press
// of a double- or triple-click cancels the toggle its first
// click is waiting to make, however long that press lasts.
cancelPendingToggle(event) {
if (event.detail > 1) { if (event.detail > 1) {
clearTimeout(this.pendingToggle); clearTimeout(this.pendingToggle);
} else if (window.getSelection().toString() === "") {
this.pendingToggle = setTimeout(() => this.toggle(), 500);
} }
}, },
get closed() { get closed() {
+2 -9
View File
@@ -203,15 +203,8 @@
<template x-if="isDatabase"> <template x-if="isDatabase">
<div> <div>
<input type="hidden" name="type" value="database"> <input type="hidden" name="type" value="database">
<div class="flex gap-2 items-center"> <input type="text" name="expiry" :value="expiry" placeholder="never" class="input text-sm">
<label class="text-sm text-gray-700">Archive expiry:</label> <p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
<select name="expiry" :value="expiry" class="input text-sm w-24">
{{range .ArchiveExpiryChoices}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</select>
</div>
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
</div> </div>
</template> </template>
<template x-if="isLog"> <template x-if="isLog">
+3 -3
View File
@@ -18,17 +18,17 @@
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group"> <div class="form-group">
<label for="name" class="label">Name</label> <label for="name" class="label">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required class="input"> <input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description" class="label">Description</label> <label for="description" class="label">Description</label>
<textarea id="description" name="description" rows="3" class="input">{{.Description}}</textarea> <textarea id="description" name="description" rows="3" class="input">{{.Webhook.Description}}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="retention_days" class="label">Retention (days)</label> <label for="retention_days" class="label">Retention (days)</label>
<input type="number" id="retention_days" name="retention_days" value="{{.RetentionDays}}" min="0" class="input"> <input type="number" id="retention_days" name="retention_days" value="{{.Webhook.RetentionDays}}" min="0" class="input">
<p class="text-xs text-gray-500 mt-1">Currently {{.Webhook.RetentionLabel}}.{{if .Webhook.RetainsForever}} No events are deleted while retention is set to forever.{{else}} A periodic cleanup permanently deletes events older than this, along with their delivery records.{{end}} Enter 0 to retain events forever; leave blank to keep the current setting.</p> <p class="text-xs text-gray-500 mt-1">Currently {{.Webhook.RetentionLabel}}.{{if .Webhook.RetainsForever}} No events are deleted while retention is set to forever.{{else}} A periodic cleanup permanently deletes events older than this, along with their delivery records.{{end}} Enter 0 to retain events forever; leave blank to keep the current setting.</p>
</div> </div>
+2 -3
View File
@@ -17,7 +17,7 @@
{{range .Events}} {{range .Events}}
<div class="p-4" x-data="collapsible"> <div class="p-4" x-data="collapsible">
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. --> <!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle"> <div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<span class="flex flex-wrap items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span> <span class="text-sm font-mono text-gray-700">{{.ID}}</span>
@@ -36,8 +36,7 @@
</span> </span>
{{end}} {{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span> <span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
<!-- The caret has no text to select, so a click on it toggles at once. --> <svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</span> </span>
+7 -3
View File
@@ -50,9 +50,13 @@
<div x-show="open" x-cloak class="mt-3"> <div x-show="open" x-cloak class="mt-3">
<label for="archive_expiry" class="label">Archive pruning</label> <label for="archive_expiry" class="label">Archive pruning</label>
<select id="archive_expiry" name="archive_expiry" class="input"> <select id="archive_expiry" name="archive_expiry" class="input">
{{range .ArchiveExpiryChoices}} <option value="never"{{if eq .Form.ArchiveExpiry "never"}} selected{{end}}>never</option>
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="1h"{{if eq .Form.ArchiveExpiry "1h"}} selected{{end}}>1h</option>
{{end}} <option value="12h"{{if eq .Form.ArchiveExpiry "12h"}} selected{{end}}>12h</option>
<option value="24h"{{if eq .Form.ArchiveExpiry "24h"}} selected{{end}}>24h</option>
<option value="720h"{{if eq .Form.ArchiveExpiry "720h"}} selected{{end}}>30d</option>
<option value="2160h"{{if eq .Form.ArchiveExpiry "2160h"}} selected{{end}}>90d</option>
<option value="8760h"{{if eq .Form.ArchiveExpiry "8760h"}} selected{{end}}>365d</option>
</select> </select>
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p> <p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
</div> </div>
+9 -13
View File
@@ -17,7 +17,7 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700"> <div class="mb-6 rounded-md bg-gray-50 p-4 text-sm text-gray-700">
This form shows the target's destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked. This form shows the target's stored destination in full, including any credential carried in its URL or headers. It is the only page that does; everywhere else the value is masked.
</div> </div>
{{end}} {{end}}
@@ -26,25 +26,25 @@
<div class="form-group"> <div class="form-group">
<label for="name" class="label">Name</label> <label for="name" class="label">Name</label>
<input type="text" id="name" name="name" value="{{.TargetForm.Name}}" required class="input"> <input type="text" id="name" name="name" value="{{.Target.Name}}" required class="input">
</div> </div>
{{if eq .Target.Type "http"}} {{if eq .Target.Type "http"}}
<div class="form-group"> <div class="form-group">
<label for="url" class="label">Destination URL</label> <label for="url" class="label">Destination URL</label>
<input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input"> <input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
<p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p> <p class="text-xs text-gray-500 mt-1">Revalidated on save; destinations that resolve to private or link-local addresses are rejected.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="headers" class="label">Headers</label> <label for="headers" class="label">Headers</label>
<textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.TargetForm.Headers}}</textarea> <textarea id="headers" name="headers" rows="4" class="input" placeholder="Authorization: Bearer ...">{{.Target.Config.Headers}}</textarea>
<p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code>, <code>Trailer</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored. Headers set here are dropped if a redirect leaves the destination's own origin, so a credential cannot follow one to another host.</p> <p class="text-xs text-gray-500 mt-1">One <code>Name: value</code> per line, sent with every delivery. Leave blank for none. <code>Host</code>, <code>Content-Length</code>, <code>Transfer-Encoding</code>, <code>Connection</code>, <code>Trailer</code> and <code>User-Agent</code> are set by the delivery engine and are rejected here rather than silently ignored. Headers set here are dropped if a redirect leaves the destination's own origin, so a credential cannot follow one to another host.</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="timeout" class="label">Timeout (seconds)</label> <label for="timeout" class="label">Timeout (seconds)</label>
<input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input"> <input type="number" id="timeout" name="timeout" value="{{.Target.Config.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p> <p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
</div> </div>
{{end}} {{end}}
@@ -52,7 +52,7 @@
{{if eq .Target.Type "slack"}} {{if eq .Target.Type "slack"}}
<div class="form-group"> <div class="form-group">
<label for="url" class="label">Webhook URL</label> <label for="url" class="label">Webhook URL</label>
<input type="url" id="url" name="url" value="{{.TargetForm.URL}}" required class="input"> <input type="url" id="url" name="url" value="{{.Target.Config.URL}}" required class="input">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p> <p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Revalidated on save.</p>
</div> </div>
{{end}} {{end}}
@@ -60,19 +60,15 @@
{{if eq .Target.Type "database"}} {{if eq .Target.Type "database"}}
<div class="form-group"> <div class="form-group">
<label for="expiry" class="label">Archive Expiry</label> <label for="expiry" class="label">Archive Expiry</label>
<select id="expiry" name="expiry" class="input"> <input type="text" id="expiry" name="expiry" value="{{.Target.Config.Expiry}}" placeholder="never" class="input">
{{range .ArchiveExpiryChoices}} <p class="text-xs text-gray-500 mt-1">"never" (the default when blank) keeps archived rows forever, or a Go duration like "720h" prunes older rows.</p>
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
</div> </div>
{{end}} {{end}}
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="form-group"> <div class="form-group">
<label for="max_retries" class="label">Max retries</label> <label for="max_retries" class="label">Max retries</label>
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input"> <input type="number" id="max_retries" name="max_retries" value="{{.Target.MaxRetries}}" min="0" max="20" class="input">
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
{{end}} {{end}}