Compare commits

4 Commits
Author SHA1 Message Date
sneak a0b315c912 Event log: a double- or triple-click on an event's ID leaves it as it was
check / check (push) Successful in 3m13s
The first click of a double- or triple-click comes before its selection and
toggled the event. A later click of it that finds text selected now puts the
event back as it was before the first click. The browser test checks this
with a triple-click on the ID, checks the caret and the row's aria-expanded
both while the event is expanded and while it is collapsed, and reaches the
row with Tab from the page's Back link before pressing Enter and Space. The
event log's checks are split in three to stay within the length limit.

Model: opus-5-5
2026-10-02 21:23:21 +00:00
clawbot 7402339afb Event log: an event's ID can be selected without toggling it (closes #348)
An event's row in the event log was a button element, whose text a browser
does not let be selected, and a drag over the event's ID toggled the event.
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. The browser test now also clicks the
row's caret, selects the ID with the mouse, and uses the keyboard.

Model: opus-5-5
2026-10-02 21:23:21 +00:00
clawbot ff24638ba4 Show each entrypoint's last event and event count on the webhook page (closes #393)
check / check (push) Successful in 3m12s
The entrypoint list showed no sign of whether anything uses an entrypoint, so an operator with several could not tell which senders are live before deactivating or deleting one. Each entrypoint now shows when its last event arrived, relative with the UTC time on hover, or "never", and how many events arrived through it within the webhook's retention. The last-event time comes from a new entrypoint_totals row written in the transaction that stores the event and left by retention, so a sender quieter than the retention period does not read "never". The count is one grouped query over a new index. Resubmitted copies count in neither. Pre-1.0: schema changed in place.

Model: opus-5-5
2026-10-02 23:16:07 +02:00
clawbot da75950e91 Targets section: one Add, then a target type and Next, then that type's fields (closes #370)
check / check (push) Successful in 3m18s
The targets section of the webhook page showed its add form open with every field, a URL included for types that use none. It now lists only its targets until "+ Add" is clicked; "+ Add" shows a choice of target type with Next and Cancel on one row, and Next shows the name and only that type's fields. The database and log types show no URL field and the server stores none for them; the slack form gains its retry field. A refused target brings the page back with the form open on its type, the values entered and the reason, and Cancel empties it. An encoding failure stays a logged 500. Target validation returns its message, so the new-webhook page can reuse it.

