Author SHA1 Message Date
sneak 3a93e6b4ed Load Alpine's CSP build so the UI's directives run (closes #371)
check / check (push) Waiting to run
The pages' Content-Security-Policy forbids eval, which the standard
Alpine.js build needs, so no directive ran: add forms showed open and
events never collapsed. 3p/ now holds the @alpinejs/csp 3.14.9 tarball
instead, and every directive in templates/ names a property or method
of a component registered in static/js/app.js, as that build requires.
The policy is unchanged.

A headless Chromium test in internal/server loads the webhook page and
the event log under the real headers. The Dockerfile's test stage
installs chromium; where it is missing the test skips.

Model: opus-5-5
2026-10-01 22:22:57 +00:00
clawbot 30e65dce53 State what the default blocklist covers (closes #244)
check / check (push) Waiting to run
The README's egress section and the comment above blockedNetworks now state what the default blocklist covers: the IPv4 private and reserved ranges, IPv6 loopback, unique local and link-local addresses, and certain public addresses, each added only because it hands credentials, user data or bootstrap material to whatever can reach it without the caller presenting anything. That is the same material as the rule above alwaysBlockedNetworks, so a future candidate can be accepted or refused against one rule.

A provider's other public addresses, such as 161.26.0.0/16 and 166.8.0.0/14, are not refused. Docs and a comment only; the lists are unchanged.

Model: opus-5-5
2026-10-02 00:21:35 +02:00
clawbot 1ac4fe0be4 Show 50 recent events with size, processing time and status (closes #347)
check / check (push) Waiting to run
The webhook page's list, now headed "50 Most Recent Events", shows the 50 newest events, limited in the query. Each row adds the time received (relative, UTC on hover), the body size, the processing time (slowest delivery, queued to final outcome, retries included, "in progress" while pending), and, when the webhook has exactly one HTTP target, that target's latest HTTP status, coloured by class.

Each event now records its body size in a new body_bytes column when it is stored, so the list never reads bodies; the list loads only the delivery and attempt columns it shows, and a failed load is an error, not an empty list.

An existing event database must be recreated: the column is added in place, with no migration.

Model: opus-5-5
2026-10-01 23:50:00 +02:00
clawbot a56f1fe0c8 Remove the fixed Account Type row from the profile page (closes #401)
check / check (push) Waiting to run
The profile page no longer shows the fixed "Account Type: Standard User" row. webhooker has one account, the administrator it creates, and no account types, so the row was untrue. The Account Information section now lists the username alone, and a test checks the row stays gone.

Model: opus-5-5
2026-10-01 23:24:41 +02:00
23 changed files with 1161 additions and 70 deletions
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -38,8 +38,9 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
COPY --from=lint /src/go.sum /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
# suite executes. chromium runs the browser test in internal/server, which
# skips where it is not installed.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq chromium && rm -rf /var/lib/apt/lists/*
WORKDIR /build
+37 -9
View File
@@ -157,6 +157,21 @@ public cloud metadata addresses: currently only `168.63.129.16`, Azure's
WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
That is all the default blocklist covers: the IPv4 private and reserved
ranges; of IPv6, only loopback (`::1`), unique local addresses
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
addresses. A public address belongs on the default blocklist only if it
hands credentials, user data or bootstrap material to whatever can reach
it, without the caller presenting anything. A provider's other public
addresses are not refused. IBM Cloud, for example, serves its package
mirrors, time servers and object storage on `161.26.0.0/16`, and the
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
range hands out credentials that way: the token service among those
endpoints issues a token only in exchange for something the caller
presents, such as an API key. Reaching these services can be a
legitimate delivery, and every cloud has some, so a partial list would
promise coverage it does not give.
That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own
network. A container on the same Docker network, a box on `10.x`, a
@@ -1255,9 +1270,20 @@ We provide:
## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
publishes it. It is a dependency, not this repo's build output, so
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
the standard `alpinejs` build needs to run the expressions written in the
markup. The CSP build runs no expressions, so every Alpine directive in
`templates/` only names a property or method of a component registered in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`. A browser test in
`internal/server` loads the pages under the real policy and fails on any
directive that does not work. It needs `chromium` on `PATH` and skips without
it; the Dockerfile installs it, so the image build always runs it.
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 output, so
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
The directory is `3p/` rather than `vendor/` because Go treats a root
`vendor/` directory as its module vendor directory.
@@ -1270,10 +1296,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
`.dockerignore` keeps any host copy out of the build context.
To move to a new version: download
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
against the `dist.integrity` hash listed at
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
with it, update its file name in `script/assets`, and run `make check`.
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
`script/assets`, and run `make check`.
## Rationale
@@ -1642,6 +1669,7 @@ data for auditing, for replay, and for resubmission.
| `headers` | JSON | Complete request headers |
| `body` | text | Raw request body |
| `content_type` | string | Content-Type header value |
| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit |
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
@@ -2753,7 +2781,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
```
webhooker/
├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/
@@ -2846,8 +2874,8 @@ webhooker/
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
+9 -2
View File
@@ -4,6 +4,9 @@ go 1.26.1
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1
@@ -28,8 +31,12 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -50,7 +57,7 @@ require (
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect
+20 -2
View File
@@ -6,6 +6,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -23,6 +29,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -55,12 +69,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -111,8 +129,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+5
View File
@@ -31,6 +31,11 @@ type Event struct {
Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored so the recent events list can show it without reading
// the body.
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
// ResubmittedFromID names the event this one was copied from by
// an operator resubmit. It is nil for an event that arrived on
// the receiver, which is every event created before the column
+7
View File
@@ -43,6 +43,13 @@ var (
// permit specific blocks out of this set with
// ALLOWED_EGRESS_CIDRS; see Guard.
//
// A public address belongs on the default blocklist only if it
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything. A
// provider's other public addresses are not refused, since
// reaching them can be legitimate and no list of them could be
// complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet
+1
View File
@@ -204,6 +204,7 @@ func assertEventCopy(
assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body)
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
assert.Equal(t, original.ContentType, fresh.ContentType)
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
assert.Equal(t, original.WebhookID, fresh.WebhookID)
+1 -1
View File
@@ -28,7 +28,7 @@ const (
// maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20
// recentEventLimit is the number of recent events to show.
recentEventLimit = 20
recentEventLimit = 50
// paginationPerPage is the number of items per page.
paginationPerPage = 25
+2
View File
@@ -88,6 +88,8 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
h.HandleProfile().ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Account Information")
assert.NotContains(t, w.Body.String(), "Account Type")
}
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
+293
View File
@@ -0,0 +1,293 @@
package handlers
import (
"net/http"
"slices"
"strconv"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// recentEventColumns is the recent events list's projection. It
// leaves out the body, for the reason maxRenderedBodyBytes gives,
// and reads its size from body_bytes, recorded when the event was
// stored.
const recentEventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, body_bytes"
// recentAttemptColumns is the part of a recorded attempt the list
// uses. The event log's deliveryResultColumns also reads response
// bodies, which the list does not show.
const recentAttemptColumns = "delivery_id, status_code, created_at"
// RecentEventView is one row of the recent events list on a
// webhook's page.
type RecentEventView struct {
Method string
ContentType string
// ResubmittedFromID names the event this one was copied from,
// empty for an event that arrived on the receiver.
ResubmittedFromID string
// Received is how long ago the event arrived, and ReceivedUTC
// the full timestamp the page shows on hover.
Received string
ReceivedUTC string
// Size is the size of the stored body.
Size string
// ProcessingTime is how long the event's slowest delivery
// took; see processingTime.
ProcessingTime string
// Status is what the webhook's HTTP target answered, and
// StatusClass its colour; see targetStatus. Both are empty
// unless the webhook has exactly one HTTP target.
Status string
StatusClass string
}
// recentEventRow is one row of recentEventColumns.
type recentEventRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
BodyBytes uint64
}
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
// when the attempt's result was recorded, which is when the attempt
// finished.
type recentAttemptRow struct {
DeliveryID string
StatusCode int
CreatedAt time.Time
}
// singleHTTPTargetID returns the ID of the webhook's HTTP target
// when it has exactly one, and "" when it has none or several.
func singleHTTPTargetID(targets []database.Target) string {
id := ""
count := 0
for i := range targets {
if targets[i].Type == database.TargetTypeHTTP {
id = targets[i].ID
count++
}
}
if count != 1 {
return ""
}
return id
}
// loadRecentEvents loads the webhook's recentEventLimit newest
// events for its page, newest first. statusTargetID is the
// webhook's only HTTP target, or "" when the list shows no status.
func loadRecentEvents(
webhookDB *gorm.DB, webhookID, statusTargetID string,
) ([]RecentEventView, error) {
var rows []recentEventRow
err := webhookDB.Model(&database.Event{}).
Select(recentEventColumns).
Where("webhook_id = ?", webhookID).
Order("created_at DESC").
Limit(recentEventLimit).
Find(&rows).Error
if err != nil {
return nil, err
}
eventIDs := make([]string, len(rows))
for i := range rows {
eventIDs[i] = rows[i].ID
}
// Oldest first, so an event's last delivery to a target is its
// newest: a replay adds a delivery rather than changing the
// earlier one.
var deliveries []database.Delivery
err = webhookDB.
Select("id, event_id, target_id, status, created_at").
Where("event_id IN ?", eventIDs).
Order("created_at ASC").
Find(&deliveries).Error
if err != nil {
return nil, err
}
byEvent := make(map[string][]database.Delivery, len(rows))
deliveryIDs := make([]string, len(deliveries))
for i := range deliveries {
eventID := deliveries[i].EventID
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
deliveryIDs[i] = deliveries[i].ID
}
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
if err != nil {
return nil, err
}
views := make([]RecentEventView, len(rows))
for i := range rows {
views[i] = rows[i].view(
byEvent[rows[i].ID], attempts, statusTargetID,
)
}
return views, nil
}
// loadRecentAttempts loads the recorded attempts of the listed
// events' deliveries, keyed by delivery ID, each delivery's in
// attempt order. The IDs go in chunks for the reason
// deliveryIDChunkSize gives.
func loadRecentAttempts(
webhookDB *gorm.DB, deliveryIDs []string,
) (map[string][]recentAttemptRow, error) {
byDelivery := make(map[string][]recentAttemptRow)
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
var rows []recentAttemptRow
err := webhookDB.Model(&database.DeliveryResult{}).
Select(recentAttemptColumns).
Where("delivery_id IN ?", chunk).
Order("attempt_num ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
for i := range rows {
id := rows[i].DeliveryID
byDelivery[id] = append(byDelivery[id], rows[i])
}
}
return byDelivery, nil
}
// view projects a loaded row for rendering. deliveries is the
// event's deliveries, oldest first, and attempts their recorded
// attempts keyed by delivery ID.
func (r *recentEventRow) view(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
statusTargetID string,
) RecentEventView {
v := RecentEventView{
Method: r.Method,
ContentType: r.ContentType,
Received: humanize.Time(r.CreatedAt),
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Size: humanize.Bytes(r.BodyBytes),
ProcessingTime: processingTime(deliveries, attempts),
}
if r.ResubmittedFromID != nil {
v.ResubmittedFromID = *r.ResubmittedFromID
}
if statusTargetID != "" {
v.Status, v.StatusClass = targetStatus(
deliveries, attempts, statusTargetID,
)
}
return v
}
// processingTime is how long the event's slowest delivery took,
// from being queued to its last recorded attempt, time spent
// waiting between retries included. A delivery is queued when its
// event is received, or when an operator replays it, so a replay
// is timed from the replay rather than from the event's arrival.
// It is "in progress" while any delivery is pending or retrying,
// and empty for an event with no deliveries.
func processingTime(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
) string {
if len(deliveries) == 0 {
return ""
}
var slowest time.Duration
for i := range deliveries {
if !deliveries[i].Status.Terminal() {
return "in progress"
}
tries := attempts[deliveries[i].ID]
if len(tries) == 0 {
continue
}
last := tries[len(tries)-1].CreatedAt
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
}
return slowest.Round(time.Millisecond).String()
}
// targetStatus is what the target answered for the event, and the
// colour to show it in: the HTTP status code of the last attempt of
// the event's newest delivery to the target. Without a code it is
// "no response" when that attempt failed before a response
// arrived, the delivery's status ("pending") before any attempt,
// and "not sent" when the event has no delivery to the target.
func targetStatus(
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
targetID string,
) (string, string) {
newest := -1
for i := range deliveries {
if deliveries[i].TargetID == targetID {
newest = i
}
}
if newest < 0 {
return "not sent", "text-gray-400"
}
tries := attempts[deliveries[newest].ID]
if len(tries) == 0 {
return string(deliveries[newest].Status), "text-gray-400"
}
code := tries[len(tries)-1].StatusCode
switch {
case code == 0:
return "no response", "text-red-600"
case code >= http.StatusInternalServerError:
return strconv.Itoa(code), "text-red-600"
case code >= http.StatusBadRequest:
return strconv.Itoa(code), "text-yellow-600"
case code >= http.StatusMultipleChoices:
return strconv.Itoa(code), "text-gray-500"
case code >= http.StatusOK:
return strconv.Itoa(code), "text-green-600"
default:
return strconv.Itoa(code), "text-gray-500"
}
}
+362
View File
@@ -0,0 +1,362 @@
package handlers_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// statusTitle marks the status column's cell in a recent events
// row; it is absent from the page when the column is not shown.
const statusTitle = `title="HTTP status from the HTTP target"`
// recentEventsFixture is one started app and a webhook whose
// recent events list a test fills.
type recentEventsFixture struct {
h *handlers.Handlers
sess *session.Session
db *database.Database
webhook *database.Webhook
webhookDB *gorm.DB
}
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
t.Helper()
f := &recentEventsFixture{}
var dbMgr *database.WebhookDBManager
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
f.webhook = seedWebhook(t, f.db)
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
require.NoError(t, err)
f.webhookDB = webhookDB
return f
}
func (f *recentEventsFixture) render(t *testing.T) string {
t.Helper()
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
}
// event records an event received at receivedAt, with its body's
// size as the receiver records it.
func (f *recentEventsFixture) event(
t *testing.T, contentType, body string, receivedAt time.Time,
) *database.Event {
t.Helper()
event := &database.Event{
WebhookID: f.webhook.ID,
Method: http.MethodPost,
Body: body,
BodyBytes: int64(len(body)),
ContentType: contentType,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// delivery records a delivery of the event to the target, queued
// when the event was received.
func (f *recentEventsFixture) delivery(
t *testing.T,
event *database.Event,
targetID string,
status database.DeliveryStatus,
) *database.Delivery {
t.Helper()
return f.deliveryQueuedAt(
t, event, targetID, status, event.CreatedAt,
)
}
// deliveryQueuedAt records a delivery of the event to the target,
// queued at queuedAt, as a replay is.
func (f *recentEventsFixture) deliveryQueuedAt(
t *testing.T,
event *database.Event,
targetID string,
status database.DeliveryStatus,
queuedAt time.Time,
) *database.Delivery {
t.Helper()
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: status,
}
dlv.CreatedAt = queuedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(dlv).Error)
return dlv
}
// attempt records one attempt of the delivery that finished took
// after the delivery was queued, with HTTP status code (0 for no
// response).
func (f *recentEventsFixture) attempt(
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
) {
t.Helper()
result := &database.DeliveryResult{
DeliveryID: dlv.ID,
AttemptNum: 1,
StatusCode: code,
}
result.CreatedAt = dlv.CreatedAt.Add(took)
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(result).Error)
}
// statusCell is the status column's cell as the page renders it.
func statusCell(class, text string) string {
return `<span class="font-medium ` + class + `" ` + statusTitle +
`>` + text + `</span>`
}
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
// headed "50 Most Recent Events" and holds the 50 newest events,
// newest first, and not one more.
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
base := time.Now().Add(-time.Hour)
for i := range 51 {
f.event(
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
}
body := f.render(t)
assert.Contains(t, body, ">50 Most Recent Events</h2>")
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
assert.NotContains(t, body, "application/x-recent-00")
assert.Contains(t, body, "application/x-recent-01")
assert.Less(
t,
strings.Index(body, "application/x-recent-50"),
strings.Index(body, "application/x-recent-49"),
)
}
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
// time relative with the UTC timestamp on hover, its body size,
// and its processing time once every delivery has finished.
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
receivedAt := time.Now().Add(-210 * time.Second).
UTC().Truncate(time.Second)
done := f.event(
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
)
f.attempt(
t,
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
0, 1500*time.Millisecond,
)
waiting := f.event(t, "text/plain", "{}", receivedAt)
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
body := f.render(t)
assert.Contains(
t, body,
`<span title="`+receivedAt.Format(time.DateTime)+
` UTC">3 minutes ago</span>`,
)
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
assert.Contains(t, body, ">1.5s</span>")
assert.Contains(t, body, ">in progress</span>")
}
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
// webhook with exactly one HTTP target shows, colour-coded, what
// that target answered for each event. The log target beside it
// does not count against "exactly one".
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
for _, code := range []int{204, 302, 404, 503, 0} {
dlv := f.delivery(
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
database.DeliveryStatusDelivered,
)
f.attempt(t, dlv, code, time.Second)
}
f.delivery(
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
database.DeliveryStatusPending,
)
f.event(t, contentTypeJSON, "{}", now)
// A replay is a newer delivery, and its answer is the one shown.
replayed := f.event(t, contentTypeJSON, "{}", now)
f.attempt(t, f.delivery(
t, replayed, target.ID, database.DeliveryStatusFailed,
), 502, time.Second)
f.attempt(t, f.deliveryQueuedAt(
t, replayed, target.ID, database.DeliveryStatusDelivered,
now.Add(time.Minute),
), 200, time.Second)
body := f.render(t)
assert.Contains(t, body, statusCell("text-green-600", "204"))
assert.Contains(t, body, statusCell("text-gray-500", "302"))
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
assert.Contains(t, body, statusCell("text-red-600", "503"))
assert.Contains(t, body, statusCell("text-red-600", "no response"))
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
assert.Contains(t, body, statusCell("text-green-600", "200"))
assert.NotContains(t, body, ">502<")
}
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
// status column is absent when the webhook has no HTTP target or
// more than one.
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
t *testing.T,
) {
t.Parallel()
cases := map[string][]database.TargetType{
"none": {database.TargetTypeLog},
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
}
for name, types := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
event := f.event(t, contentTypeJSON, "{}", time.Now())
for _, tt := range types {
target := seedTarget(t, f.db, f.webhook.ID, tt)
f.attempt(t, f.delivery(
t, event, target.ID,
database.DeliveryStatusDelivered,
), 200, time.Second)
}
body := f.render(t)
assert.Contains(t, body, `title="Body size"`)
assert.NotContains(t, body, statusTitle)
})
}
}
// TestHandleWebhook_RecordsBodySize proves the receiver records the
// body's size in bytes, not characters, with the event it stores.
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
seedEntrypoint(t, f.db, f.webhook.ID)
// Two bytes per character.
body := strings.Repeat("é", 1024)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/webhook/x",
strings.NewReader(body),
)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
req = req.WithContext(context.WithValue(
req.Context(), chi.RouteCtxKey, rctx,
))
w := httptest.NewRecorder()
f.h.HandleWebhook().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var stored database.Event
require.NoError(t, f.webhookDB.First(&stored).Error)
assert.Equal(t, int64(2048), stored.BodyBytes)
}
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
// list cannot be loaded the page answers with an error, rather than
// an empty list claiming the webhook has no events.
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
f.attempt(t, f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
database.DeliveryStatusDelivered,
), 200, time.Second)
// The attempts are the list's last query, so its events and
// deliveries have already loaded when it fails.
require.NoError(t, f.webhookDB.Exec(
"DROP TABLE delivery_results",
).Error)
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NotContains(t, w.Body.String(), "No events received yet.")
}
+18 -3
View File
@@ -62,6 +62,23 @@ func renderSourceDetailPage(
) string {
t.Helper()
w := serveSourceDetailPage(t, h, sess, webhookID)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// serveSourceDetailPage runs the real source detail handler for a
// webhook and returns its response, whatever its status.
func serveSourceDetailPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
@@ -87,9 +104,7 @@ func renderSourceDetailPage(
w := httptest.NewRecorder()
h.HandleSourceDetail().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
return w
}
// TestHandleSourceDetail_MasksSlackWebhookURL is the
+15 -8
View File
@@ -415,16 +415,23 @@ func (h *Handlers) renderSourceDetail(
"webhook_id = ?", webhook.ID,
).Find(&targets)
var events []database.Event
var events []RecentEventView
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
if dbErr == nil {
webhookDB.Where(
"webhook_id = ?", webhook.ID,
).Order("created_at DESC").Limit(
recentEventLimit,
).Find(&events)
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
return
}
events, err = loadRecentEvents(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, "failed to load recent events", err)
return
}
}
+1
View File
@@ -230,6 +230,7 @@ func (s eventSource) event() *database.Event {
Method: s.Method,
Headers: s.HeadersJSON,
Body: string(s.Body),
BodyBytes: int64(len(s.Body)),
ContentType: s.ContentType,
ResubmittedFromID: s.ResubmittedFromID,
}
+276
View File
@@ -0,0 +1,276 @@
package server_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// browserTimeout bounds everything one test does in the browser.
browserTimeout = 60 * time.Second
// settleTimeout bounds the wait for an element to show or hide.
settleTimeout = 5 * time.Second
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in headless Chromium, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
ctx, consoleProblems := startBrowser(t)
env := newTestEnv(t)
srv := httptest.NewServer(env.router)
t.Cleanup(srv.Close)
userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
checkAddForms(ctx, t, srv.URL+"/source/"+webhook.ID)
checkTargetType(ctx, t)
checkEventToggle(
ctx, t, srv.URL+"/source/"+webhook.ID+"/logs", event.ID,
)
assert.Empty(
t, consoleProblems(),
"the pages printed console warnings, errors or exceptions",
)
}
// startBrowser starts headless Chromium for one test. It returns the
// context that drives it, and a function listing every console warning
// or error and every uncaught exception its pages raised: that is how
// Alpine.js reports an expression it cannot run.
//
// The test is skipped when chromium is not on PATH. The Dockerfile's
// test stage installs it, so the image build always runs this check.
func startBrowser(t *testing.T) (context.Context, func() []string) {
t.Helper()
path, err := exec.LookPath("chromium")
if err != nil {
t.Skipf("chromium is not installed: %v", err)
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
t.Context(),
append(
chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(path),
// The image build runs tests as root, where Chromium's
// sandbox cannot start.
chromedp.NoSandbox,
)...,
)
t.Cleanup(cancelAlloc)
ctx, cancel := chromedp.NewContext(allocCtx)
t.Cleanup(cancel)
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
t.Cleanup(cancelTimeout)
var (
mu sync.Mutex
problems []string
)
chromedp.ListenTarget(ctx, func(ev any) {
var problem string
switch ev := ev.(type) {
case *runtime.EventConsoleAPICalled:
if ev.Type != runtime.APITypeWarning &&
ev.Type != runtime.APITypeError {
return
}
args := make([]string, 0, len(ev.Args))
for _, arg := range ev.Args {
args = append(args, string(arg.Value))
}
problem = strings.Join(args, " ")
case *runtime.EventExceptionThrown:
problem = ev.ExceptionDetails.Error()
default:
return
}
mu.Lock()
defer mu.Unlock()
problems = append(problems, problem)
})
return ctx, func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(problems)
}
}
// setCookies gives the browser the cookies for the server at base.
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
return chromedp.ActionFunc(func(ctx context.Context) error {
for _, c := range cookies {
err := network.SetCookie(c.Name, c.Value).
WithURL(base).
Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", c.Name, err)
}
}
return nil
})
}
// loadPage opens url and waits for Alpine.js to start, which it does
// by removing every x-cloak attribute. Until then x-cloak hides the
// elements Alpine would hide, so a check made earlier proves nothing.
func loadPage(url string) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(url),
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
}
}
// shown waits up to settleTimeout for the first element matching a CSS
// selector to be rendered, and reports whether it was. The wait is
// needed because Alpine.js shows an element on the next animation
// frame, not at once.
func shown(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitVisible(selector, chromedp.ByQuery),
) == nil
}
// hidden is shown's opposite: it waits for the element to be hidden.
func hidden(ctx context.Context, selector string) bool {
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
defer cancel()
return chromedp.Run(
ctx, chromedp.WaitNotVisible(selector, chromedp.ByQuery),
) == nil
}
// click clicks the first element matching an XPath expression.
func click(ctx context.Context, t *testing.T, xpath string) {
t.Helper()
require.NoError(t, chromedp.Run(
ctx, chromedp.Click(xpath, chromedp.BySearch),
))
}
// checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked.
func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper()
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
sections := []struct{ heading, form string }{
{"Entrypoints", `form[action$="/entrypoints"]`},
{"Targets", `form[action$="/targets"]`},
}
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,
)
}
}
// 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.
func checkTargetType(ctx context.Context, t *testing.T) {
t.Helper()
const (
chooseSlack = `(() => {
const type = document.querySelector('select[name="type"]');
type.value = "slack";
type.dispatchEvent(new Event("change"));
})()`
submitted = `[...new FormData(
document.querySelector('form[action$="/targets"]')).keys()]`
)
var fields []string
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(chooseSlack, nil),
chromedp.Evaluate(submitted, &fields),
))
assert.Equal(
t,
[]string{"csrf_token", "name", "type", "max_retries", "url"},
fields,
"with Slack chosen, the HTTP fields must not be submitted",
)
}
// checkEventToggle loads the event log and checks that clicking an
// event's row expands it and clicking again collapses it.
func checkEventToggle(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
// The row shows the event's ID; its Resubmit form is in the part
// that expands.
row := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, row)
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
click(ctx, t, row)
assert.True(t, hidden(ctx, expanded), "clicking it again does not collapse it")
}
+5 -4
View File
@@ -1,15 +1,16 @@
#!/bin/sh
# script/assets: extract Alpine.js from its npm package tarball, committed
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
# extracted file is not committed. script/test, make build and make dev run
# this first.
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
# forbids eval. The extracted file is not committed. script/test, make
# build and make dev run this first.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
+59
View File
@@ -57,3 +57,62 @@
init();
}
})();
// Alpine.js components.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
// Alpine's CSP build, which cannot run expressions written in the
// markup: a directive in templates/ may only name a property or method,
// and each x-data names a component registered here. This script runs
// before Alpine, whose script tag is deferred, so this listener is in
// place when Alpine starts.
document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an event in the event log, a delivery's attempts.
window.Alpine.data("collapsible", function () {
return {
open: false,
toggle() {
this.open = !this.open;
},
get closed() {
return !this.open;
},
// Turns a downward caret up while open.
get caretClass() {
return { "rotate-180": this.open };
},
};
});
// The add target form. Only the chosen type's fields show, and the
// others are disabled so that the form does not submit them.
window.Alpine.data("targetForm", function () {
return {
targetType: "http",
chooseType(event) {
this.targetType = event.target.value;
},
get isHttp() {
return this.targetType === "http";
},
get isSlack() {
return this.targetType === "slack";
},
get isDatabase() {
return this.targetType === "database";
},
get notHttp() {
return !this.isHttp;
},
get notSlack() {
return !this.isSlack;
},
get notDatabase() {
return !this.isDatabase;
},
};
});
});
+3 -3
View File
@@ -1,14 +1,14 @@
{{define "navbar"}}
<nav class="app-bar" x-data="{ open: false }">
<nav class="app-bar" x-data="collapsible">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
</div>
<!-- Mobile menu button -->
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
-4
View File
@@ -41,10 +41,6 @@
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
</div>
<div class="flex">
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
<dd class="text-sm text-gray-900">Standard User</dd>
</div>
</dl>
</div>
</div>
+37 -25
View File
@@ -3,7 +3,7 @@
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2">
@@ -26,10 +26,10 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints -->
<div class="card">
<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">Entrypoints</h2>
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
<button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 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>
@@ -38,7 +38,7 @@
</div>
<!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
@@ -85,10 +85,10 @@
</div>
<!-- Targets -->
<div class="card">
<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 @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
<button @click="toggle" class="btn-text text-sm">
<svg class="w-4 h-4 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>
@@ -97,42 +97,42 @@
</div>
<!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="targetForm" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32">
<select name="type" @change="chooseType" 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 x-show="targetType === 'http'">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
<div x-show="isHttp">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
</div>
<div x-show="targetType === 'http'">
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
<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="targetType === 'http'" class="flex gap-2 items-center">
<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="targetType !== 'http'" class="input text-sm w-24">
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
</div>
<div x-show="targetType === 'http'">
<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="targetType === 'slack'">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
<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="targetType === 'database'">
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
<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>
@@ -181,18 +181,30 @@
<!-- Recent Events -->
<div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
</div>
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span>
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
{{if .ResubmittedFromID}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span title="Body size">{{.Size}}</span>
{{if .ProcessingTime}}
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
{{end}}
{{if .Status}}
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
{{end}}
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
</div>
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
</div>
</div>
{{else}}
+7 -7
View File
@@ -23,8 +23,8 @@
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4" x-data="{ open: false }">
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
<div class="p-4" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
@@ -43,7 +43,7 @@
</span>
{{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
@@ -71,8 +71,8 @@
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
<div class="mt-2 divide-y divide-gray-200">
{{range .Deliveries}}
<div class="py-2" x-data="{ attempts: false }">
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
<div class="py-2" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
@@ -86,13 +86,13 @@
</form>
{{end}}
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
<div x-show="open" x-cloak class="mt-2 space-y-2">
{{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}}