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
clawbot 9d29baaa2d Commit the Alpine.js tarball in 3p/ and extract it at build time (closes #345)
check / check (push) Waiting to run
The build no longer downloads Alpine.js. Its npm package tarball is committed as 3p/alpinejs-3.14.9.tgz, byte for byte the file script/fetch-assets downloaded, with the sha256 that script pinned. script/assets (make assets) extracts package/dist/cdn.min.js to the ignored static/js/alpine.min.js; script/test runs it, so make test, make check, the pre-commit hook and the Dockerfile get the file with no network access, and make build, run and dev run it too.

Removed: script/fetch-assets, its Dockerfile step, static/vendor.sha256 and static/vendor_test.go. The README describes the new flow.

Model: opus-5-5
2026-10-01 22:44:41 +02:00
clawbot 507980a347 Bound username length at creation (closes #184)
check / check (push) Successful in 4m8s
Usernames are limited to 1024 bytes, so no account can exist that is unable to log in. The username rides in the session cookie, which browsers and securecookie refuse past about 4 KB, leaving room for roughly 2000 bytes of username; the limit is about half that.

User.BeforeSave returns ErrUsernameTooLong when a whole User is created or saved. A byte-counting check constraint on users.username catches every other write, including a column update. The limit appears in the constant and in the struct tag; a test fails if they disagree.

Model: opus-5-5
2026-10-01 21:38:48 +02:00
clawbot b79e4649a1 Container sets its data directory's owner and mode itself (#353)
check / check (push) Canceled after 0s
Closes #340.

The image no longer sets `USER`. Its new `ENTRYPOINT`, `deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if missing, gives the directory and anything in it owned by another user to `webhooker` (UID 1000), sets the directory to `0750`, and runs the command as `webhooker` through `su-exec`. An empty root-owned bind mount, or data left by another UID, now works as mounted; the app never runs as root and is still PID 1. `CMD` is still `/app/webhooker`, so the `resetpw` commands are unchanged. Started with `--user`, the script only runs the command. It is in `/usr/local/bin`, not `/app`, which belongs to `webhooker`.

README: the UID 1000 ownership block, the upaas pre-deploy commands and the restore ownership step are gone; the upaas volume bullet names only the path.

- Judgement call: `su-exec` over `setpriv`: Alpine's small tool for this, needing only musl; busybox's `setpriv` cannot change user, and util-linux's adds `libcap-ng`.
- Deviation: `su-exec` is pinned by version (`0.2-r3`), not by hash; `ca-certificates` beside it is unpinned.
- Judgement call: each start reads every entry's owner but changes only entries owned by someone else.
- `docker exec` and the health check now run as root, since the image sets no `USER`.
- No automated test covers the script: the suite runs inside `docker build`, which cannot start a container.
- A missing host directory under upaas is sneak/upaas#235.

Model: opus-5-5
Reviewed-on: #353
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-09-29 13:05:16 +02:00
clawbot 1428154bbd Let a browser with cookies from an earlier database log in (closes #359)
check / check (push) Canceled after 0s
A new database brings a new session key. A browser still holding the
old session cookie got a 500 on a correct login: Session.Get returned
the cookie's decode error and the login handler answered it with a
500. Get now treats a cookie that does not decode as absent, and
logging in replaces it. gorilla/csrf already did the same for the
CSRF cookie.

A start that creates webhooker.db now logs "created a new, empty
database" at WARN with its path, shortly before the first-boot banner,
so an unexpectedly empty DATA_DIR is noticed.

The codec tests now decode through the store, since Get no longer
reports the codec's reason.

Model: opus-5-5
2026-09-29 12:58:44 +02:00
sneak 8ad2a86e4b Sneak/testdeploy (#356)
check / check (push) Successful in 11s
Reviewed-on: #356
2026-09-29 12:01:33 +02:00
sneak a891b726e5 Milestone: next into main (#342)
check / check (push) Successful in 9s
Reviewed-on: #342
2026-09-29 11:53:19 +02:00
clawbot ab63b5f777 Restrict /s/* to GET and HEAD (closes #169)
check / check (push) Successful in 3m37s
The static file server was attached with Mount, which registers every
method, so POST, PUT and DELETE on an asset were answered 200 with the
file. It is now registered for GET and HEAD only, inside a /s group
whose method-not-allowed handler answers 405 with Allow: GET, HEAD.
A method chi does not route at all, such as PROPFIND, still gets 405
from the top-level router, without Allow. The inverted test and the
README route table say the same.

Model: opus-5-5
2026-09-29 11:10:27 +02:00
36 changed files with 1377 additions and 346 deletions
+2 -4
View File
@@ -3,10 +3,8 @@
# stage of the Dockerfile.
.git/
bin/
# Third-party browser assets are fetched and hash-verified inside the build by
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
# from supplying the bytes that get shipped. The script and its
# static/vendor.sha256 manifest stay in the context.
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js
*.md
LICENSE
+3 -4
View File
@@ -46,7 +46,6 @@ temp/
# CI cache barrier, written into the build context by the check workflow
.ci-fingerprint
# Third-party browser assets, fetched and hash-verified by
# script/fetch-assets against static/vendor.sha256. Not committed:
# REPO_POLICIES.md forbids minified bundles in version control.
/static/js/alpine.min.js
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# what is committed.
/static/js/alpine.min.js
Binary file not shown.
+7 -13
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
@@ -51,15 +52,8 @@ RUN go mod download
# the lint stage above.
COPY . .
# Fetch the third-party browser assets the UI serves. They are not committed
# (REPO_POLICIES.md forbids minified bundles in version control) and
# .dockerignore keeps any host copy out of the build context, so this step is
# the only way they enter the image. Each download is checked against a
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
# hashes against the bytes go:embed actually put in the binary.
RUN script/fetch-assets
# Run tests and build
# Run tests and build. Both first run script/assets, which extracts Alpine.js
# from its tarball in 3p/.
RUN make test
# Version stamped into the binary. .dockerignore excludes .git/, so
@@ -67,8 +61,8 @@ RUN make test
# host and passes it in. The default is what a bare `docker build .`
# with no --build-arg gets, and it names no tag the tree may not be at.
#
# Declared here, below the test and asset steps, so a changed version
# does not invalidate their cached layers.
# Declared here, below the test step, so a changed version does not
# invalidate its cached layer.
ARG VERSION=unknown
RUN make build VERSION="$VERSION"
+3 -3
View File
@@ -28,7 +28,7 @@ setup:
@script/setup
assets:
@script/fetch-assets
@script/assets
test:
@script/test
@@ -45,13 +45,13 @@ fmt-check:
check:
@script/check
build:
build: assets
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build
./bin/webhooker
dev:
dev: assets
go run ./cmd/webhooker
deps:
+77 -51
View File
@@ -21,9 +21,6 @@ before deploying one.
- Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for
containerized deployment)
- `curl`, used by `script/fetch-assets` to download the third-party
browser assets, which are not committed (`make bootstrap` installs
it if missing)
golangci-lint is not a prerequisite and must not be installed on the
host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -36,9 +33,7 @@ digest-pinned linter image via `Dockerfile.lint`.
git clone https://git.eeqj.de/sneak/webhooker.git
cd webhooker
# Install Go dependencies and the third-party browser assets.
# `make deps` alone is not enough: it only runs go mod download/tidy,
# and the checks below need the fetched assets.
# Install the Go toolchain if missing, and the Go dependencies
make bootstrap
# Run all checks (test, lint, format check)
@@ -58,7 +53,7 @@ make docker
```bash
make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook
make assets # Fetch + verify third-party browser assets
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint)
@@ -162,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
@@ -1221,14 +1231,15 @@ This repository adheres to the
standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though
`build` and `version` both take their value from `script/version`.
`version` are inline commands with no script behind them, though `build`,
`run` and `dev` first run `script/assets`, and `build` and `version` both
take their value from `script/version`.
`make check` needs the third-party browser assets in `static/`, which
are not committed, so run `make bootstrap` (or just `make assets`) once
after cloning. Without them the tests fail with a message naming that
remedy. `make check` does not fetch them itself because it must not
change any files in the repo.
`script/test`, `make build` and `make dev` each run `script/assets`
first, which writes the ignored `static/js/alpine.min.js` (see
[Third-party browser assets](#third-party-browser-assets)), so
`make test`, `make check` and the pre-commit hook work on a fresh clone
without a separate step.
We provide:
@@ -1236,8 +1247,8 @@ We provide:
- `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit)
- `script/projectname` — output the project name ("webhooker")
- `script/fetch-assets` — download the third-party browser assets into
`static/`, verifying each against its pinned sha256
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
[Third-party browser assets](#third-party-browser-assets))
- `script/test` — run the test suite
- `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes)
@@ -1259,24 +1270,37 @@ We provide:
## Third-party browser assets
The web UI serves one third-party script, Alpine.js. It is **not** committed:
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
both committed build artifacts and unpinned external references.
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.
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
download against a hardcoded sha256, and installs it under `static/`. The
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
against that manifest — so the pin is enforced on what actually ships, not
merely written down. Any mismatch fails the build.
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.
`make bootstrap` runs the fetch for local development, and the Dockerfile
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
artifact out of both the repo and the build context.
`script/assets` (`make assets`) extracts the browser build,
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
it first, and the Dockerfile builds through `make test` and `make build`, so
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: update the version, URL, and tarball sha256 in
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
run `make assets && make check`.
To move to a new version: download
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
against the `dist.integrity` hash listed at
`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
@@ -1439,7 +1463,7 @@ A registered user of the webhooker service.
| Field | Type | Description |
| ---------- | -------- | ----------- |
| `id` | UUID | Primary key |
| `username` | string | Unique login name |
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie |
| `password` | string | Argon2id hash (never exposed via API) |
**Relations:** Has many Webhooks. Has many APIKeys.
@@ -1645,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
@@ -2379,14 +2404,14 @@ Removing either cap fails 14 subtests.
`internal/middleware/logbound_test.go` and
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
at each of these — 1 KB at `invalid password`, whose accounts are
shared with the successful-login line, where a username past 4 KB
overflows the session cookie and answers 500 before that line is
written — through both handlers, and through seven fills: plain text
as the baseline, and then the quotation mark, backslash, tab, newline,
C0 control and astral non-printable, six characters the wider of the
two handlers spends more on than the client spent sending them. Every
case holds each line to the 2,560-byte ceiling. That per-line ceiling
at each of these — just under 1 KB at `invalid password`, whose
accounts are shared with the successful-login line and so must stay
within the 1024-byte username limit — through both handlers, and
through seven fills: plain text as the baseline, and then the
quotation mark, backslash, tab, newline, C0 control and astral
non-printable, six characters the wider of the two handlers spends
more on than the client spent sending them. Every case holds each
line to the 2,560-byte ceiling. That per-line ceiling
is what the figure above states, and every row establishes it.
Three of the sites go further and bound the whole flood's output — the
@@ -2755,6 +2780,8 @@ imports. The entry point is `cmd/webhooker/main.go`.
```
webhooker/
├── 3p/
│ └── 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/
@@ -2847,9 +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, fetched by script/fetch-assets, not committed
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
│ ├── 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
@@ -3161,14 +3187,14 @@ version is fixed independently of the compiler's:
`make fmt-check`, then `golangci-lint config verify` and
`golangci-lint run`, both with `--network=none`.
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
stage passing (it copies a file from it), runs `script/fetch-assets`
to download and verify the third-party browser assets, then runs
`make test` and `make build`, and finally rebuilds the binary with
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
go through `make build`, the relink adding its `-extldflags` via
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
The version arrives as the `VERSION` build arg, since the context
has no `.git` (see [Version stamping](#version-stamping)).
stage passing (it copies a file from it), runs `make test` and
`make build` (both extract Alpine.js from `3p/` first), and finally
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
runs on musl. Both builds go through `make build`, the relink adding
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
stamps the version. The version arrives as the `VERSION` build arg,
since the context has no `.git` (see
[Version stamping](#version-stamping)).
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
directory for all SQLite databases, exposes port 8080, and includes
+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
+47 -2
View File
@@ -1,13 +1,58 @@
package database
import (
"errors"
"fmt"
"gorm.io/gorm"
)
// MaxUsernameBytes is the longest username, in bytes, that a user may
// have. The same number appears in the check constraint on
// User.Username, because a struct tag cannot reference a constant.
//
// A login stores the username in the session cookie, and both
// securecookie and browsers refuse a cookie value past about 4096
// bytes. That value is the session base64-encoded twice, so it holds
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
// timestamp and the session's other values take about 270 of those: a
// username longer than about 2030 bytes can never log in. The limit is
// about half that, so the session can carry more values later without
// locking out an account whose username is already at the limit.
const MaxUsernameBytes = 1024
// ErrUsernameTooLong is returned when a user is saved with a username
// longer than MaxUsernameBytes.
var ErrUsernameTooLong = errors.New("username is too long")
// User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct {
BaseModel
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations
Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"`
}
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
// User is created or saved, so those calls get ErrUsernameTooLong rather
// than the database's constraint error. A column update such as
// Update("username", ...) is caught only by the check constraint, as is
// any path that writes the table without this model.
func (u *User) BeforeSave(_ *gorm.DB) error {
if len(u.Username) > MaxUsernameBytes {
return fmt.Errorf(
"%w: %d bytes, limit is %d",
ErrUsernameTooLong,
len(u.Username),
MaxUsernameBytes,
)
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package database_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
// two-byte character. A check that counted characters rather than bytes
// would see half the length and let the one-byte-longer name through.
func usernameAtLimit() string {
return strings.Repeat("é", database.MaxUsernameBytes/2)
}
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(&database.User{
Username: usernameAtLimit() + "x",
Password: "hash",
}).Error
require.ErrorIs(t, err, database.ErrUsernameTooLong)
}
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(&database.User{
Username: usernameAtLimit(),
Password: "hash",
}).Error)
}
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
// SQL, as a path that bypassed User.BeforeSave would, so only the
// table's check constraint stands between it and an over-long
// username. Accepting the name at the limit and refusing the next byte
// also pins the constraint's number to MaxUsernameBytes.
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
require.NoError(t, db.Exec(
insert, uuid.New().String(), usernameAtLimit(), "hash",
).Error)
err := db.Exec(
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
+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
+30
View File
@@ -453,3 +453,33 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
"the issued cookie must carry an authenticated session",
)
}
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
// database.MaxUsernameBytes still fits in the session cookie. Past
// what the cookie can carry, a correct login answers 500.
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
username := strings.Repeat("a", database.MaxUsernameBytes)
hash, err := database.HashPassword(operatorPassword)
require.NoError(t, err)
require.NoError(t, db.DB().Create(&database.User{
Username: username,
Password: hash,
}).Error)
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
assert.Equal(t, http.StatusSeeOther, w.Code)
}
+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
+3 -5
View File
@@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple"
// storedFillBytes is the raw length of the client-chosen value in
// those accounts' usernames. It is well past the 512-byte field
// budget, so the line is still truncated, but short enough that the
// session cookie a successful login writes stays inside
// securecookie's 4 KB limit: the cookie is written BEFORE the
// "user logged in" line, so an 8 KB username answers 500 and never
// reaches it.
const storedFillBytes = 1024
// whole username, markers and fill name included, stays within
// database.MaxUsernameBytes.
const storedFillBytes = 960
// storedFill builds a username fill of storedFillBytes raw bytes out
// of repetitions of ch, with both markers at its far end.
+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")
}
+3 -3
View File
@@ -13,9 +13,9 @@ import (
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
// template loads on each page and fetches it through the real router.
// Alpine.js is fetched at build time rather than committed, so nothing
// in the repo guarantees it is present: this is the check that the page
// still gets the JavaScript it asks for.
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
// file is not in the tree: this is the check that the page still gets
// the JavaScript it asks for.
func TestBaseTemplateScriptsAreServed(t *testing.T) {
t.Parallel()
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/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 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-csp-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
main "$@"
+1 -8
View File
@@ -4,9 +4,7 @@
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is deliberately not installed: linting runs
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
# script/fetch-assets, which installs the hash-pinned third-party browser
# assets the repo does not commit.
# only in docker, via script/lint and Dockerfile.lint.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -69,11 +67,6 @@ main() {
go mod download
# Third-party browser assets are not committed; fetch and verify them
# so a fresh clone can build and test.
if missing curl; then pkg_install curl curl curl curl; fi
"$ROOT/script/fetch-assets"
echo "bootstrap complete"
}
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# extension to scripts-to-rule-them-all.
# Writes only the ignored static/js/alpine.min.js, through script/test.
# Generic: usually needs no adaptation.
set -eu
-104
View File
@@ -1,104 +0,0 @@
#!/bin/sh
# script/fetch-assets: download the third-party browser assets the web UI
# ships and install them under static/. Minified bundles are not committed
# (REPO_POLICIES.md: no build artifacts in version control), so the build
# fetches them here. Every download is verified against a hardcoded sha256
# before it is installed, and any mismatch aborts. Idempotent: an asset
# already present with its pinned hash is left alone.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# The sha256 of each installed asset lives in static/vendor.sha256, in
# sha256sum(1) format, with paths relative to static/. That file is the
# single source of truth: this script verifies against it, and
# static/vendor_test.go asserts the bytes embedded into the binary match
# it, so the hash cannot rot into a value nothing checks.
MANIFEST="static/vendor.sha256"
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
# a <script> tag.
ALPINE_VERSION="3.14.9"
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
# sha256 of alpinejs-3.14.9.tgz
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
ALPINE_MEMBER="package/dist/cdn.min.js"
ALPINE_DEST="js/alpine.min.js"
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
# expected_sha256 <path-relative-to-static>
expected_sha256() {
awk -v want="$1" '$2 == want { print $1; found = 1 }
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
}
# verify <file> <expected-sha256> <what>
verify() {
actual="$(sha256_of "$1")"
if [ "$actual" != "$2" ]; then
echo "fetch-assets: sha256 mismatch for $3" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# up_to_date <path-relative-to-static> <expected-sha256>
up_to_date() {
[ -f "$ROOT/static/$1" ] || return 1
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
}
fetch_alpine() {
want="$(expected_sha256 "$ALPINE_DEST")"
if up_to_date "$ALPINE_DEST" "$want"; then
echo "fetch-assets: static/$ALPINE_DEST already at $want"
return 0
fi
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
rm -rf "$tmp"
trap - EXIT INT TERM
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
}
# Re-check every manifest entry against what is now on disk, so an entry
# no script installs fails loudly instead of passing silently.
verify_manifest() {
while read -r want path; do
case "$want" in '' | '#'*) continue ;; esac
if [ ! -f "$ROOT/static/$path" ]; then
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
exit 1
fi
verify "$ROOT/static/$path" "$want" "static/$path"
done <"$ROOT/$MANIFEST"
}
main() {
cd "$ROOT"
fetch_alpine
verify_manifest
echo "fetch-assets: all assets in $MANIFEST verified"
}
main "$@"
+1
View File
@@ -28,6 +28,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/assets"
go test -v -race -timeout 90s ./...
}
+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;
},
};
});
});
-1
View File
@@ -1 +0,0 @@
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
-92
View File
@@ -1,92 +0,0 @@
package static_test
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/static"
)
const manifestPath = "vendor.sha256"
// fetchHint is appended to every failure here: the assets the manifest
// covers are fetched by the build, not committed, so a fresh clone that
// has not run script/fetch-assets fails this test and should be told why.
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
"the pinned third-party assets"
// TestVendoredAssetsMatchManifest asserts that every asset listed in
// static/vendor.sha256 is embedded in the binary with exactly the pinned
// bytes. script/fetch-assets verifies the same hashes at download time;
// this test verifies them again on what actually ships, so a build that
// skipped, cached, or subverted the fetch cannot produce a binary serving
// unpinned third-party JavaScript.
func TestVendoredAssetsMatchManifest(t *testing.T) {
t.Parallel()
entries := readManifest(t)
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
for path, want := range entries {
t.Run(path, func(t *testing.T) {
t.Parallel()
data, err := static.Static.ReadFile(path)
require.NoErrorf(
t, err,
"%s is listed in %s but is not embedded; %s",
path, manifestPath, fetchHint,
)
sum := sha256.Sum256(data)
got := hex.EncodeToString(sum[:])
require.Equalf(
t, want, got,
"embedded %s does not match its pinned sha256 in %s; %s",
path, manifestPath, fetchHint,
)
})
}
}
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
// format with paths relative to static/.
func readManifest(t *testing.T) map[string]string {
t.Helper()
f, err := os.Open(manifestPath)
require.NoError(t, err, "opening %s", manifestPath)
defer func() { require.NoError(t, f.Close()) }()
entries := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
require.Lenf(
t, fields, 2,
"%s: malformed entry %q, want \"<sha256> <path>\"",
manifestPath, line,
)
sum, path := fields[0], fields[1]
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
entries[path] = sum
}
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
return entries
}
+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}}