1 Commits
Author SHA1 Message Date
clawbot 50661e060c Event log: an event's ID can be selected without toggling it (closes #348)
check / check (push) Waiting to run
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 20:17:07 +00:00
21 changed files with 527 additions and 1185 deletions
+49 -74
View File
@@ -1127,7 +1127,7 @@ unconditionally against whatever files it finds:
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
`Entrypoint`, `Target`
- each event database when it is lazily opened — `Event`, `Delivery`,
`DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals`
`DeliveryResult`, `EventTotals`, `TargetTotals`
- each archive database on every open and reopen
There is no schema version table, no migration ledger, and no down
@@ -1344,27 +1344,25 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page and the event log
under the real policy and checks that: the add entrypoint form stays hidden
until Add is clicked; for every target type, the targets section's Add shows
only a choice of type with Next and Cancel, Next shows only that type's fields
(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
under the real policy and checks that: both add forms stay hidden until Add is
clicked; choosing Slack in the add target form leaves the HTTP fields out of
what it submits, also after leaving the page and going back to it, when the
browser restores the choice; the Copy button beside an entrypoint URL reads
"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
drops what was typed, as does leaving the page and going back to it, and Save
changes the description; of the recent events on the webhook page only the
newest starts expanded, each expands and collapses, and Open leads to the
event's own page; an event in the event log expands and collapses, and so do a
delivery's attempts inside it; and at phone width the menu button opens and
closes the mobile menu. It also fails if the browser reports a console warning
or error, an uncaught exception, or anything the policy refused. `make check`
and the image build lint it but do not run it, and `make test` leaves it out
(its file is built only with the `browser` build tag). Run it with
`make test-browser` after changing `templates/` or `static/js/`: that builds
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
event's own page; an event in the event log expands and collapses when its
row's caret or its ID is clicked, and from the keyboard, but not when its ID is
selected with the mouse, and a delivery's attempts inside it expand and
collapse; and at phone width the menu button opens and closes the mobile menu.
It also fails if the browser reports a console warning or error, an uncaught
exception, or anything the policy refused. `make check` and the image build lint
it but do not run it, and `make test` leaves it out (its file is built only with
the `browser` build tag). Run it with `make test-browser` after changing
`templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
test in a digest-pinned headless browser image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -1531,9 +1529,6 @@ tier** (event ingestion, delivery, and logging).
│ ┌──────────────┐ (one row per target: running counts │
│ │ TargetTotals │ of its deliveries) │
│ └──────────────┘ │
│ ┌──────────────────┐ (one row per entrypoint: when the │
│ │ EntrypointTotals │ last event arrived on its URL) │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
@@ -1647,11 +1642,6 @@ different event sources that all feed into the same processing pipeline
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
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
A delivery destination for events. Each target defines where and how
@@ -1861,11 +1851,10 @@ retries) is individually logged for full observability.
**Relations:** Belongs to Delivery.
#### EventTotals, TargetTotals and EntrypointTotals
#### EventTotals and TargetTotals
Running counts in each event database, read by the statistics pane at the
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:
top of the webhook page and by the webhook list. `EventTotals` is one row:
| Field | Type | Description |
| ---------------- | --------- | ----------- |
@@ -1884,26 +1873,18 @@ event, read by the webhook page's entrypoint list. `EventTotals` is one row:
| `deliveries_removed` | integer | Its deliveries retention has deleted |
| `failed_removed` | integer | Its failed deliveries retention has deleted |
`EntrypointTotals` is one row per entrypoint, created by the first event
that arrives on its URL:
| Field | Type | Description |
| --------------- | --------- | ----------- |
| `entrypoint_id` | UUID | The entrypoint (primary key) |
| `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention |
Each count changes in the transaction that writes or deletes the rows it counts,
and each `last_event_at` in the transaction that stores the event. The pane's
lifetime events are `events`, and its lifetime deliveries and failures are
`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.
Each count changes in the transaction that writes or deletes the rows it
counts. The pane's lifetime events are `events`, and its lifetime
deliveries and failures are `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`, written in the transaction that stores the event, so it
still shows once retention has removed every event. 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
webhook: its events within retention and its last event, both from
@@ -1925,31 +1906,26 @@ 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 |
| `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` | `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 |
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves
it out. SQLite keeps no statistics on these tables, and without them it rates
the `deleted_at` index, which every live row matches, above an index on a column
matched against several values or compared with a range. So every index but the
last also covers `deleted_at`. It comes second in the `event_id` and
`delivery_id` indexes, so that retention can use them without it. The event
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
leaves it out. SQLite keeps no statistics on these tables, and without them it
rates the `deleted_at` index, which every live row matches, above an index on
a column matched against several values or compared with a range. So every
index but the last also covers `deleted_at`. It comes second in the `event_id`
and `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
`deleted_at IS NULL` and uses both columns. The entrypoint list's count, the one
query on the `entrypoint_id` index, uses all four, `resubmitted_from_id IS NULL`
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.
`deleted_at IS NULL` and uses both columns. 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
Every entity except `Setting`, `EventTotals`, `TargetTotals` and
`EntrypointTotals` includes these fields from `BaseModel`. `Setting` is a bare
key-value row with no `id`, no timestamps and no soft delete. Of the three
totals tables, `event_totals` holds counts and `last_event_at`, keyed by a
numeric `id`; `target_totals` holds counts, keyed by `target_id`; and
`entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`:
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
these fields from `BaseModel`. `Setting` is a bare key-value row with no
`id`, no timestamps and no soft delete, and the two totals tables hold
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
and by `target_id`:
| Field | Type | Description |
| ------------ | --------- | ----------- |
@@ -1991,9 +1967,8 @@ encryption key is generated and stored, and an `admin` user is created.
- **Events** — captured incoming webhook payloads
- **Deliveries** — event-to-target pairings and their status
- **DeliveryResults** — individual delivery attempt logs
- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running
counts of the above, the deliveries per target, and each entrypoint's
last event, kept through retention
- **EventTotals** and **TargetTotals** — running counts of the above,
the deliveries per target, kept through retention
Per-webhook databases are created automatically when a webhook is
created. They are managed by the `WebhookDBManager` component, which
@@ -3058,7 +3033,7 @@ webhooker/
│ │ ├── model_event.go # Event entity (per-webhook DB)
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
│ │ ├── model_totals.go # EventTotals, TargetTotals and EntrypointTotals (per-webhook DB)
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
│ │ ├── model_apikey.go # APIKey entity
│ │ ├── password.go # Argon2id hashing and verification
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
@@ -3334,9 +3309,9 @@ check, see [The login endpoint](#the-login-endpoint).
`ENTRYPOINT` script, which sets the data directory's owner and mode
before the app starts; the image's health check; and `docker exec`,
unless given `--user`
- GORM soft deletes on every entity that carries `BaseModel`, which is all of
them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals`
(data preserved for audit)
- GORM soft deletes on every entity that carries `BaseModel`, which is
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
preserved for audit)
### Shutdown
@@ -233,43 +233,6 @@ func TestResubmitCountUsesItsIndex(t *testing.T) {
"(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
// in a dry run, run with the same SQL and arguments GORM would send,
// names each of the given indexes.
+6 -9
View File
@@ -20,15 +20,12 @@ type Event struct {
// has no deleted_at condition and uses the index on created_at
// alone. The other tables keep the unindexed BaseModel created_at.
// DeletedAt is also the second column of the resubmitted_from_id
// index, for the reason DeliveryResult gives. The entrypoint_id
// index, for the webhook page's entrypoint list, has it second too,
// 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"`
// index, for the reason DeliveryResult gives.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
// Request data
Method string `gorm:"not null" json:"method"`
@@ -47,7 +44,7 @@ type Event struct {
// existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is
// 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;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON.
-37
View File
@@ -52,21 +52,6 @@ func (TargetTotals) TableName() string {
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,
// 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
@@ -112,25 +97,3 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
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,13 +111,6 @@ func (w *Webhook) RetainsForever() bool {
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
// text, so that no template has to know about the sentinel value.
func (w *Webhook) RetentionLabel() string {
+1 -1
View File
@@ -3,7 +3,7 @@ package database
// Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
// TargetTotals, EntrypointTotals) live in
// TargetTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error {
return d.db.AutoMigrate(
+2 -2
View File
@@ -49,7 +49,7 @@ var ErrSidecarNotRemoved = errors.New(
// WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
// running totals of them (EventTotals, TargetTotals).
// Database connections are opened lazily and cached.
type WebhookDBManager struct {
dataDir string
@@ -381,7 +381,7 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only
err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
&EventTotals{}, &TargetTotals{},
)
if err != nil {
_ = sqlDB.Close()
-77
View File
@@ -1,11 +1,6 @@
package handlers
import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -16,21 +11,6 @@ type EntrypointView struct {
Path string
Description string
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.
@@ -52,60 +32,3 @@ func NewEntrypointViews(
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
@@ -1,197 +0,0 @@
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)
}
+12 -9
View File
@@ -143,20 +143,22 @@ func (s *Handlers) RenderTemplateForTest(
// BuildSlackTargetConfigForTest exposes
// buildSlackTargetConfig for use in the handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, string, error) {
return s.buildSlackTargetConfig(ctx, targetURL)
) (string, error) {
return s.buildSlackTargetConfig(w, r, targetURL)
}
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
// for use in the handlers_test package, taking the form fields
// an HTTP target's configuration is built from.
func (s *Handlers) BuildHTTPTargetConfigForTest(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetURL, headers, timeout string,
) (string, string, error) {
return s.buildHTTPTargetConfig(ctx, targetFormInput{
) (string, error) {
return s.buildHTTPTargetConfig(w, r, targetFormInput{
URL: targetURL,
Headers: headers,
Timeout: timeout,
@@ -166,8 +168,9 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
// BuildDatabaseTargetConfigForTest exposes
// buildDatabaseTargetConfig for use in the handlers_test
// package.
func BuildDatabaseTargetConfigForTest(
func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string,
) (string, string, error) {
return buildDatabaseTargetConfig(expiry)
) (string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
}
+43 -20
View File
@@ -314,12 +314,16 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://93.184.216.34/services/T00/B00/xxx",
)
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, cfg, "webhookUrl")
}
@@ -333,13 +337,17 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Cleanup(app.RequireStop)
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
t.Context(), "http://169.254.169.254/latest/meta-data/",
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://169.254.169.254/latest/meta-data/",
)
require.NoError(t, err)
assert.Contains(t, errMsg, "Invalid target URL")
require.Error(t, err)
assert.Empty(t, cfg)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRenderTemplate(t *testing.T) {
@@ -436,22 +444,29 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// Empty expiry: the keep-forever default, empty config.
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
w := httptest.NewRecorder()
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.Empty(t, cfg)
// Explicit never is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
w = httptest.NewRecorder()
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config.
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
w = httptest.NewRecorder()
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
require.NoError(t, err)
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
}
@@ -460,14 +475,22 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
) {
t.Parallel()
for _, bad := range []string{"nonsense", "7d", "-5h"} {
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
var h *handlers.Handlers
require.NoError(t, err)
assert.Contains(
t, errMsg, "Invalid archive expiry",
"expiry %q should be refused", bad,
)
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
for _, bad := range []string{"nonsense", "7d", "-5h"} {
w := httptest.NewRecorder()
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
require.Error(t, err, "expiry %q", bad)
assert.Empty(t, cfg)
assert.Equal(
t, http.StatusBadRequest, w.Code,
"expiry %q should be rejected with 400", bad,
)
}
}
+159 -154
View File
@@ -1,7 +1,6 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -39,6 +38,9 @@ type WebhookListItem struct {
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
// 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
@@ -458,20 +460,15 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return
}
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
h.renderSourceDetail(w, r, webhook)
}
}
// 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.
// renderSourceDetail loads and renders a source detail page.
func (h *Handlers) renderSourceDetail(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetForm targetFormInput,
targetErr string,
) {
var entrypoints []database.Entrypoint
@@ -485,8 +482,6 @@ func (h *Handlers) renderSourceDetail(
"webhook_id = ?", webhook.ID,
).Find(&targets)
entrypointViews := NewEntrypointViews(entrypoints)
var events []RecentEventView
if h.dbMgr.DBExists(webhook.ID) {
@@ -505,15 +500,6 @@ func (h *Handlers) renderSourceDetail(
return
}
err = addEntrypointEvents(
webhookDB, &webhook, entrypointViews, time.Now(),
)
if err != nil {
h.serverError(w, r, "failed to count entrypoint events", err)
return
}
}
scheme := "http"
@@ -534,21 +520,14 @@ func (h *Handlers) renderSourceDetail(
// Targets are projected to a display-safe view: a
// target's stored config blob holds a credential, and it
// must never reach a template.
"Entrypoints": entrypointViews,
"Entrypoints": NewEntrypointViews(entrypoints),
"Targets": h.targetRows(&webhook, targets),
"Events": events,
"BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
"TargetForm": targetForm,
"TargetError": targetErr,
}
status := http.StatusOK
if targetErr != "" {
status = http.StatusBadRequest
}
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
h.renderTemplate(w, r, "source_detail.html", data)
}
// HandleSourceEdit shows the form to edit a webhook.
@@ -1539,29 +1518,66 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
}
}
// 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.
// processTargetCreate validates and creates a new target.
func (h *Handlers) processTargetCreate(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
in := targetFormInputFrom(r)
// The body size cap is enforced by the MaxBodySize middleware,
// 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"))
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
if name == "" {
http.Error(
w, "Name is required", http.StatusBadRequest,
)
return
}
if errMsg != "" {
h.renderSourceDetail(w, r, webhook, in, errMsg)
if !isValidTargetType(targetType) {
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return
}
configJSON, err := h.buildTargetConfig(
w, r, targetType, targetFormInputFrom(r),
)
if err != nil {
return
}
// 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 rejected rather than becoming
// that default.
maxRetries, ok := targetMaxRetries(w, r, 0)
if !ok {
return
}
target := &database.Target{
WebhookID: webhook.ID,
Name: name,
Type: targetType,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, r, "failed to create target", err)
@@ -1575,49 +1591,6 @@ 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.
func isValidTargetType(tt database.TargetType) bool {
switch tt {
@@ -1649,16 +1622,11 @@ func pageOrFirst(s string) int {
return v
}
// targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to buildTargetConfig,
// so neither can come to validate a destination differently from the
// other. A refused add target form is shown again from it.
// targetFormInput carries the raw form values describing a target's
// configuration. Both the create and the edit path fill one and hand
// it to buildTargetConfig, so neither can come to validate a
// destination differently from the other.
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
// for a Slack target.
URL string
@@ -1667,15 +1635,13 @@ type targetFormInput struct {
Headers string
// Timeout is an HTTP target's per-request timeout in seconds.
Timeout string
// MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string
// Expiry is a database (archive) target's row expiry.
Expiry string
}
// targetFormInputFrom reads a target form from a request body. The
// body size cap is enforced by the MaxBodySize middleware, which runs
// before CSRF parses the form.
// targetFormInputFrom reads the configuration fields from a request
// body. The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
//
// Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let
@@ -1687,38 +1653,38 @@ type targetFormInput struct {
// tokens.
func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{
Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
Expiry: r.PostFormValue("expiry"),
}
}
// buildTargetConfig builds the JSON config string for a target from
// the submitted form values, or returns the message the form shows
// 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.
// the submitted form values, writing its own 4xx response on
// rejection. Which fields of in apply depends on the target type.
func (h *Handlers) buildTargetConfig(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetType database.TargetType,
in targetFormInput,
) (string, string, error) {
) (string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(ctx, in)
return h.buildHTTPTargetConfig(w, r, in)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(ctx, in.URL)
return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase:
return buildDatabaseTargetConfig(in.Expiry)
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
case database.TargetTypeLog:
return "", "", nil
return "", nil
default:
return "", "Invalid target type", nil
http.Error(
w, "Invalid target type",
http.StatusBadRequest,
)
return "", errMissingURL
}
}
@@ -1726,73 +1692,92 @@ func (h *Handlers) buildTargetConfig(
// SSRF-validated destination plus the optional headers and timeout
// the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
in targetFormInput,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, in.URL, "URL is required for HTTP targets",
) (string, error) {
err := h.validateTargetURL(
w, r, in.URL, "URL is required for HTTP targets",
)
if errMsg != "" {
return "", errMsg, nil
if err != nil {
return "", err
}
headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil {
return "", fmt.Sprintf("Invalid headers: %v", err), nil
http.Error(
w,
"Invalid headers: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil {
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
http.Error(
w,
"Invalid timeout: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
})
return configJSON, "", err
}
// buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, targetURL,
) (string, error) {
err := h.validateTargetURL(
w, r, targetURL,
"Webhook URL is required for Slack targets",
)
if errMsg != "" {
return "", errMsg, nil
if err != nil {
return "", err
}
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
return configJSON, "", err
}
// validateTargetURL refuses an empty or SSRF-blocked destination,
// returning the message the form shows, or "" when the destination
// is accepted. missingMsg is the message for no URL at all.
// validateTargetURL rejects an empty or SSRF-blocked destination,
// writing the 400 itself. missingMsg is the error shown when no URL
// is given.
//
// 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
// reached storage without passing through here would reopen the hole
// the guard closes.
func (h *Handlers) validateTargetURL(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
targetURL, missingMsg string,
) string {
) error {
if targetURL == "" {
return missingMsg
http.Error(
w,
missingMsg,
http.StatusBadRequest,
)
return errMissingURL
}
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
err := h.ssrf.ValidateTargetURL(
r.Context(), targetURL,
)
if err != nil {
// The submitted URL can be a credential (a Slack
// incoming webhook URL is a bearer token), so the log
@@ -1818,16 +1803,25 @@ func (h *Handlers) validateTargetURL(
"egress to your own network\" in the README)."
}
return msg
http.Error(w, msg, http.StatusBadRequest)
return err
}
return ""
return nil
}
// marshalTargetConfig serialises a target configuration for storage.
func marshalTargetConfig(cfg any) (string, error) {
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return "", err
}
@@ -1835,24 +1829,35 @@ func marshalTargetConfig(cfg any) (string, error) {
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry is validated here, at
// creation time, so an unparseable value is refused instead of
// failing every subsequent delivery. An empty expiry yields an
// empty config (the keep-forever default).
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
// (archive) target. The optional expiry (a form value read by
// the caller, which bounds the request body) is validated here,
// at creation time, so an unparseable value is rejected with a
// 400 instead of failing every subsequent delivery. An empty
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry)
if expiry == "" {
return "", "", nil
return "", nil
}
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
http.Error(
w,
"Invalid archive expiry: "+err.Error(),
http.StatusBadRequest,
)
return "", err
}
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
return configJSON, "", err
return h.marshalTargetConfig(
w, r, map[string]any{"expiry": expiry},
)
}
// HandleEntrypointDelete handles deleting an entrypoint.
-154
View File
@@ -1,154 +0,0 @@
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))
})
}
}
+6 -11
View File
@@ -120,23 +120,18 @@ func (h *Handlers) applyTargetEdit(
) {
name := r.PostFormValue("name")
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
http.Error(
w, "Name is required", http.StatusBadRequest,
)
return
}
configJSON, errMsg, err := h.buildTargetConfig(
r.Context(), target.Type, targetFormInputFrom(r),
configJSON, err := h.buildTargetConfig(
w, r, target.Type, targetFormInputFrom(r),
)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest)
// buildTargetConfig has already written the response.
return
}
@@ -1,7 +1,6 @@
package handlers_test
import (
"html"
"net/http"
"net/url"
"testing"
@@ -44,16 +43,12 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
form.Set("type", string(targetType))
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(
env, http.MethodPost, targetsPath, form,
)
assert.Equal(t, http.StatusBadRequest, added.Code)
assert.Contains(
t, added.Body.String(),
html.EscapeString(privateRefusalHint),
t, added.Body.String(), privateRefusalHint,
)
form.Set("url", editOriginalURL)
+4 -5
View File
@@ -90,14 +90,13 @@ func retriesErrorMessage(err error) string {
", or 0 for fire-and-forget"
}
// targetMaxRetries reads and validates max_retries from a target edit
// targetMaxRetries reads and validates max_retries from a target form
// submission, answering the request with a 400 and reporting false
// when the value is set but invalid.
//
// It and the create path (newTarget) both use parseMaxRetries and
// retriesErrorMessage, so the two cannot come to disagree about what a
// valid retry count is. The wording matches the timeout control on
// the same submission.
// Both the create and the edit path go through here, so the two
// cannot come to disagree about what a valid retry count is. The
// wording matches the timeout control on the same submission.
func targetMaxRetries(
w http.ResponseWriter,
r *http.Request,
-13
View File
@@ -326,19 +326,6 @@ func (h *Handlers) createAndFanOut(
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
if err != nil {
return nil, nil, fmt.Errorf(
+171 -211
View File
@@ -18,10 +18,13 @@ import (
"time"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
@@ -87,37 +90,8 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
page := srv.URL + "/hook/" + webhook.ID
checkAddEntrypoint(ctx, t, page)
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
targetTypes := []struct {
name string
fields string
values map[string]string
}{
{
"http", "csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL},
},
{
"slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL},
},
{
"database", "csrf_token name type expiry",
map[string]string{"expiry": "720h"},
},
{"log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
checkAddTarget(
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
)
}
checkRefusedTarget(ctx, t, page)
checkAddForms(ctx, t, page)
checkTargetType(ctx, t, page+"/events")
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
@@ -262,194 +236,115 @@ func click(ctx context.Context, t *testing.T, xpath string) {
))
}
// checkAddEntrypoint loads a webhook page and checks that the add
// entrypoint form stays hidden until the Add button beside its heading
// is clicked. The click looks for a button element there, so it also
// checks that Add is one.
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,
) {
// checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked.
// The click looks for a button element there, so it also checks that
// Add is one.
func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.Truef(t, hidden(ctx, typeSelect),
"%s: the type choice shows before Add is clicked", targetType)
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,
)))
sections := []struct{ heading, form string }{
{"Entrypoints", `form[action$="/entrypoints"]`},
{"Targets", `form[action$="/targets"]`},
}
click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
"%s: the added target is not listed with its type", targetType)
for _, s := range sections {
assert.Truef(
t, hidden(ctx, s.form),
"%s: the add form shows before Add is clicked", s.heading,
)
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,
)
}
}
// chooseTargetType clicks Add, picks targetType and clicks Next, and
// checks that the type's fields then show in place of the type choice.
func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
t.Helper()
click(ctx, t, addTarget)
require.NoError(t, chromedp.Run(
ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
))
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) {
// checkTargetType chooses Slack in the open add target form and checks
// what the form would then submit: one url field, the Slack one, and
// not the HTTP url, headers or timeout, which are hidden and disabled.
//
// 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
// chosen, without a change event; the form must again show and submit
// Slack's fields, not the HTTP ones.
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
t.Helper()
const (
refusedURL = "http://127.0.0.1/hook"
urlField = `form[action$="/targets"] input[name="url"]`
reason = `//div[@class="alert-error"]`
chooseSlack = `(() => {
const type = document.querySelector('select[name="type"]');
type.value = "slack";
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"]`
)
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
slackFields := strings.Fields("csrf_token name type max_retries url")
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
var fields []string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.Evaluate(chooseSlack, nil),
chromedp.Evaluate(submitted, &fields),
))
assert.Equal(t, "refused", name,
"a refused target does not keep the name entered")
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")
assert.Equal(
t, slackFields, fields,
"with Slack chosen, the HTTP fields must not be submitted",
)
click(ctx, t, cancelFields)
chooseTargetType(ctx, t, "http")
assert.True(t, hidden(ctx, reason),
"after Cancel, the next Add still shows the reason")
var loaded, restored string
// Going back waits for the load event, after which the browser has
// restored the form.
require.NoError(t, chromedp.Run(
ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery),
loadPage(elsewhere),
chromedp.NavigateBack(),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
chromedp.Evaluate(howLoaded, &loaded),
chromedp.Evaluate(chosen, &restored),
))
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
// A page the browser kept in memory and showed again as it was
// would prove nothing here.
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
@@ -489,13 +384,9 @@ func checkEntrypointEdit(
) {
t.Helper()
// Cancel and Save are found inside the edit form, since the add
// target form has buttons of the same names.
const (
editForm = `form[action$="/edit"]`
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"]`
edit = `//button[text()="Edit"]`
)
@@ -516,7 +407,7 @@ func checkEntrypointEdit(
require.NoError(t, chromedp.Run(
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
))
click(ctx, t, cancelEdit)
click(ctx, t, `//button[text()="Cancel"]`)
assert.True(t, hidden(ctx, editForm),
"clicking Cancel does not hide the edit form")
assert.True(t, shown(ctx, description),
@@ -558,7 +449,7 @@ func checkEntrypointEdit(
require.NoError(t, chromedp.Run(
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
))
click(ctx, t, saveEdit)
click(ctx, t, `//button[text()="Save"]`)
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
"saving the edit form does not change the description")
@@ -604,18 +495,24 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"the event's own page does not show its body")
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
// the event's row again collapses it.
// 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
// delivery's attempts and clicking again hides them. Clicking the row's
// caret collapses the event. Selecting the ID with the mouse leaves the
// event collapsed, and the caret's next click expands it. Clicking the
// ID again collapses it. From the keyboard, Enter on the row expands
// the event and Space collapses it.
func checkEventLog(
ctx context.Context, t *testing.T, url, eventID, targetName string,
) {
t.Helper()
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
// The event's row shows its ID and ends with its caret, and its
// Resubmit form is in the part that expands. The delivery's row
// there shows the target's name.
id := `//span[text()="` + eventID + `"]`
row := id + `/ancestor::div[@role="button"]`
caret := row + `//*[local-name()="svg"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
@@ -624,8 +521,9 @@ func checkEventLog(
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, eventRow)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
click(ctx, t, id)
assert.True(t, shown(ctx, expanded),
"clicking the event's ID does not expand it")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
@@ -637,9 +535,71 @@ func checkEventLog(
assert.True(t, hidden(ctx, attempt),
"clicking the delivery again does not hide its attempts")
click(ctx, t, eventRow)
click(ctx, t, caret)
assert.True(t, hidden(ctx, expanded),
"clicking the event again does not collapse it")
"clicking the caret does not collapse the event")
var (
selected, state string
hasState bool
)
selectText(ctx, t, id)
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
chromedp.AttributeValue(
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
),
))
assert.Equal(t, eventID, selected, "the event's ID cannot be selected")
require.True(t, hasState, "the event's row does not say if it is expanded")
assert.Equal(t, "false", state, "selecting the event's ID expands it")
click(ctx, t, caret)
assert.True(t, shown(ctx, expanded),
"clicking the caret after selecting the ID 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")
require.NoError(t, chromedp.Run(
ctx,
chromedp.Focus(row, chromedp.BySearch),
chromedp.KeyEvent(kb.Enter),
))
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
}
// selectText selects the text of the element matching an XPath
// expression as a person does with the mouse: pressing the button at the
// text's start, moving to its end and releasing it there.
func selectText(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.
left, right := box.Content[0]+1, box.Content[2]-1
middle := (box.Content[1] + box.Content[5]) / 2
require.NoError(t, chromedp.Run(
ctx,
input.DispatchMouseEvent(input.MousePressed, left, middle).
WithButton(input.Left).WithButtons(1).WithClickCount(1),
input.DispatchMouseEvent(input.MouseMoved, right, middle).
WithButton(input.Left).WithButtons(1),
input.DispatchMouseEvent(input.MouseReleased, right, middle).
WithButton(input.Left).WithClickCount(1),
))
}
// checkMobileMenu loads a page in a phone-sized window and checks that
+27 -55
View File
@@ -82,6 +82,13 @@ document.addEventListener("alpine:init", function () {
toggle() {
this.open = !this.open;
},
// Toggles, except on a click that ends a text selection, such
// as selecting an event's ID to copy it.
toggleUnlessSelecting() {
if (window.getSelection().toString() === "") {
this.toggle();
}
},
get closed() {
return !this.open;
},
@@ -92,65 +99,24 @@ document.addEventListener("alpine:init", function () {
};
});
// The targets section's add target form, in three steps: closed,
// choosing a type, then filling in that type's fields. targetType
// is empty until Next takes it from the type select.
// The add target form. Only the chosen type's fields show, and the
// others are disabled so that the form does not submit them.
//
// The reason and the fields' values come from the properties below
// rather than from the markup, because each type's fields are made
// afresh from the markup whenever that type is chosen. A refused
// submission comes back with its type, reason and values in the
// 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.
// The type is read from the type select when Alpine starts, when the
// select changes, and on pageshow. Going back to the page, the
// browser restores the type chosen before without a change event,
// in some browsers only after Alpine has started, but always before
// pageshow.
window.Alpine.data("targetForm", function () {
return {
choosing: false,
targetType: "",
reason: "",
name: "",
url: "",
headers: "",
timeout: "",
maxRetries: "",
expiry: "",
init() {
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;
this.readType();
},
add() {
this.choosing = true;
},
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;
readType() {
this.targetType = this.$root.querySelector(
'select[name="type"]'
).value;
},
get isHttp() {
return this.targetType === "http";
@@ -161,8 +127,14 @@ document.addEventListener("alpine:init", function () {
get isDatabase() {
return this.targetType === "database";
},
get isLog() {
return this.targetType === "log";
get notHttp() {
return !this.isHttp;
},
get notSlack() {
return !this.isSlack;
},
get notDatabase() {
return !this.isDatabase;
},
};
});
+43 -104
View File
@@ -97,18 +97,6 @@
</div>
<!-- The URL above is the entrypoint's credential:
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>
{{else}}
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
@@ -116,23 +104,11 @@
</div>
</div>
<!-- Targets. The data attributes carry a refused add target
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}}">
<!-- Targets -->
<div class="card" x-data="collapsible">
<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>
<button type="button" @click="add" x-show="closed" class="btn-small">
<button type="button" @click="toggle" class="btn-small">
<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"/>
</svg>
@@ -140,85 +116,48 @@
</button>
</div>
<!-- Add target form. Add shows the type choice; Next replaces
it with the chosen type's fields. Each type's fields,
and the hidden type field submitted with them, exist
only while that type is chosen. A refused submission
comes back open on its type, with the values entered;
Cancel empties the form. The type choice's p-2, narrower
than an input's own padding, keeps it, Next and Cancel on
one row on a 360px-wide phone. -->
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
<option value="http">HTTP</option>
<option value="slack">Slack</option>
<option value="database">Database</option>
<option value="log">Log</option>
</select>
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
</div>
<div x-show="filling" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 space-y-3">
<div x-show="reason" x-text="reason" class="alert-error"></div>
<input type="text" name="name" :value="name" placeholder="Target name" required class="input text-sm">
<template x-if="isHttp">
<div class="space-y-3">
<input type="hidden" name="type" value="http">
<input type="url" name="url" :value="url" placeholder="https://example.com/webhook" class="input text-sm">
<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>
</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>
<!-- Add target form -->
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<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>
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" @change="readType" class="input text-sm w-32">
<option value="http">HTTP</option>
<option value="slack">Slack</option>
<option value="database">Database</option>
<option value="log">Log</option>
</select>
</div>
</div>
</form>
<div x-show="isHttp">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
</div>
<div x-show="isHttp">
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" 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 x-show="isHttp" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
</div>
<div x-show="isHttp">
<div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" value="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 x-show="isSlack">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" 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 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">
{{range .Targets}}
+3 -2
View File
@@ -16,7 +16,8 @@
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4" x-data="collapsible">
<button type="button" class="btn-small w-full flex flex-wrap justify-between gap-2 text-left" @click="toggle">
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
@@ -39,7 +40,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</span>
</button>
</div>
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">