Model: opus-5-5
2026-10-02 22:24:38 +02:00
20 changed files with 1286 additions and 440 deletions
+65 -39
View File
@@ -1127,7 +1127,7 @@ unconditionally against whatever files it finds:
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`, - the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
`Entrypoint`, `Target` `Entrypoint`, `Target`
- each event database when it is lazily opened — `Event`, `Delivery`, - each event database when it is lazily opened — `Event`, `Delivery`,
`DeliveryResult`, `EventTotals`, `TargetTotals` `DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals`
- each archive database on every open and reopen - each archive database on every open and reopen
There is no schema version table, no migration ledger, and no down There is no schema version table, no migration ledger, and no down
@@ -1344,10 +1344,13 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`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 and the event log A browser test in `internal/server` loads the webhook page and the event log
under the real policy and checks that: both add forms stay hidden until Add is under the real policy and checks that: the add entrypoint form stays hidden
clicked; choosing Slack in the add target form leaves the HTTP fields out of until Add is clicked; for every target type, the targets section's Add shows
what it submits, also after leaving the page and going back to it, when the only a choice of type with Next and Cancel, Next shows only that type's fields
browser restores the choice; the Copy button beside an entrypoint URL reads (no url field for `database` or `log`), Cancel at either step closes the form,
and saving adds the target; a refused target comes back with its form open, the
values entered and the reason, and after Cancel the next Add starts with an
empty form and no reason; the Copy button beside an entrypoint URL reads
"Copied" once clicked; an entrypoint's Edit button shows its edit form in place "Copied" once clicked; an entrypoint's Edit button shows its edit form in place
of its description and hides until the form closes, Cancel hides the form and of its description and hides until the form closes, Cancel hides the form and
drops what was typed, as does leaving the page and going back to it, and Save drops what was typed, as does leaving the page and going back to it, and Save
@@ -1529,6 +1532,9 @@ tier** (event ingestion, delivery, and logging).
│ ┌──────────────┐ (one row per target: running counts │ │ ┌──────────────┐ (one row per target: running counts │
│ │ TargetTotals │ of its deliveries) │ │ │ TargetTotals │ of its deliveries) │
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────────────┐ (one row per entrypoint: when the │
│ │ EntrypointTotals │ last event arrived on its URL) │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
@@ -1642,6 +1648,11 @@ different event sources that all feed into the same processing pipeline
(e.g., one entrypoint for GitHub, another for Stripe, both routing to (e.g., one entrypoint for GitHub, another for Stripe, both routing to
the same targets). the same targets).
The webhook page shows, for each entrypoint, when the last event arrived
on its URL, which retention leaves in place, or "never" if none ever has,
and how many events arrived on it within the webhook's retention period.
A resubmitted event did not arrive on the URL and counts in neither.
#### Target #### Target
A delivery destination for events. Each target defines where and how A delivery destination for events. Each target defines where and how
@@ -1851,10 +1862,11 @@ retries) is individually logged for full observability.
**Relations:** Belongs to Delivery. **Relations:** Belongs to Delivery.
#### EventTotals and TargetTotals #### EventTotals, TargetTotals and EntrypointTotals
Running counts in each event database, read by the statistics pane at the Running counts in each event database, read by the statistics pane at the
top of the webhook page and by the webhook list. `EventTotals` is one row: top of the webhook page and by the webhook list, and each entrypoint's last
event, read by the webhook page's entrypoint list. `EventTotals` is one row:
| Field | Type | Description | | Field | Type | Description |
| ---------------- | --------- | ----------- | | ---------------- | --------- | ----------- |
@@ -1873,18 +1885,26 @@ top of the webhook page and by the webhook list. `EventTotals` is one row:
| `deliveries_removed` | integer | Its deliveries retention has deleted | | `deliveries_removed` | integer | Its deliveries retention has deleted |
| `failed_removed` | integer | Its failed deliveries retention has deleted | | `failed_removed` | integer | Its failed deliveries retention has deleted |
Each count changes in the transaction that writes or deletes the rows it `EntrypointTotals` is one row per entrypoint, created by the first event
counts. The pane's lifetime events are `events`, and its lifetime that arrives on its URL:
deliveries and failures are `deliveries` and `failed` summed over the
targets; each figure within retention is the same less what retention | Field | Type | Description |
removed, so neither needs the rows themselves. Its last event is | --------------- | --------- | ----------- |
`last_event_at`, written in the transaction that stores the event, so it | `entrypoint_id` | UUID | The entrypoint (primary key) |
still shows once retention has removed every event. Its last-10-minutes and | `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention |
last-24-hours figures are counted from the `events` and `deliveries`
indexes over just that window, the deliveries in one query grouped by Each count changes in the transaction that writes or deletes the rows it counts,
target. Its failure percentage for a window is the deliveries that became and each `last_event_at` in the transaction that stores the event. The pane's
`failed` in it out of all that became `delivered` or `failed` in it, and lifetime events are `events`, and its lifetime deliveries and failures are
a dash when none did. `deliveries` and `failed` summed over the targets; each figure within retention
is the same less what retention removed, so neither needs the rows themselves.
Its last event is `last_event_at` in `EventTotals`, so it still shows once
retention has removed every event; each entrypoint's last event, from
`EntrypointTotals`, does too. Its last-10-minutes and last-24-hours figures are
counted from the `events` and `deliveries` indexes over just that window, the
deliveries in one query grouped by target. Its failure percentage for a window
is the deliveries that became `failed` in it out of all that became `delivered`
or `failed` in it, and a dash when none did.
The webhook list at `/hooks` shows three of the pane's figures for each The webhook list at `/hooks` shows three of the pane's figures for each
webhook: its events within retention and its last event, both from webhook: its events within retention and its last event, both from
@@ -1906,26 +1926,31 @@ tags, so `AutoMigrate` creates them on a fresh database:
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | | `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | | `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | | `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period |
| `events` | `created_at` | Retention, which selects expired events by age | | `events` | `created_at` | Retention, which selects expired events by age |
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves
leaves it out. SQLite keeps no statistics on these tables, and without them it it out. SQLite keeps no statistics on these tables, and without them it rates
rates the `deleted_at` index, which every live row matches, above an index on the `deleted_at` index, which every live row matches, above an index on a column
a column matched against several values or compared with a range. So every matched against several values or compared with a range. So every index but the
index but the last also covers `deleted_at`. It comes second in the `event_id` last also covers `deleted_at`. It comes second in the `event_id` and
and `delivery_id` indexes, so that retention can use them without it. The event `delivery_id` indexes, so that retention can use them without it. The event
log's count, the one query on the `resubmitted_from_id` index, always carries log's count, the one query on the `resubmitted_from_id` index, always carries
`deleted_at IS NULL` and uses both columns. In the statistics' `events` index `deleted_at IS NULL` and uses both columns. The entrypoint list's count, the one
`deleted_at` comes first, because they compare `created_at` with a range (`>=`) query on the `entrypoint_id` index, uses all four, `resubmitted_from_id IS NULL`
and SQLite narrows by a range only on the last column it uses. leaving out resubmitted copies and `created_at` last because it compares it with
a range (`>=`). In the statistics' `events` index `deleted_at` comes first,
because they compare `created_at` with a range (`>=`) and SQLite narrows by a
range only on the last column it uses.
#### Common Fields #### Common Fields
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes Every entity except `Setting`, `EventTotals`, `TargetTotals` and
these fields from `BaseModel`. `Setting` is a bare key-value row with no `EntrypointTotals` includes these fields from `BaseModel`. `Setting` is a bare
`id`, no timestamps and no soft delete, and the two totals tables hold key-value row with no `id`, no timestamps and no soft delete. Of the three
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id` totals tables, `event_totals` holds counts and `last_event_at`, keyed by a
and by `target_id`: numeric `id`; `target_totals` holds counts, keyed by `target_id`; and
`entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`:
| Field | Type | Description | | Field | Type | Description |
| ------------ | --------- | ----------- | | ------------ | --------- | ----------- |
@@ -1967,8 +1992,9 @@ encryption key is generated and stored, and an `admin` user is created.
- **Events** — captured incoming webhook payloads - **Events** — captured incoming webhook payloads
- **Deliveries** — event-to-target pairings and their status - **Deliveries** — event-to-target pairings and their status
- **DeliveryResults** — individual delivery attempt logs - **DeliveryResults** — individual delivery attempt logs
- **EventTotals** and **TargetTotals** — running counts of the above, - **EventTotals**, **TargetTotals** and **EntrypointTotals** — running
the deliveries per target, kept through retention counts of the above, the deliveries per target, and each entrypoint's
last event, kept through retention
Per-webhook databases are created automatically when a webhook is Per-webhook databases are created automatically when a webhook is
created. They are managed by the `WebhookDBManager` component, which created. They are managed by the `WebhookDBManager` component, which
@@ -3033,7 +3059,7 @@ webhooker/
│ │ ├── model_event.go # Event entity (per-webhook DB) │ │ ├── model_event.go # Event entity (per-webhook DB)
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB) │ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB) │ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB) │ │ ├── model_totals.go # EventTotals, TargetTotals and EntrypointTotals (per-webhook DB)
│ │ ├── model_apikey.go # APIKey entity │ │ ├── model_apikey.go # APIKey entity
│ │ ├── password.go # Argon2id hashing and verification │ │ ├── password.go # Argon2id hashing and verification
│ │ ├── retention.go # Retention reaper (per-webhook event expiry) │ │ ├── retention.go # Retention reaper (per-webhook event expiry)
@@ -3309,9 +3335,9 @@ check, see [The login endpoint](#the-login-endpoint).
`ENTRYPOINT` script, which sets the data directory's owner and mode `ENTRYPOINT` script, which sets the data directory's owner and mode
before the app starts; the image's health check; and `docker exec`, before the app starts; the image's health check; and `docker exec`,
unless given `--user` unless given `--user`
- GORM soft deletes on every entity that carries `BaseModel`, which is - GORM soft deletes on every entity that carries `BaseModel`, which is all of
all of them but `Setting`, `EventTotals` and `TargetTotals` (data them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals`
preserved for audit) (data preserved for audit)
### Shutdown ### Shutdown
@@ -233,6 +233,43 @@ func TestResubmitCountUsesItsIndex(t *testing.T) {
"(resubmitted_from_id=? AND deleted_at=?)") "(resubmitted_from_id=? AND deleted_at=?)")
} }
// TestEntrypointEventsUseTheirIndex does the same for the webhook
// page's count, for each entrypoint, of the events that arrived on its
// URL since the retention cutoff (addEntrypointEvents in the
// handlers), which must come from the index alone. It passes 25
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
func TestEntrypointEventsUseTheirIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
entrypoints := make([]string, 25)
for i := range entrypoints {
entrypoints[i] = uuid.New().String()
}
var rows []struct{ Events int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
entrypoints).
Where("created_at >= ?", time.Now()).
Group("entrypoint_id").Find(&rows),
"COVERING INDEX idx_events_entrypoint_id "+
"(entrypoint_id=? AND deleted_at=? AND "+
"resubmitted_from_id=? AND created_at>?)")
}
// assertPlanUses asserts that SQLite's plan for a statement GORM built // assertPlanUses asserts that SQLite's plan for a statement GORM built
// in a dry run, run with the same SQL and arguments GORM would send, // in a dry run, run with the same SQL and arguments GORM would send,
// names each of the given indexes. // names each of the given indexes.
+9 -6
View File
@@ -20,12 +20,15 @@ type Event struct {
// has no deleted_at condition and uses the index on created_at // has no deleted_at condition and uses the index on created_at
// alone. The other tables keep the unindexed BaseModel created_at. // alone. The other tables keep the unindexed BaseModel created_at.
// DeletedAt is also the second column of the resubmitted_from_id // DeletedAt is also the second column of the resubmitted_from_id
// index, for the reason DeliveryResult gives. // index, for the reason DeliveryResult gives. The entrypoint_id
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"` // index, for the webhook page's entrypoint list, has it second too,
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"` // resubmitted_from_id third, and created_at last, which the list
// compares with a range.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"` EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data // Request data
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
@@ -44,7 +47,7 @@ type Event struct {
// existed. It is not a foreign key: the source event can be // existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is // reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way. // kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"` ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so // Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON. // Webhook and Entrypoint are left out of the JSON.
+37
View File
@@ -52,6 +52,21 @@ func (TargetTotals) TableName() string {
return "target_totals" return "target_totals"
} }
// EntrypointTotals is one row per entrypoint, created by the first
// event that arrives on its URL: when the newest such event arrived,
// which retention leaves as it is. A resubmitted copy did not arrive
// on the URL and does not change it.
type EntrypointTotals struct {
EntrypointID string `gorm:"type:uuid;primaryKey"`
LastEventAt time.Time `gorm:"not null"`
}
// TableName names the table AddEntrypointTotals updates.
func (EntrypointTotals) TableName() string {
return "entrypoint_totals"
}
// AddEventTotals adds each count in add to the webhook's event totals, // AddEventTotals adds each count in add to the webhook's event totals,
// and records add.LastEventAt as when the newest event arrived if it is // and records add.LastEventAt as when the newest event arrived if it is
// set. Call it on the transaction that writes or deletes the events it // set. Call it on the transaction that writes or deletes the events it
@@ -97,3 +112,25 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
return nil return nil
} }
// AddEntrypointTotals records add.LastEventAt as when the newest event
// arrived on the URL of the entrypoint add.EntrypointID names, creating
// its row the first time. Call it on the transaction that stores the
// event.
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
err := tx.Exec(
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
VALUES (?, ?)
ON CONFLICT (entrypoint_id) DO UPDATE SET
last_event_at = excluded.last_event_at`,
add.EntrypointID, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of entrypoint %s: %w",
add.EntrypointID, err,
)
}
return nil
}
+7
View File
@@ -111,6 +111,13 @@ func (w *Webhook) RetainsForever() bool {
return retainsForever(w.RetentionDays) return retainsForever(w.RetentionDays)
} }
// RetentionCutoff returns the time before which this webhook's events
// have expired, as the reaper computes it, and false when the webhook
// retains them forever.
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
return retentionCutoff(now, w.RetentionDays)
}
// RetentionLabel returns the webhook's retention policy as display // RetentionLabel returns the webhook's retention policy as display
// text, so that no template has to know about the sentinel value. // text, so that no template has to know about the sentinel value.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
+1 -1
View File
@@ -3,7 +3,7 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, // Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
// TargetTotals) live in // TargetTotals, EntrypointTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+2 -2
View File
@@ -49,7 +49,7 @@ var ErrSidecarNotRemoved = errors.New(
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals). // running totals of them (EventTotals, TargetTotals, EntrypointTotals).
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
@@ -381,7 +381,7 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
+77
View File
@@ -1,6 +1,11 @@
package handlers package handlers
import ( import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -11,6 +16,21 @@ type EntrypointView struct {
Path string Path string
Description string Description string
Active bool Active bool
// Events is how many events arrived on the entrypoint's URL within
// the webhook's retention period. LastEvent is when the newest
// event ever to arrive on it did, relative, and LastEventUTC the
// full time; both are empty when none ever did.
Events int64
LastEvent string
LastEventUTC string
}
// entrypointEvents is one entrypoint's count read by
// addEntrypointEvents.
type entrypointEvents struct {
EntrypointID string
Events int64
} }
// NewEntrypointViews projects entrypoints for rendering. // NewEntrypointViews projects entrypoints for rendering.
@@ -32,3 +52,60 @@ func NewEntrypointViews(
return views return views
} }
// addEntrypointEvents fills in each view's event figures from the
// webhook's event database: when the last event arrived on its URL,
// from its EntrypointTotals row, and how many events arrived on it
// since the webhook's retention cutoff, counted in one query over the
// events' entrypoint_id index. Resubmitted copies did not arrive on
// the URL and are left out of both.
func addEntrypointEvents(
webhookDB *gorm.DB,
webhook *database.Webhook,
views []EntrypointView,
now time.Time,
) error {
ids := make([]string, len(views))
byID := make(map[string]*EntrypointView, len(views))
for i := range views {
ids[i] = views[i].ID
byID[views[i].ID] = &views[i]
}
var totals []database.EntrypointTotals
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
if err != nil {
return fmt.Errorf("reading entrypoint totals: %w", err)
}
query := webhookDB.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
cutoff, finite := webhook.RetentionCutoff(now)
if finite {
query = query.Where("created_at >= ?", cutoff)
}
var counts []entrypointEvents
err = query.Group("entrypoint_id").Find(&counts).Error
if err != nil {
return fmt.Errorf("counting events by entrypoint: %w", err)
}
for _, row := range totals {
view := byID[row.EntrypointID]
view.LastEvent = humanize.Time(row.LastEventAt)
view.LastEventUTC =
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
for _, row := range counts {
byID[row.EntrypointID].Events = row.Events
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// entrypointRow returns the part of a rendered webhook page from an
// entrypoint's URL to the next entrypoint's, which holds its figures.
func entrypointRow(t *testing.T, page, entrypointID string) string {
t.Helper()
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
require.True(t, found)
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
return row
}
// lastEventShown matches an entrypoint row's last event arriving at at.
func lastEventShown(at time.Time) string {
return `Last Event:</span>\s*<span title="` +
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
}
// eventsShown matches an entrypoint row's count of n events.
func eventsShown(n int) string {
return `Events Within Retention:</span>\s*<span>` +
strconv.Itoa(n) + `</span>`
}
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
// on the webhook page shows its own figures: how many events arrived
// through it within the webhook's retention period, leaving out one
// older than that, and when the newest arrived, or "never" for an
// entrypoint with none.
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
entrypoint := func() *database.Entrypoint {
ep := &database.Entrypoint{
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// event stores an event that arrived on ep's URL age ago and
// records it as ep's last event, as the receiver does.
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
e := &database.Event{
WebhookID: wh.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
}
e.CreatedAt = time.Now().Add(-age)
require.NoError(t,
webhookDB.Omit(clause.Associations).Create(e).Error)
require.NoError(t, database.AddEntrypointTotals(webhookDB,
database.EntrypointTotals{
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
}))
return e.CreatedAt
}
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
event(busy, 8*24*time.Hour) // older than the 7 days kept
event(busy, 3*time.Hour)
busyLast := event(busy, time.Hour)
quietLast := event(quiet, 2*24*time.Hour)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
entrypointRow(t, body, unused.ID))
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
}
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
// that once retention has removed every event that arrived on an
// entrypoint's URL, the entrypoint still shows when the last one
// arrived rather than "never".
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(0), row)
}
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
// resubmitted copy, which did not arrive on the entrypoint's URL,
// changes neither the entrypoint's last event nor its count.
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
ep := seedEntrypoint(t, db, wh.ID)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
arrived := events[0].CreatedAt
require.Equal(t, http.StatusSeeOther,
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
require.Len(t, listEvents(t, webhookDB), 2)
var totals database.EntrypointTotals
require.NoError(t, webhookDB.Take(&totals).Error)
assert.True(t, arrived.Equal(totals.LastEventAt))
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
assert.Regexp(t, lastEventShown(arrived), row)
assert.Regexp(t, eventsShown(1), row)
}
+9 -12
View File
@@ -143,22 +143,20 @@ func (s *Handlers) RenderTemplateForTest(
// BuildSlackTargetConfigForTest exposes // BuildSlackTargetConfigForTest exposes
// buildSlackTargetConfig for use in the handlers_test package. // buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest( func (s *Handlers) BuildSlackTargetConfigForTest(
w http.ResponseWriter, ctx context.Context,
r *http.Request,
targetURL string, targetURL string,
) (string, error) { ) (string, string, error) {
return s.buildSlackTargetConfig(w, r, targetURL) return s.buildSlackTargetConfig(ctx, targetURL)
} }
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig // BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package, taking the form fields // for use in the handlers_test package, taking the form fields
// an HTTP target's configuration is built from. // an HTTP target's configuration is built from.
func (s *Handlers) BuildHTTPTargetConfigForTest( func (s *Handlers) BuildHTTPTargetConfigForTest(
w http.ResponseWriter, ctx context.Context,
r *http.Request,
targetURL, headers, timeout string, targetURL, headers, timeout string,
) (string, error) { ) (string, string, error) {
return s.buildHTTPTargetConfig(w, r, targetFormInput{ return s.buildHTTPTargetConfig(ctx, targetFormInput{
URL: targetURL, URL: targetURL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
@@ -168,9 +166,8 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
// BuildDatabaseTargetConfigForTest exposes // BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test // buildDatabaseTargetConfig for use in the handlers_test
// package. // package.
func (s *Handlers) BuildDatabaseTargetConfigForTest( func BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string, expiry string,
) (string, error) { ) (string, string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry) return buildDatabaseTargetConfig(expiry)
} }
+19 -42
View File
@@ -314,16 +314,12 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext( cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
context.Background(), http.MethodPost, "/", nil) t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://93.184.216.34/services/T00/B00/xxx",
) )
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code) assert.Empty(t, errMsg)
assert.Contains(t, cfg, "webhookUrl") assert.Contains(t, cfg, "webhookUrl")
} }
@@ -337,17 +333,13 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext( cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
context.Background(), http.MethodPost, "/", nil) t.Context(), "http://169.254.169.254/latest/meta-data/",
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://169.254.169.254/latest/meta-data/",
) )
require.Error(t, err) require.NoError(t, err)
assert.Contains(t, errMsg, "Invalid target URL")
assert.Empty(t, cfg) assert.Empty(t, cfg)
assert.Equal(t, http.StatusBadRequest, w.Code)
} }
func TestRenderTemplate(t *testing.T) { func TestRenderTemplate(t *testing.T) {
@@ -444,29 +436,22 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// Empty expiry: the keep-forever default, empty config. // Empty expiry: the keep-forever default, empty config.
w := httptest.NewRecorder() cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Empty(t, cfg) assert.Empty(t, cfg)
// Explicit never is stored as config. // Explicit never is stored as config.
w = httptest.NewRecorder() cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg) assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config. // A positive duration is stored as config.
w = httptest.NewRecorder() cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg) assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
} }
@@ -475,22 +460,14 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
) { ) {
t.Parallel() t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
for _, bad := range []string{"nonsense", "7d", "-5h"} { for _, bad := range []string{"nonsense", "7d", "-5h"} {
w := httptest.NewRecorder() cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
require.Error(t, err, "expiry %q", bad) require.NoError(t, err)
assert.Empty(t, cfg) assert.Contains(
assert.Equal( t, errMsg, "Invalid archive expiry",
t, http.StatusBadRequest, w.Code, "expiry %q should be refused", bad,
"expiry %q should be rejected with 400", bad,
) )
assert.Empty(t, cfg)
} }
} }
+155 -160
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -38,9 +39,6 @@ type WebhookListItem struct {
EventsUnreadable bool EventsUnreadable bool
} }
// errMissingURL signals that a required URL was not provided.
var errMissingURL = errors.New("missing URL")
// parseRetentionDays interprets a retention_days form value. It // parseRetentionDays interprets a retention_days form value. It
// returns the number of days, or, for a value it refuses, the message // returns the number of days, or, for a value it refuses, the message
// the create and edit forms show; the message is empty when the value // the create and edit forms show; the message is empty when the value
@@ -460,15 +458,20 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return return
} }
h.renderSourceDetail(w, r, webhook) h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
} }
} }
// renderSourceDetail loads and renders a source detail page. // renderSourceDetail loads and renders a source detail page. With a
// targetErr, it is the page shown again for a refused add target
// form: it answers 400, and the form opens on targetForm's type with
// its values and the message.
func (h *Handlers) renderSourceDetail( func (h *Handlers) renderSourceDetail(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
targetForm targetFormInput,
targetErr string,
) { ) {
var entrypoints []database.Entrypoint var entrypoints []database.Entrypoint
@@ -482,6 +485,8 @@ func (h *Handlers) renderSourceDetail(
"webhook_id = ?", webhook.ID, "webhook_id = ?", webhook.ID,
).Find(&targets) ).Find(&targets)
entrypointViews := NewEntrypointViews(entrypoints)
var events []RecentEventView var events []RecentEventView
if h.dbMgr.DBExists(webhook.ID) { if h.dbMgr.DBExists(webhook.ID) {
@@ -500,6 +505,15 @@ func (h *Handlers) renderSourceDetail(
return return
} }
err = addEntrypointEvents(
webhookDB, &webhook, entrypointViews, time.Now(),
)
if err != nil {
h.serverError(w, r, "failed to count entrypoint events", err)
return
}
} }
scheme := "http" scheme := "http"
@@ -520,14 +534,21 @@ 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": NewEntrypointViews(entrypoints), "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),
"TargetForm": targetForm,
"TargetError": targetErr,
} }
h.renderTemplate(w, r, "source_detail.html", data) status := http.StatusOK
if targetErr != "" {
status = http.StatusBadRequest
}
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
} }
// HandleSourceEdit shows the form to edit a webhook. // HandleSourceEdit shows the form to edit a webhook.
@@ -1518,64 +1539,27 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
} }
} }
// processTargetCreate validates and creates a new target. // processTargetCreate validates and creates a new target. A refused
// submission shows the webhook page again, with the add target form
// open on the chosen type, the values entered, and the reason.
func (h *Handlers) processTargetCreate( func (h *Handlers) processTargetCreate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
) { ) {
// The body size cap is enforced by the MaxBodySize middleware, in := targetFormInputFrom(r)
// which runs before CSRF parses the form.
//
// Every field here is read with PostFormValue, not FormValue.
// FormValue falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and
// the request line, unlike the body, is what logs, proxies,
// Referer headers and error trackers record.
name := r.PostFormValue("name")
targetType := database.TargetType(r.PostFormValue("type"))
if name == "" { target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
http.Error(
w, "Name is required", http.StatusBadRequest,
)
return
}
if !isValidTargetType(targetType) {
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return
}
configJSON, err := h.buildTargetConfig(
w, r, targetType, targetFormInputFrom(r),
)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return return
} }
// A new target has no stored retry count, so an absent field if errMsg != "" {
// takes the fire-and-forget default. A field the operator filled h.renderSourceDetail(w, r, webhook, in, errMsg)
// in with something invalid is rejected rather than becoming
// that default.
maxRetries, ok := targetMaxRetries(w, r, 0)
if !ok {
return
}
target := &database.Target{ return
WebhookID: webhook.ID,
Name: name,
Type: targetType,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
} }
err = h.db.DB().Create(target).Error err = h.db.DB().Create(target).Error
@@ -1591,6 +1575,49 @@ func (h *Handlers) processTargetCreate(
) )
} }
// newTarget validates a new target for a webhook and returns the row
// to create, or, when it refuses the target, the message the form
// shows. An error is the server's fault, not a refusal: the accepted
// configuration could not be encoded. Every form that creates a
// target goes through here, so they all accept and refuse the same
// things.
func (h *Handlers) newTarget(
ctx context.Context,
webhookID string,
in targetFormInput,
) (*database.Target, string, error) {
if in.Name == "" {
return nil, "Name is required", nil
}
if !isValidTargetType(in.Type) {
return nil, "Invalid target type", nil
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
if err != nil || errMsg != "" {
return nil, errMsg, err
}
// 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
}
// isValidTargetType checks whether the target type is supported. // isValidTargetType checks whether the target type is supported.
func isValidTargetType(tt database.TargetType) bool { func isValidTargetType(tt database.TargetType) bool {
switch tt { switch tt {
@@ -1622,11 +1649,16 @@ func pageOrFirst(s string) int {
return v return v
} }
// targetFormInput carries the raw form values describing a target's // targetFormInput carries the raw values of a target form. Both the
// configuration. Both the create and the edit path fill one and hand // create and the edit path fill one and hand it to buildTargetConfig,
// it to buildTargetConfig, so neither can come to validate a // so neither can come to validate a destination differently from the
// destination differently from the other. // other. A refused add target form is shown again from it.
type targetFormInput struct { type targetFormInput struct {
// Name is the target's name.
Name string
// Type is the type chosen on the add target form. The edit form
// has none: a target's stored type decides.
Type database.TargetType
// URL is the destination for an HTTP target and the webhook URL // URL is the destination for an HTTP target and the webhook URL
// for a Slack target. // for a Slack target.
URL string URL string
@@ -1635,13 +1667,15 @@ type targetFormInput struct {
Headers string Headers string
// Timeout is an HTTP target's per-request timeout in seconds. // Timeout is an HTTP target's per-request timeout in seconds.
Timeout string Timeout string
// MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string
// Expiry is a database (archive) target's row expiry. // Expiry is a database (archive) target's row expiry.
Expiry string Expiry string
} }
// targetFormInputFrom reads the configuration fields from a request // targetFormInputFrom reads a target form from a request body. The
// body. The body size cap is enforced by the MaxBodySize middleware, // body size cap is enforced by the MaxBodySize middleware, which runs
// which runs before CSRF parses the form. // before CSRF parses the form.
// //
// Every field is read with PostFormValue, not FormValue. FormValue // Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let // falls back to the query string, which would let
@@ -1653,38 +1687,38 @@ type targetFormInput struct {
// tokens. // tokens.
func targetFormInputFrom(r *http.Request) targetFormInput { func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{ return targetFormInput{
URL: r.PostFormValue("url"), Name: r.PostFormValue("name"),
Headers: r.PostFormValue("headers"), Type: database.TargetType(r.PostFormValue("type")),
Timeout: r.PostFormValue("timeout"), URL: r.PostFormValue("url"),
Expiry: r.PostFormValue("expiry"), Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
} }
} }
// buildTargetConfig builds the JSON config string for a target from // buildTargetConfig builds the JSON config string for a target from
// the submitted form values, writing its own 4xx response on // the submitted form values, or returns the message the form shows
// rejection. Which fields of in apply depends on the target type. // for a value it refuses. An error is the server's fault, not a
// refusal: the accepted configuration could not be encoded. Which
// fields of in apply depends on the target type; a type without a URL
// ignores any URL submitted.
func (h *Handlers) buildTargetConfig( func (h *Handlers) buildTargetConfig(
w http.ResponseWriter, ctx context.Context,
r *http.Request,
targetType database.TargetType, targetType database.TargetType,
in targetFormInput, in targetFormInput,
) (string, error) { ) (string, string, error) {
switch targetType { switch targetType {
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(w, r, in) return h.buildHTTPTargetConfig(ctx, in)
case database.TargetTypeSlack: case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL) return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, r, in.Expiry) return buildDatabaseTargetConfig(in.Expiry)
case database.TargetTypeLog: case database.TargetTypeLog:
return "", nil return "", "", nil
default: default:
http.Error( return "", "Invalid target type", nil
w, "Invalid target type",
http.StatusBadRequest,
)
return "", errMissingURL
} }
} }
@@ -1692,92 +1726,73 @@ func (h *Handlers) buildTargetConfig(
// SSRF-validated destination plus the optional headers and timeout // SSRF-validated destination plus the optional headers and timeout
// the delivery path honours. // the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig( func (h *Handlers) buildHTTPTargetConfig(
w http.ResponseWriter, ctx context.Context,
r *http.Request,
in targetFormInput, in targetFormInput,
) (string, error) { ) (string, string, error) {
err := h.validateTargetURL( errMsg := h.validateTargetURL(
w, r, in.URL, "URL is required for HTTP targets", ctx, in.URL, "URL is required for HTTP targets",
) )
if err != nil { if errMsg != "" {
return "", err return "", errMsg, nil
} }
headers, err := delivery.ParseTargetHeaders(in.Headers) headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil { if err != nil {
http.Error( return "", fmt.Sprintf("Invalid headers: %v", err), nil
w,
"Invalid headers: "+err.Error(),
http.StatusBadRequest,
)
return "", err
} }
timeout, err := delivery.ParseTargetTimeout(in.Timeout) timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil { if err != nil {
http.Error( return "", fmt.Sprintf("Invalid timeout: %v", err), nil
w,
"Invalid timeout: "+err.Error(),
http.StatusBadRequest,
)
return "", err
} }
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{ configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL, URL: in.URL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
}) })
return configJSON, "", err
} }
// buildSlackTargetConfig builds config JSON for a Slack target, // buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL. // whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig( func (h *Handlers) buildSlackTargetConfig(
w http.ResponseWriter, ctx context.Context,
r *http.Request,
targetURL string, targetURL string,
) (string, error) { ) (string, string, error) {
err := h.validateTargetURL( errMsg := h.validateTargetURL(
w, r, targetURL, ctx, targetURL,
"Webhook URL is required for Slack targets", "Webhook URL is required for Slack targets",
) )
if err != nil { if errMsg != "" {
return "", err return "", errMsg, nil
} }
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{ configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
WebhookURL: targetURL, WebhookURL: targetURL,
}) })
return configJSON, "", err
} }
// validateTargetURL rejects an empty or SSRF-blocked destination, // validateTargetURL refuses an empty or SSRF-blocked destination,
// writing the 400 itself. missingMsg is the error shown when no URL // returning the message the form shows, or "" when the destination
// is given. // is accepted. missingMsg is the message for no URL at all.
// //
// It is the single point at which a user-supplied destination enters // It is the single point at which a user-supplied destination enters
// the SSRF guard, on create and on edit alike. An edit path that // the SSRF guard, on create and on edit alike. An edit path that
// reached storage without passing through here would reopen the hole // reached storage without passing through here would reopen the hole
// the guard closes. // the guard closes.
func (h *Handlers) validateTargetURL( func (h *Handlers) validateTargetURL(
w http.ResponseWriter, ctx context.Context,
r *http.Request,
targetURL, missingMsg string, targetURL, missingMsg string,
) error { ) string {
if targetURL == "" { if targetURL == "" {
http.Error( return missingMsg
w,
missingMsg,
http.StatusBadRequest,
)
return errMissingURL
} }
err := h.ssrf.ValidateTargetURL( err := h.ssrf.ValidateTargetURL(ctx, targetURL)
r.Context(), targetURL,
)
if err != nil { if err != nil {
// The submitted URL can be a credential (a Slack // The submitted URL can be a credential (a Slack
// incoming webhook URL is a bearer token), so the log // incoming webhook URL is a bearer token), so the log
@@ -1803,25 +1818,16 @@ func (h *Handlers) validateTargetURL(
"egress to your own network\" in the README)." "egress to your own network\" in the README)."
} }
http.Error(w, msg, http.StatusBadRequest) return msg
return err
} }
return nil return ""
} }
// marshalTargetConfig serialises a target configuration for storage, // marshalTargetConfig serialises a target configuration for storage.
// writing a 500 itself if it cannot. func marshalTargetConfig(cfg any) (string, error) {
func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg) configBytes, err := json.Marshal(cfg)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return "", err return "", err
} }
@@ -1829,35 +1835,24 @@ func (h *Handlers) marshalTargetConfig(
} }
// buildDatabaseTargetConfig builds config JSON for a database // buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry (a form value read by // (archive) target. The optional expiry is validated here, at
// the caller, which bounds the request body) is validated here, // creation time, so an unparseable value is refused instead of
// at creation time, so an unparseable value is rejected with a // failing every subsequent delivery. An empty expiry yields an
// 400 instead of failing every subsequent delivery. An empty // empty config (the keep-forever default).
// expiry yields an empty config (the keep-forever default). func buildDatabaseTargetConfig(expiry string) (string, string, error) {
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry) expiry = strings.TrimSpace(expiry)
if expiry == "" { if expiry == "" {
return "", nil return "", "", nil
} }
err := delivery.ValidateArchiveExpiry(expiry) err := delivery.ValidateArchiveExpiry(expiry)
if err != nil { if err != nil {
http.Error( return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
w,
"Invalid archive expiry: "+err.Error(),
http.StatusBadRequest,
)
return "", err
} }
return h.marshalTargetConfig( configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
w, r, map[string]any{"expiry": expiry},
) return configJSON, "", err
} }
// HandleEntrypointDelete handles deleting an entrypoint. // HandleEntrypointDelete handles deleting an entrypoint.
+154
View File
@@ -0,0 +1,154 @@
package handlers_test
import (
"html"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestHandleTargetCreate_EveryType adds a target of each type. Each
// submission carries a url: only the http and slack types store one.
func TestHandleTargetCreate_EveryType(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is the rest of each submission, as a query string.
cases := []struct {
targetType database.TargetType
fields string
wantConfig string
wantRetries int
}{
{
database.TargetTypeHTTP, "timeout=12&max_retries=3",
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
},
{
database.TargetTypeSlack, "max_retries=4",
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
},
{
database.TargetTypeDatabase, "expiry=720h",
`{"expiry":"720h"}`, 0,
},
{database.TargetTypeLog, "", "", 0},
}
for _, tc := range cases {
t.Run(string(tc.targetType), func(t *testing.T) {
t.Parallel()
webhook := seedWebhookWithRetention(t, env.db, 30)
form, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
form.Set("name", "every-type")
form.Set("type", string(tc.targetType))
form.Set("url", editOriginalURL)
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.Equal(t, tc.targetType, targets[0].Type)
assert.Equal(t, tc.wantRetries, targets[0].MaxRetries)
if tc.wantConfig == "" {
assert.Empty(t, targets[0].Config)
} else {
assert.JSONEq(t, tc.wantConfig, targets[0].Config)
}
})
}
}
// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each
// type and checks that the webhook page comes back with the add target
// form open on that type, the values entered, and the reason.
func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
// fields is what the operator typed, as a query string.
cases := []struct {
targetType database.TargetType
fields string
reason string
}{
{
database.TargetTypeHTTP,
"name=private&url=" + editBlockedURL +
"&timeout=12&max_retries=3",
"Invalid target URL",
},
{
database.TargetTypeSlack, "name=no-url&max_retries=4",
"Webhook URL is required for Slack targets",
},
{
database.TargetTypeDatabase, "name=archive&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)
typed, err := url.ParseQuery(tc.fields)
require.NoError(t, err)
form := url.Values{}
form.Set("type", string(tc.targetType))
for field := range typed {
form.Set(field, typed.Get(field))
}
w := serveTarget(
env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
page := w.Body.String()
assert.Contains(
t, page, `data-type="`+string(tc.targetType)+`"`,
)
assert.Contains(t, page, html.EscapeString(tc.reason))
// Each value comes back in a data attribute of the targets
// section named after its field (max_retries as
// data-max-retries), except url, which comes back in
// data-destination; templates/source_detail.html says why.
for field := range typed {
attr := "data-" + strings.ReplaceAll(field, "_", "-")
if field == "url" {
attr = "data-destination"
}
assert.Contains(
t, page, attr+`="`+
html.EscapeString(typed.Get(field))+`"`,
)
}
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
})
}
}
+11 -6
View File
@@ -120,18 +120,23 @@ func (h *Handlers) applyTargetEdit(
) { ) {
name := r.PostFormValue("name") name := r.PostFormValue("name")
if name == "" { if name == "" {
http.Error( http.Error(w, "Name is required", http.StatusBadRequest)
w, "Name is required", http.StatusBadRequest,
)
return return
} }
configJSON, err := h.buildTargetConfig( configJSON, errMsg, err := h.buildTargetConfig(
w, r, target.Type, targetFormInputFrom(r), r.Context(), target.Type, targetFormInputFrom(r),
) )
if err != nil { if err != nil {
// buildTargetConfig has already written the response. h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest)
return return
} }
@@ -1,6 +1,7 @@
package handlers_test package handlers_test
import ( import (
"html"
"net/http" "net/http"
"net/url" "net/url"
"testing" "testing"
@@ -43,12 +44,16 @@ 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, where
// the hint is HTML-escaped; a refused edit answers in
// plain text.
added := serveTarget( added := serveTarget(
env, http.MethodPost, targetsPath, form, env, http.MethodPost, targetsPath, form,
) )
assert.Equal(t, http.StatusBadRequest, added.Code) assert.Equal(t, http.StatusBadRequest, added.Code)
assert.Contains( assert.Contains(
t, added.Body.String(), privateRefusalHint, t, added.Body.String(),
html.EscapeString(privateRefusalHint),
) )
form.Set("url", editOriginalURL) form.Set("url", editOriginalURL)
+5 -4
View File
@@ -90,13 +90,14 @@ func retriesErrorMessage(err error) string {
", or 0 for fire-and-forget" ", or 0 for fire-and-forget"
} }
// targetMaxRetries reads and validates max_retries from a target form // targetMaxRetries reads and validates max_retries from a target edit
// submission, answering the request with a 400 and reporting false // submission, answering the request with a 400 and reporting false
// when the value is set but invalid. // when the value is set but invalid.
// //
// Both the create and the edit path go through here, so the two // It and the create path (newTarget) both use parseMaxRetries and
// cannot come to disagree about what a valid retry count is. The // retriesErrorMessage, so the two cannot come to disagree about what a
// wording matches the timeout control on the same submission. // valid retry count is. The wording matches the timeout control on
// the same submission.
func targetMaxRetries( func targetMaxRetries(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
+13
View File
@@ -326,6 +326,19 @@ func (h *Handlers) createAndFanOut(
return nil, nil, err return nil, nil, err
} }
// A resubmitted copy did not arrive on its entrypoint's URL, so it
// leaves the entrypoint's last event as it is.
if src.ResubmittedFromID == nil {
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
})
if err != nil {
tx.Rollback()
return nil, nil, err
}
}
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return nil, nil, fmt.Errorf( return nil, nil, fmt.Errorf(
+305 -100
View File
@@ -90,12 +90,43 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
page := srv.URL + "/hook/" + webhook.ID page := srv.URL + "/hook/" + webhook.ID
checkAddForms(ctx, t, page) checkAddEntrypoint(ctx, t, page)
checkTargetType(ctx, t, page+"/events")
// 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)
checkCopy(ctx, t, page) checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events") checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page) checkRecentEvents(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkEventSelection(ctx, t, page+"/events", event.ID)
checkEventKeyboard(ctx, t, page+"/events", event.ID)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
@@ -236,115 +267,194 @@ func click(ctx context.Context, t *testing.T, xpath string) {
)) ))
} }
// checkAddForms loads a webhook page and checks that each section's add // checkAddEntrypoint loads a webhook page and checks that the add
// form stays hidden until the Add button beside its heading is clicked. // entrypoint form stays hidden until the Add button beside its heading
// The click looks for a button element there, so it also checks that // is clicked. The click looks for a button element there, so it also
// Add is one. // checks that Add is one.
func checkAddForms(ctx context.Context, t *testing.T, url string) { func checkAddEntrypoint(ctx context.Context, t *testing.T, url string) {
t.Helper()
form := `form[action$="/entrypoints"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, form),
"the add entrypoint form shows before Add is clicked")
click(ctx, t, `//h2[text()="Entrypoints"]/following-sibling::button`)
assert.True(t, shown(ctx, form),
"the add entrypoint form stays hidden when Add is clicked")
}
// publicTargetURL is a destination the server accepts for an http or
// slack target. It is a literal public address, so accepting it needs
// no DNS.
const publicTargetURL = "https://93.184.216.34/hook"
// The parts of the targets section's add target form the checks below
// find and click. Add is the button beside the Targets heading; each
// Cancel is found from the button beside it, since both are on the
// page at once.
const (
addTarget = `//h2[text()="Targets"]/following-sibling::button`
typeSelect = `//select[@aria-label="Target type"]`
nextButton = `//button[text()="Next"]`
cancelChoice = nextButton + `/following-sibling::button[text()="Cancel"]`
saveButton = `//form[contains(@action, "/targets")]//button[text()="Save"]`
cancelFields = saveButton + `/following-sibling::button[text()="Cancel"]`
targetName = `form[action$="/targets"] input[name="name"]`
submittedKeys = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
)
// checkAddTarget loads a webhook page and walks the add target form for
// one target type. The form shows nothing until Add is clicked; Add
// shows only the type choice; Cancel there closes it; Next shows the
// type's own fields in place of the choice, and the form then submits
// exactly fields, so a field another type uses, such as url, is absent;
// Cancel closes it again. It then adds a target of the type, filling in
// values, and checks that the section lists it with that type.
func checkAddTarget(
ctx context.Context,
t *testing.T,
url, targetType string,
fields []string,
values map[string]string,
) {
t.Helper() t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url))) require.NoError(t, chromedp.Run(ctx, loadPage(url)))
sections := []struct{ heading, form string }{ assert.Truef(t, hidden(ctx, typeSelect),
{"Entrypoints", `form[action$="/entrypoints"]`}, "%s: the type choice shows before Add is clicked", targetType)
{"Targets", `form[action$="/targets"]`}, assert.Truef(t, hidden(ctx, targetName),
"%s: the fields show before Add is clicked", targetType)
click(ctx, t, addTarget)
assert.Truef(t, shown(ctx, typeSelect),
"%s: Add does not show the type choice", targetType)
assert.Truef(t, hidden(ctx, targetName),
"%s: Add shows the fields before Next", targetType)
click(ctx, t, cancelChoice)
assert.Truef(t, hidden(ctx, typeSelect),
"%s: Cancel does not close the type choice", targetType)
chooseTargetType(ctx, t, targetType)
var submitted []string
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(submittedKeys, &submitted),
))
assert.Equalf(t, fields, submitted,
"%s: the form does not submit exactly the type's fields", targetType)
click(ctx, t, cancelFields)
assert.Truef(t, hidden(ctx, targetName),
"%s: Cancel does not close the fields", targetType)
assert.Truef(t, shown(ctx, addTarget),
"%s: Add does not come back after Cancel", targetType)
name := "added-" + targetType
chooseTargetType(ctx, t, targetType)
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(targetName, name, chromedp.ByQuery),
))
for field, value := range values {
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
`form[action$="/targets"] [name="`+field+`"]`, value,
chromedp.ByQuery,
)))
} }
for _, s := range sections { click(ctx, t, saveButton)
assert.Truef( assert.Truef(t, shown(ctx, `//span[text()="`+name+
t, hidden(ctx, s.form), `"]/following-sibling::div/span[text()="`+targetType+`"]`),
"%s: the add form shows before Add is clicked", s.heading, "%s: the added target is not listed with its type", targetType)
)
click(ctx, t, `//h2[text()="`+s.heading+
`"]/following-sibling::button`)
assert.Truef(
t, shown(ctx, s.form),
"%s: the add form stays hidden when Add is clicked", s.heading,
)
}
} }
// checkTargetType chooses Slack in the open add target form and checks // chooseTargetType clicks Add, picks targetType and clicks Next, and
// what the form would then submit: one url field, the Slack one, and // checks that the type's fields then show in place of the type choice.
// not the HTTP url, headers or timeout, which are hidden and disabled. func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
// t.Helper()
// It then opens the page at elsewhere and goes back. The browser loads
// the webhook page again and restores the form as it was left, Slack click(ctx, t, addTarget)
// chosen, without a change event; the form must again show and submit require.NoError(t, chromedp.Run(
// Slack's fields, not the HTTP ones. ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) { ))
click(ctx, t, nextButton)
assert.Truef(t, shown(ctx, targetName),
"%s: Next does not show the fields", targetType)
assert.Truef(t, hidden(ctx, typeSelect),
"%s: Next leaves the type choice showing", targetType)
assert.Truef(t, hidden(ctx, addTarget),
"%s: Add still shows while the form is open", targetType)
}
// checkRefusedTarget submits an http target the server refuses, a
// loopback destination, and checks that the page comes back with the
// form open on the http fields, the values entered and the reason, and
// that after Cancel the next Add starts with an empty form and no
// reason.
func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
t.Helper() t.Helper()
const ( const (
chooseSlack = `(() => { refusedURL = "http://127.0.0.1/hook"
const type = document.querySelector('select[name="type"]'); urlField = `form[action$="/targets"] input[name="url"]`
type.value = "slack"; reason = `//div[@class="alert-error"]`
type.dispatchEvent(new Event("change"));
})()`
chosen = `document.querySelector('select[name="type"]').value`
howLoaded = `performance.getEntriesByType("navigation")[0].type`
submitted = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
httpURL = `input[placeholder="https://example.com/webhook"]`
) )
slackFields := strings.Fields("csrf_token name type max_retries url") require.NoError(t, chromedp.Run(ctx, loadPage(url)))
var fields []string chooseTargetType(ctx, t, "http")
require.NoError(t, chromedp.Run(
ctx,
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
))
click(ctx, t, saveButton)
assert.True(t, shown(ctx, reason),
"a refused target does not show the reason")
var name, typed string
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
chromedp.Evaluate(chooseSlack, nil), chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Evaluate(submitted, &fields), chromedp.Value(urlField, &typed, chromedp.ByQuery),
)) ))
assert.Equal( assert.Equal(t, "refused", name,
t, slackFields, fields, "a refused target does not keep the name entered")
"with Slack chosen, the HTTP fields must not be submitted", assert.Equal(t, refusedURL, typed,
) "a refused target does not keep the url entered")
assert.True(t, shown(ctx, targetName),
"a refused target does not come back with the form open")
assert.True(t, hidden(ctx, typeSelect),
"a refused target comes back on the type choice")
var loaded, restored string click(ctx, t, cancelFields)
chooseTargetType(ctx, t, "http")
assert.True(t, hidden(ctx, reason),
"after Cancel, the next Add still shows the reason")
// Going back waits for the load event, after which the browser has
// restored the form.
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
loadPage(elsewhere), chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.NavigateBack(), chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
chromedp.Evaluate(howLoaded, &loaded),
chromedp.Evaluate(chosen, &restored),
)) ))
// A page the browser kept in memory and showed again as it was assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
// would prove nothing here. assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
require.Equal(
t, "back_forward", loaded,
"going back, the browser did not load the page again",
)
require.Equal(
t, "slack", restored,
"going back, the browser did not restore the chosen type",
)
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
assert.True(t, shown(ctx, slackURL),
"going back with Slack chosen, the Slack fields are not shown")
assert.True(t, hidden(ctx, httpURL),
"going back with Slack chosen, the HTTP fields are shown")
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t, slackFields, fields,
"going back with Slack chosen, the HTTP fields must not be submitted",
)
} }
// checkCopy loads a webhook page and checks that the Copy control beside // checkCopy loads a webhook page and checks that the Copy control beside
@@ -384,9 +494,13 @@ func checkEntrypointEdit(
) { ) {
t.Helper() t.Helper()
// Cancel and Save are found inside the edit form, since the add
// target form has buttons of the same names.
const ( const (
editForm = `form[action$="/edit"]` editForm = `form[action$="/edit"]`
input = editForm + ` input[name="description"]` input = editForm + ` input[name="description"]`
cancelEdit = `//form[contains(@action, "/edit")]/button[text()="Cancel"]`
saveEdit = `//form[contains(@action, "/edit")]/button[text()="Save"]`
description = `//span[text()="Entrypoint"]` description = `//span[text()="Entrypoint"]`
edit = `//button[text()="Edit"]` edit = `//button[text()="Edit"]`
) )
@@ -407,7 +521,7 @@ func checkEntrypointEdit(
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery), ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
)) ))
click(ctx, t, `//button[text()="Cancel"]`) click(ctx, t, cancelEdit)
assert.True(t, hidden(ctx, editForm), assert.True(t, hidden(ctx, editForm),
"clicking Cancel does not hide the edit form") "clicking Cancel does not hide the edit form")
assert.True(t, shown(ctx, description), assert.True(t, shown(ctx, description),
@@ -449,7 +563,7 @@ func checkEntrypointEdit(
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery), ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
)) ))
click(ctx, t, `//button[text()="Save"]`) click(ctx, t, saveEdit)
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`), assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
"saving the edit form does not change the description") "saving the edit form does not change the description")
@@ -498,21 +612,23 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
// checkEventLog loads the event log and checks an event's row. Clicking // checkEventLog loads the event log and checks an event's row. Clicking
// its ID expands the event, and in there clicking its delivery shows the // its ID expands the event, and in there clicking its delivery shows the
// delivery's attempts and clicking again hides them. Clicking the row's // delivery's attempts and clicking again hides them. Clicking the row's
// caret collapses the event. Selecting the ID with the mouse leaves the // caret collapses the event, clicking it again expands it, and clicking
// event collapsed, and the caret's next click expands it. Clicking the // the ID again collapses it. While the event is expanded the row says so
// ID again collapses it. From the keyboard, Enter on the row expands // and its caret is turned up, and while it is collapsed neither.
// the event and Space collapses it.
func checkEventLog( func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string, ctx context.Context, t *testing.T, url, eventID, targetName string,
) { ) {
t.Helper() t.Helper()
// The event's row shows its ID and ends with its caret, and its // The event's row shows its ID and ends with its caret, which turns
// Resubmit form is in the part that expands. The delivery's row // up with Tailwind's rotate-180 class, and its Resubmit form is in
// there shows the target's name. // the part that expands. The delivery's row there shows the target's
// name.
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"]`
caretUp := caret + `[contains(@class, "rotate-180")]`
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
expanded := `form[action$="/` + eventID + `/resubmit"]` expanded := `form[action$="/` + eventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]` deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]` attempt := `//span[text()="Attempt 1"]`
@@ -524,6 +640,10 @@ func checkEventLog(
click(ctx, t, id) click(ctx, t, id)
assert.True(t, shown(ctx, expanded), assert.True(t, shown(ctx, expanded),
"clicking the event's ID does not expand it") "clicking the event's ID does not expand it")
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
"the expanded event's row does not say it is expanded")
assert.True(t, shown(ctx, caretUp),
"the expanded event's caret does not turn up")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
@@ -538,12 +658,41 @@ func checkEventLog(
click(ctx, t, caret) click(ctx, t, caret)
assert.True(t, hidden(ctx, expanded), assert.True(t, hidden(ctx, expanded),
"clicking the caret does not collapse the event") "clicking the caret does not collapse the event")
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
"the collapsed event's row does not say it is collapsed")
assert.True(t, shown(ctx, caretDown),
"the collapsed event's caret stays turned up")
click(ctx, t, caret)
assert.True(t, shown(ctx, expanded),
"clicking the caret again does not expand the event")
click(ctx, t, id)
assert.True(t, hidden(ctx, expanded),
"clicking the event's ID again does not collapse it")
}
// checkEventSelection loads the event log and checks that selecting an
// event's ID with the mouse leaves the event as it was: dragging over the
// ID leaves it collapsed, the caret's next click still expands it, and a
// triple-click on the ID then leaves it expanded.
func checkEventSelection(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var ( var (
selected, state string selected, state string
hasState bool hasState bool
) )
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
selectText(ctx, t, id) selectText(ctx, t, id)
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
@@ -556,19 +705,50 @@ func checkEventLog(
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)
assert.True(t, shown(ctx, expanded), assert.True(t, shown(ctx, expanded),
"clicking the caret after selecting the ID does not expand the event") "clicking the caret after selecting the ID does not expand the event")
click(ctx, t, id) tripleClick(ctx, t, id)
assert.True(t, hidden(ctx, expanded), require.NoError(t, chromedp.Run(
"clicking the event's ID again does not collapse it") ctx,
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
chromedp.AttributeValue(
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
),
))
assert.Contains(t, selected, eventID,
"a triple-click does not select the event's ID")
assert.Equal(t, "true", state,
"a triple-click selecting the event's ID collapses it")
}
// checkEventKeyboard loads the event log and checks that Tab from the
// page's Back link reaches the event's row, the first after it, and that
// Enter then expands the event and Space collapses it.
func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
back := `//a[contains(text(), "Back to")]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
chromedp.Focus(row, chromedp.BySearch), loadPage(url),
chromedp.KeyEvent(kb.Enter), chromedp.Focus(back, chromedp.BySearch),
chromedp.KeyEvent(kb.Tab),
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
)) ))
require.Contains(t, focused, eventID,
"Tab from the Back link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, shown(ctx, expanded), "Enter does not expand the event") assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" "))) require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
@@ -602,6 +782,31 @@ func selectText(ctx context.Context, t *testing.T, xpath string) {
)) ))
} }
// tripleClick clicks three times in a row in the middle of the element
// matching an XPath expression, as a person does to select a whole line
// of text. The browser selects a word on the second click and the whole
// paragraph on the third.
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
var box *dom.BoxModel
require.NoError(t, chromedp.Run(
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
))
// The content box's corners, clockwise from its top left.
x := (box.Content[0] + box.Content[2]) / 2
y := (box.Content[1] + box.Content[5]) / 2
require.NoError(t, chromedp.Run(
ctx,
chromedp.MouseClickXY(x, y, chromedp.ClickCount(1)),
chromedp.MouseClickXY(x, y, chromedp.ClickCount(2)),
chromedp.MouseClickXY(x, y, chromedp.ClickCount(3)),
))
}
// checkMobileMenu loads a page in a phone-sized window and checks that // checkMobileMenu loads a page in a phone-sized window and checks that
// the menu button opens and closes the mobile menu. // the menu button opens and closes the mobile menu.
func checkMobileMenu(ctx context.Context, t *testing.T, url string) { func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
+73 -24
View File
@@ -76,17 +76,31 @@ document.addEventListener("alpine:init", function () {
window.Alpine.data("collapsible", function () { window.Alpine.data("collapsible", function () {
return { return {
open: false, open: false,
// Whether it was open before its latest single click, which
// may be the first of a double- or triple-click.
wasOpen: false,
init() { init() {
this.open = this.$root.hasAttribute("data-open"); this.open = this.$root.hasAttribute("data-open");
}, },
toggle() { toggle() {
this.open = !this.open; this.open = !this.open;
}, },
// Toggles, except on a click that ends a text selection, such // Toggles on a click, except one that selects text, such as
// as selecting an event's ID to copy it. // selecting an event's ID to copy it. A double- or
toggleUnlessSelecting() { // triple-click selects its text on its second click, after
if (window.getSelection().toString() === "") { // its first may have toggled, so a later click that finds
// text selected puts back the state from before the first.
toggleUnlessSelecting(event) {
const selecting = window.getSelection().toString() !== "";
if (event.detail === 1) {
this.wasOpen = this.open;
}
if (!selecting) {
this.toggle(); this.toggle();
} else if (event.detail > 1) {
this.open = this.wasOpen;
} }
}, },
get closed() { get closed() {
@@ -99,24 +113,65 @@ document.addEventListener("alpine:init", function () {
}; };
}); });
// The add target form. Only the chosen type's fields show, and the // The targets section's add target form, in three steps: closed,
// others are disabled so that the form does not submit them. // choosing a type, then filling in that type's fields. targetType
// is empty until Next takes it from the type select.
// //
// The type is read from the type select when Alpine starts, when the // The reason and the fields' values come from the properties below
// select changes, and on pageshow. Going back to the page, the // rather than from the markup, because each type's fields are made
// browser restores the type chosen before without a change event, // afresh from the markup whenever that type is chosen. A refused
// in some browsers only after Alpine has started, but always before // submission comes back with its type, reason and values in the
// pageshow. // section's data attributes, and starts on that type's fields with
// them. Cancel empties these properties and resets the form, which
// holds whatever was typed, so the next Add starts with an empty
// form and no reason.
window.Alpine.data("targetForm", function () { window.Alpine.data("targetForm", function () {
return { return {
choosing: false,
targetType: "", targetType: "",
reason: "",
name: "",
url: "",
headers: "",
timeout: "",
maxRetries: "",
expiry: "",
init() { init() {
this.readType(); const refused = this.$root.dataset;
this.targetType = refused.type;
this.reason = refused.reason;
this.name = refused.name;
this.url = refused.destination;
this.headers = refused.headers;
this.timeout = refused.timeout;
this.maxRetries = refused.maxRetries;
this.expiry = refused.expiry;
}, },
readType() { add() {
this.targetType = this.$root.querySelector( this.choosing = true;
'select[name="type"]' },
).value; next() {
this.targetType = this.$refs.type.value;
this.choosing = false;
},
cancel() {
this.choosing = false;
this.targetType = "";
this.reason = "";
this.name = "";
this.url = "";
this.headers = "";
this.timeout = "";
this.maxRetries = "";
this.expiry = "";
this.$refs.form.reset();
},
get filling() {
return this.targetType !== "";
},
get closed() {
return !this.choosing && !this.filling;
}, },
get isHttp() { get isHttp() {
return this.targetType === "http"; return this.targetType === "http";
@@ -127,14 +182,8 @@ document.addEventListener("alpine:init", function () {
get isDatabase() { get isDatabase() {
return this.targetType === "database"; return this.targetType === "database";
}, },
get notHttp() { get isLog() {
return !this.isHttp; return this.targetType === "log";
},
get notSlack() {
return !this.isSlack;
},
get notDatabase() {
return !this.isDatabase;
}, },
}; };
}); });
+104 -43
View File
@@ -97,6 +97,18 @@
</div> </div>
<!-- The URL above is the entrypoint's credential: <!-- The URL above is the entrypoint's credential:
anyone holding it can submit events. --> anyone holding it can submit events. -->
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Last Event:</span>
{{if .LastEvent}}
<span title="{{.LastEventUTC}}">{{.LastEvent}}</span>
{{else}}
<span>never</span>
{{end}}
</div>
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Events Within Retention:</span>
<span>{{.Events}}</span>
</div>
</div> </div>
{{else}} {{else}}
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div> <div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
@@ -104,11 +116,23 @@
</div> </div>
</div> </div>
<!-- Targets --> <!-- Targets. The data attributes carry a refused add target
<div class="card" x-data="collapsible"> submission's type, reason and values back to the form. The
URL is data-destination, not data-url: html/template treats
an attribute named like a URL as a link and would rewrite
a refused ftp: or javascript: value. -->
<div class="card" x-data="targetForm"
data-type="{{.TargetForm.Type}}"
data-reason="{{.TargetError}}"
data-name="{{.TargetForm.Name}}"
data-destination="{{.TargetForm.URL}}"
data-headers="{{.TargetForm.Headers}}"
data-timeout="{{.TargetForm.Timeout}}"
data-max-retries="{{.TargetForm.MaxRetries}}"
data-expiry="{{.TargetForm.Expiry}}">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Targets</h2> <h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button type="button" @click="toggle" class="btn-small"> <button type="button" @click="add" x-show="closed" class="btn-small">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
@@ -116,48 +140,85 @@
</button> </button>
</div> </div>
<!-- Add target form --> <!-- Add target form. Add shows the type choice; Next replaces
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> it with the chosen type's fields. Each type's fields,
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3"> and the hidden type field submitted with them, exist
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> only while that type is chosen. A refused submission
<div class="flex gap-2"> comes back open on its type, with the values entered;
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1"> Cancel empties the form. The type choice's p-2, narrower
<select name="type" @change="readType" class="input text-sm w-32"> than an input's own padding, keeps it, Next and Cancel on
<option value="http">HTTP</option> one row on a 360px-wide phone. -->
<option value="slack">Slack</option> <form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
<option value="database">Database</option> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<option value="log">Log</option> <div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
</select> <select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
</div> <option value="http">HTTP</option>
<div x-show="isHttp"> <option value="slack">Slack</option>
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm"> <option value="database">Database</option>
</div> <option value="log">Log</option>
<div x-show="isHttp"> </select>
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea> <button type="button" @click="next" class="btn-primary text-sm">Next</button>
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p> <button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
</div> </div>
<div x-show="isHttp" class="flex gap-2 items-center"> <div x-show="filling" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 space-y-3">
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label> <div x-show="reason" x-text="reason" class="alert-error"></div>
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24"> <input type="text" name="name" :value="name" placeholder="Target name" required class="input text-sm">
</div> <template x-if="isHttp">
<div x-show="isHttp"> <div class="space-y-3">
<div class="flex gap-2 items-center"> <input type="hidden" name="type" value="http">
<label class="text-sm text-gray-700">Max retries:</label> <input type="url" name="url" :value="url" placeholder="https://example.com/webhook" class="input text-sm">
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24"> <div>
<textarea name="headers" rows="3" :value="headers" placeholder="Authorization: Bearer ..." class="input text-sm"></textarea>
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
</div>
<div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
</div>
<div>
<div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div>
<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> </div>
<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> </template>
<template x-if="isSlack">
<div class="space-y-3">
<input type="hidden" name="type" value="slack">
<div>
<input type="url" name="url" :value="url" placeholder="https://hooks.slack.com/services/..." class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div>
<div>
<div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div>
<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>
</template>
<template x-if="isDatabase">
<div>
<input type="hidden" name="type" value="database">
<input type="text" name="expiry" :value="expiry" placeholder="never" class="input text-sm">
<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>
</div>
</template>
<template x-if="isLog">
<div>
<input type="hidden" name="type" value="log">
<p class="text-xs text-gray-500">A log target writes each event to the application log. It has no settings beyond its name.</p>
</div>
</template>
<div class="flex gap-2">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
</div> </div>
<div x-show="isSlack"> </div>
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm"> </form>
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div>
<div x-show="isDatabase">
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
<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>
</div>
<button type="submit" class="btn-primary text-sm">Add Target</button>
</form>
</div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Targets}} {{range .Targets}}