Author SHA1 Message Date
sneak 1647b43aa6 deploy (#365)
check / check (push) Canceled after 0s
Reviewed-on: #365
2026-09-29 13:06:27 +02:00
sneakandclawbot f7151f0168 deployment to prod (#357)
check / check (push) Successful in 10s
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
Reviewed-on: #357
2026-09-29 12:01:56 +02:00
sneak 3cc05a36eb deployment (#355)
check / check (push) Successful in 4m11s
Reviewed-on: #355
2026-09-29 11:54:06 +02:00
sneak 7316f0a7e2 Merge branch 'main' into prod
check / check (push) Successful in 7s
2026-09-29 11:49:18 +02:00
clawbotandsneak 5f84d891cf Deploy: main into prod (#343)
check / check (push) Successful in 3m32s
Brings `prod`, which upaas deploys, up to `main` at `9cf9cdd`, the merge of #321. `prod` was cut from `main` at `251cb3d` (1.0.0b1).

What it deploys is everything listed in #321. For running it:

- With `WEBHOOKER_ENVIRONMENT` unset, the instance runs as `prod` and sends no `Access-Control-Allow-Origin: *`.
- Each event database gains its new indexes the first time it is opened after the upgrade.
- `webhooker_delivery_retries_total` no longer counts a circuit breaker holding back a delivery that is already `retrying`.

Not in this PR yet: #340, in which the container sets its own data directory owner and mode before start. It is in progress on `next`. Once it reaches `main`, this PR carries it, because the PR follows `main`.

Model: opus-5-5
Co-authored-by: Jeffrey Paul <1+sneak@noreply.example.org>
Reviewed-on: #343
2026-09-29 02:48:03 -07:00
57 changed files with 792 additions and 1806 deletions
+4 -2
View File
@@ -3,8 +3,10 @@
# stage of the Dockerfile. # stage of the Dockerfile.
.git/ .git/
bin/ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Third-party browser assets are fetched and hash-verified inside the build by
# needed. The tarball in 3p/ must stay in the context. # 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.
static/js/alpine.min.js static/js/alpine.min.js
*.md *.md
LICENSE LICENSE
+4 -3
View File
@@ -46,6 +46,7 @@ temp/
# CI cache barrier, written into the build context by the check workflow # CI cache barrier, written into the build context by the check workflow
.ci-fingerprint .ci-fingerprint
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is # Third-party browser assets, fetched and hash-verified by
# what is committed. # script/fetch-assets against static/vendor.sha256. Not committed:
/static/js/alpine.min.js # REPO_POLICIES.md forbids minified bundles in version control.
/static/js/alpine.min.js
Binary file not shown.
+11 -4
View File
@@ -51,8 +51,15 @@ RUN go mod download
# the lint stage above. # the lint stage above.
COPY . . COPY . .
# Run tests and build. Both first run script/assets, which extracts Alpine.js # Fetch the third-party browser assets the UI serves. They are not committed
# from its tarball in 3p/. # (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 make test RUN make test
# Version stamped into the binary. .dockerignore excludes .git/, so # Version stamped into the binary. .dockerignore excludes .git/, so
@@ -60,8 +67,8 @@ RUN make test
# host and passes it in. The default is what a bare `docker build .` # 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. # with no --build-arg gets, and it names no tag the tree may not be at.
# #
# Declared here, below the test step, so a changed version does not # Declared here, below the test and asset steps, so a changed version
# invalidate its cached layer. # does not invalidate their cached layers.
ARG VERSION=unknown ARG VERSION=unknown
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
+3 -3
View File
@@ -28,7 +28,7 @@ setup:
@script/setup @script/setup
assets: assets:
@script/assets @script/fetch-assets
test: test:
@script/test @script/test
@@ -45,13 +45,13 @@ fmt-check:
check: check:
@script/check @script/check
build: assets build:
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
dev: assets dev:
go run ./cmd/webhooker go run ./cmd/webhooker
deps: deps:
+131 -139
View File
@@ -21,6 +21,9 @@ before deploying one.
- Go 1.26.1+ (the version in `go.mod`) - Go 1.26.1+ (the version in `go.mod`)
- Docker (for linting, for the test stage of the CI gate, and for - Docker (for linting, for the test stage of the CI gate, and for
containerized deployment) 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 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 host: `script/bootstrap` does not install it, and `make lint` runs the
@@ -33,7 +36,9 @@ digest-pinned linter image via `Dockerfile.lint`.
git clone https://git.eeqj.de/sneak/webhooker.git git clone https://git.eeqj.de/sneak/webhooker.git
cd webhooker cd webhooker
# Install the Go toolchain if missing, and the Go dependencies # 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.
make bootstrap make bootstrap
# Run all checks (test, lint, format check) # Run all checks (test, lint, format check)
@@ -53,7 +58,7 @@ make docker
```bash ```bash
make bootstrap # Install all dependencies (idempotent) make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook make setup # Bootstrap + install git pre-commit hook
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it) make assets # Fetch + verify third-party browser assets
make fmt # Format code (gofmt + goimports) make fmt # Format code (gofmt + goimports)
make fmt-check # Fail if gofmt would change anything (writes nothing) make fmt-check # Fail if gofmt would change anything (writes nothing)
make lint # Run golangci-lint in Docker (Dockerfile.lint) make lint # Run golangci-lint in Docker (Dockerfile.lint)
@@ -142,7 +147,7 @@ TTY detection, and security headers are always applied.
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` | | `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | | `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` | | `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) | | `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) | | `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
#### Allowing egress to your own network #### Allowing egress to your own network
@@ -157,21 +162,6 @@ public cloud metadata addresses: currently only `168.63.129.16`, Azure's
WireServer, which serves an Azure VM its credentials. Because it is a WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. 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 That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own 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 network. A container on the same Docker network, a box on `10.x`, a
@@ -389,37 +379,41 @@ unlocked.
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare `TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
address such as `192.168.1.7` is accepted and treated as a single address such as `192.168.1.7` is accepted and treated as a single
host), for example `192.168.1.7, 2001:db8::5`. It decides whose host), for example `192.168.1.7, 2001:db8::5`. It decides whose
`X-Forwarded-For` header the rate limiters believe, so it should cover `X-Forwarded-For` header the rate limiters believe, so it should name
the addresses of your reverse proxies. the addresses of your reverse proxies and nothing else.
`X-Forwarded-For` is honoured **only** when the connecting peer is `X-Forwarded-For` is honoured **only** when the connecting peer is
inside one of these blocks; for every other peer the client identity is inside one of these blocks; for every other peer the client identity is
the connection's own address and the header is ignored. Unset (or the connection's own address and the header is ignored. The default is
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`, the empty list, which trusts nobody — anything else would let any
`172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default client pick its own rate limit bucket, minting a fresh one per request
entirely. A set but unparseable value aborts startup. or draining someone else's. Set it to the address of your reverse
proxy, and to nothing wider. A set but unparseable value aborts
startup.
If any client can reach webhooker, or the proxy in front of it, from an That default is safe against forged headers, but leaving it unset in
RFC 1918 source address (directly, or through anything that can production has a cost you must know about. Production runs behind a
rewrite source addresses, such as NAT or a published container port), TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate request keys on the proxy's own address and all clients share a single
limit, the webhook receiver's included, can be bypassed by those bucket per limit. The receiver limits become service-wide ceilings,
clients. The address to set is the `remoteIP` field of the and the login endpoint's failure counting collapses onto one key, so a
`http request` log line for a request that came through the proxy. stranger's wrong passwords throttle every other client's wrong
passwords.
Behind a proxy the list does not cover, every request keys on the
proxy's own address and all clients share a single bucket per limit.
The receiver limits become service-wide ceilings, and the login
endpoint's failure counting collapses onto one key, so a stranger's
wrong passwords throttle every other client's wrong passwords. Set
`TRUSTED_PROXIES` to that proxy's address to restore per-client
buckets.
What it cannot do is lock the operator out. The login endpoint What it cannot do is lock the operator out. The login endpoint
verifies credentials **before** it consults any limit and charges only verifies credentials **before** it consults any limit and charges only
failures, so a correct password is never throttled no matter how full failures, so a correct password is never throttled no matter how full
the bucket is. See [Rate Limiting](#rate-limiting). the bucket is. See [Rate Limiting](#rate-limiting).
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
address, which restores per-client buckets. webhooker logs a warning
at startup whenever `TRUSTED_PROXIES` is empty, in every environment,
because behind a proxy every client shares one bucket in `dev` and
`prod` alike. The warning is informational when nothing proxies to the
process: with no proxy in front, the peer address is the client's own
and the buckets are already per-client. See
[Rate Limiting](#rate-limiting) for what each limit shares.
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer. `X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
Reverse proxies append to `X-Forwarded-For` but forward other client Reverse proxies append to `X-Forwarded-For` but forward other client
headers verbatim, so a single-valued header is client-controlled even headers verbatim, so a single-valued header is client-controlled even
@@ -435,10 +429,20 @@ instead, since past such an entry the chain is not the shape assumed
here. The peer address is likewise used when the header is absent or here. The peer address is likewise used when the header is absent or
every hop in it is a trusted proxy. every hop in it is a trusted proxy.
Your proxy must therefore **append** the peer address to Two operator requirements follow:
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
`option forwardfor`, Caddy and AWS ALB by default), and must append a - Your proxy must **append** the peer address to `X-Forwarded-For`
bare address with no port. (nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
Caddy and AWS ALB by default), and must append a bare address with
no port.
- List proxy hosts **only**. Any address inside `TRUSTED_PROXIES`
chooses its own rate-limit key: its `X-Forwarded-For` is walked, so
it can name a different address on every request to get a fresh
bucket each time, or name another client's address to drain that
client's bucket. Never list a block that also covers clients — a
broad `10.0.0.0/8` on a network where clients live in the same range
makes all three limits, including the unauthenticated webhook
receiver, silently bypassable by every client in the block.
#### Sessions #### Sessions
@@ -737,15 +741,10 @@ repository's `Dockerfile` and runs it. The app needs:
- **Volume:** one host directory mounted at `/var/lib/webhooker`. - **Volume:** one host directory mounted at `/var/lib/webhooker`.
- **Environment variables:** - **Environment variables:**
- `WEBHOOKER_ENVIRONMENT=prod` - `WEBHOOKER_ENVIRONMENT=prod`
- `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to - `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
your reverse proxy's address alone if that address is outside network. The `remoteIP` field of the `http request` log line for a
those ranges, or if any client can reach webhooker, or the proxy, request that came through the proxy shows it; the health check's
from an RFC 1918 source address (directly, or through anything own lines show `::1`. See [Trusted proxies](#trusted-proxies).
that can rewrite source addresses, such as NAT or a published
container port). The `remoteIP` field of the `http request` log
line for a request that came through the proxy shows that
address; the health check's own lines show `::1`. See
[Trusted proxies](#trusted-proxies).
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
`/var/lib/webhooker`. `/var/lib/webhooker`.
@@ -808,16 +807,12 @@ reports.
behind a proxy means the `X-Forwarded-Proto` header. The block below behind a proxy means the `X-Forwarded-Proto` header. The block below
sets it; without it every request is read as plaintext and cookies sets it; without it every request is read as plaintext and cookies
ship without `Secure`. See [Configuration](#configuration). ship without `Secure`. See [Configuration](#configuration).
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a 3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
proxy it does not cover, every rate limiter keys on the proxy, so limiter keys on the connecting peer, which behind a proxy is the
all clients share one bucket per limit. Unset, the list is the RFC proxy on every request: all clients collapse into one global bucket
1918 ranges, which do not cover a proxy that reaches the binary per limit and the receiver's per-IP limits become service-wide
itself over loopback (the binary bound to `127.0.0.1`). With the ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
image, the address to check is the `remoteIP` field of the and nothing else.
`http request` log line for a request that came through the proxy.
If any client can reach webhooker, or the proxy, from an RFC 1918
source address, set the list to the proxy's address alone. See
[Trusted proxies](#trusted-proxies).
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the 4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
port. webhooker's Origin/Referer check compares against the host it port. webhooker's Origin/Referer check compares against the host it
was given, so on any port other than 443 `$host` makes every form was given, so on any port other than 443 `$host` makes every form
@@ -1226,15 +1221,14 @@ This repository adheres to the
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow. Ten of the Makefile's seventeen targets are thin development workflow. Ten of the Makefile's seventeen targets are thin
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
`version` are inline commands with no script behind them, though `build`, `version` are inline commands with no script behind them, though
`run` and `dev` first run `script/assets`, and `build` and `version` both `build` and `version` both take their value from `script/version`.
take their value from `script/version`.
`script/test`, `make build` and `make dev` each run `script/assets` `make check` needs the third-party browser assets in `static/`, which
first, which writes the ignored `static/js/alpine.min.js` (see are not committed, so run `make bootstrap` (or just `make assets`) once
[Third-party browser assets](#third-party-browser-assets)), so after cloning. Without them the tests fail with a message naming that
`make test`, `make check` and the pre-commit hook work on a fresh clone remedy. `make check` does not fetch them itself because it must not
without a separate step. change any files in the repo.
We provide: We provide:
@@ -1242,8 +1236,8 @@ We provide:
- `script/setup` — make a fresh clone ready for development - `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit) (bootstrap, then install-precommit)
- `script/projectname` — output the project name ("webhooker") - `script/projectname` — output the project name ("webhooker")
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see - `script/fetch-assets` — download the third-party browser assets into
[Third-party browser assets](#third-party-browser-assets)) `static/`, verifying each against its pinned sha256
- `script/test` — run the test suite - `script/test` — run the test suite
- `script/lint` — run golangci-lint in Docker (see Linting below) - `script/lint` — run golangci-lint in Docker (see Linting below)
- `script/fmt` — format all code (writes) - `script/fmt` — format all code (writes)
@@ -1265,25 +1259,24 @@ We provide:
## Third-party browser assets ## Third-party browser assets
The web UI serves one third-party script, Alpine.js. Its npm package tarball The web UI serves one third-party script, Alpine.js. It is **not** committed:
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
publishes it. It is a dependency, not this repo's build output, so both committed build artifacts and unpinned external references.
`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.
`script/assets` (`make assets`) extracts the browser build, Instead `script/fetch-assets` downloads it from a pinned URL, checks the
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, download against a hardcoded sha256, and installs it under `static/`. The
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run sha256 of every installed asset is recorded in `static/vendor.sha256`, and
it first, and the Dockerfile builds through `make test` and `make build`, so `static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
nothing downloads Alpine.js. The extracted file is not committed, and against that manifest — so the pin is enforced on what actually ships, not
`.dockerignore` keeps any host copy out of the build context. merely written down. Any mismatch fails the build.
To move to a new version: download `make bootstrap` runs the fetch for local development, and the Dockerfile
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it runs it in the build stage; `.gitignore` and `.dockerignore` keep the
against the `dist.integrity` hash listed at artifact out of both the repo and the build context.
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
with it, update its file name in `script/assets`, and run `make check`. 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`.
## Rationale ## Rationale
@@ -1370,11 +1363,10 @@ It uses:
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for - **[go-chi/httprate](https://github.com/go-chi/httprate)** for
sliding-window rate limiting of the password-change and webhook sliding-window rate limiting of the password-change and webhook
receiver endpoints. The bucket is per client IP only when receiver endpoints. The bucket is per client IP only when
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the `TRUSTED_PROXIES` names the reverse proxy; unset, every client
RFC 1918 private ranges); otherwise every client behind that proxy behind that proxy shares one bucket per limit. The login endpoint
shares one bucket per limit. The login endpoint counts failed counts failed attempts itself instead, so that a correct password is
attempts itself instead, so that a correct password is never never throttled (see [Rate Limiting](#rate-limiting))
throttled (see [Rate Limiting](#rate-limiting))
- **[Prometheus](https://prometheus.io)** for metrics, served at - **[Prometheus](https://prometheus.io)** for metrics, served at
`/metrics` behind basic auth `/metrics` behind basic auth
- **[Sentry](https://sentry.io)** for optional error reporting - **[Sentry](https://sentry.io)** for optional error reporting
@@ -1447,7 +1439,7 @@ A registered user of the webhooker service.
| Field | Type | Description | | Field | Type | Description |
| ---------- | -------- | ----------- | | ---------- | -------- | ----------- |
| `id` | UUID | Primary key | | `id` | UUID | Primary key |
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie | | `username` | string | Unique login name |
| `password` | string | Argon2id hash (never exposed via API) | | `password` | string | Argon2id hash (never exposed via API) |
**Relations:** Has many Webhooks. Has many APIKeys. **Relations:** Has many Webhooks. Has many APIKeys.
@@ -1653,7 +1645,6 @@ data for auditing, for replay, and for resubmission.
| `headers` | JSON | Complete request headers | | `headers` | JSON | Complete request headers |
| `body` | text | Raw request body | | `body` | text | Raw request body |
| `content_type` | string | Content-Type header value | | `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 | | `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 **Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
@@ -2388,14 +2379,14 @@ Removing either cap fails 14 subtests.
`internal/middleware/logbound_test.go` and `internal/middleware/logbound_test.go` and
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text `internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
at each of these — just under 1 KB at `invalid password`, whose at each of these — 1 KB at `invalid password`, whose accounts are
accounts are shared with the successful-login line and so must stay shared with the successful-login line, where a username past 4 KB
within the 1024-byte username limit — through both handlers, and overflows the session cookie and answers 500 before that line is
through seven fills: plain text as the baseline, and then the written — through both handlers, and through seven fills: plain text
quotation mark, backslash, tab, newline, C0 control and astral as the baseline, and then the quotation mark, backslash, tab, newline,
non-printable, six characters the wider of the two handlers spends C0 control and astral non-printable, six characters the wider of the
more on than the client spent sending them. Every case holds each two handlers spends more on than the client spent sending them. Every
line to the 2,560-byte ceiling. That per-line ceiling 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. 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 Three of the sites go further and bound the whole flood's output — the
@@ -2545,44 +2536,47 @@ the tree is checked out: four checkouts have reported 3,959, 3,961,
client-supplied field was cut, and that the shipped chain's stack client-supplied field was cut, and that the shipped chain's stack
arrived uncut — never the numbers. arrived uncut — never the numbers.
Every limiter here — receiver, login, password change, delivery replay Every limiter here — receiver, login, and password change — identifies
and event resubmit — identifies the client the same way, through one the client the same way, through one shared key function: the
shared key function: the connection's own address, unless the peer is connection's own address, unless the peer is listed in
inside `TRUSTED_PROXIES`, in which case the forwarded client address is `TRUSTED_PROXIES`, in which case the forwarded client address is used
used instead. That address becomes a bucket by family: IPv4 keys on instead. That address becomes a bucket by family: IPv4 keys on the full
the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal address, IPv6 on its `/64` prefix. A routed `/64` is the normal
residential and mobile IPv6 allocation, so keying IPv6 per address would residential and mobile IPv6 allocation, so keying IPv6 per address would
let one subscriber rotate source addresses and mint a fresh bucket per let one subscriber rotate source addresses and mint a fresh bucket per
request, evading these limits at the network layer without spoofing request, evading these limits at the network layer without spoofing
anything; the cost is that distinct clients inside one `/64` share a anything; the cost is that distinct clients inside one `/64` share a
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
they carry. See [Trusted proxies](#trusted-proxies). When that variable they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
does not cover the reverse proxy, a client behind it shares one bucket variable set, a client behind a reverse proxy shares one bucket with
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
the proxy's address to get per-client limits back. What the shared bucket proxy's address to get per-client limits back. What the shared bucket
costs is not the same for every limiter, and the two cases pull in costs is not the same for every limiter, and the two cases pull in
opposite directions: opposite directions:
- For the **receiver** limits it costs throughput, which is the safe - For the **receiver** limits it costs throughput, which is the safe
direction to be wrong in: sharing can only make a limit bind sooner, direction to be wrong in: sharing can only make a limit bind sooner,
never let a sender past it. It matters more for the aggregate limit never let a sender past it. It matters more for the aggregate limit
than for the per-entrypoint one: with every request keyed on the than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind
proxy, the aggregate limit becomes a service-wide ceiling of 1200 the reverse proxy a production deployment is required to run behind,
requests per minute across all senders and all entrypoints, where the every request keys on the proxy, so the aggregate limit becomes a
per-entrypoint limit's capacity still grows with the number of service-wide ceiling of 1200 requests per minute across all senders
entrypoints. and all entrypoints, where the per-entrypoint limit's capacity still
grows with the number of entrypoints. Any deployment with more than a
handful of busy entrypoints must set `TRUSTED_PROXIES`.
- For the **login and password-change** limits it costs precision, not - For the **login and password-change** limits it costs precision, not
availability. Login failures from every client land in one counter, availability. Login failures from every client land in one counter,
so a stranger's wrong passwords make the operator's own wrong so a stranger's wrong passwords make the operator's own wrong
passwords answer `429` sooner; the operator's _correct_ password is passwords answer `429` sooner; the operator's _correct_ password is
never affected, because it is never counted. never affected, because it is never counted. Production deployments
should still set `TRUSTED_PROXIES`; webhooker warns at startup
whenever it is empty, in any environment.
#### The login endpoint #### The login endpoint
The login `POST` is the one endpoint with no pre-emptive limiter in The login `POST` is the one endpoint with no pre-emptive limiter in
front of it, and that is deliberate. A limiter that spends budget on front of it, and that is deliberate. A limiter that spends budget on
arrival is a lockout wherever clients share one bucket, as they do arrival is a lockout in this deployment shape: sharing one bucket, a
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
stranger sending five POSTs a minute — about 0.08 requests per second, stranger sending five POSTs a minute — about 0.08 requests per second,
from anywhere — keeps it permanently full, and the operator has no from anywhere — keeps it permanently full, and the operator has no
second administrative path. So the handler inverts the order: second administrative path. So the handler inverts the order:
@@ -2679,10 +2673,8 @@ re-fills both verification slots on its first two requests. The
remedies are to block the source at the reverse proxy, or to remedies are to block the source at the reverse proxy, or to
rate-limit `POST /pages/login` there — the one place a limit can be rate-limit `POST /pages/login` there — the one place a limit can be
applied without reintroducing the lockout, because the proxy sees the applied without reintroducing the lockout, because the proxy sees the
real client address. `TRUSTED_PROXIES` does not stop the saturation. real client address. Setting `TRUSTED_PROXIES` does not stop the
The flood's source is in the proxy's access log: webhooker's own logs saturation, but it makes the source visible in the failure logs.
record the proxy's address, not the client's (see
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
Finer-grained per-webhook rate limits (configured in the web UI and Finer-grained per-webhook rate limits (configured in the web UI and
enforced in the webhook handler) can layer on top of this env-level enforced in the webhook handler) can layer on top of this env-level
@@ -2763,8 +2755,6 @@ imports. The entry point is `cmd/webhooker/main.go`.
``` ```
webhooker/ webhooker/
├── 3p/
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
├── cmd/webhooker/ ├── cmd/webhooker/
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
├── internal/ ├── internal/
@@ -2858,7 +2848,8 @@ webhooker/
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── css/style.css # Older hand-written stylesheet, no longer loaded
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard │ ├── js/app.js # Progressive-enhancement copy-to-clipboard
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed │ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
├── Dockerfile # Three stages: lint, test+build, Alpine runtime ├── Dockerfile # Three stages: lint, test+build, Alpine runtime
@@ -3040,9 +3031,10 @@ check, see [The login endpoint](#the-login-endpoint).
It runs behind session auth, so only a client already holding a It runs behind session auth, so only a client already holding a
valid session reaches it, and an operator throttled out of changing valid session reaches it, and an operator throttled out of changing
a password can still log in. The bucket is per client IP only when a password can still log in. The bucket is per client IP only when
`TRUSTED_PROXIES` covers the reverse proxy; otherwise every client `TRUSTED_PROXIES` names the reverse proxy; unset, every client
shares one bucket, which costs precision rather than availability shares one bucket, which costs precision rather than availability
(see [Rate Limiting](#rate-limiting)) (see [Rate Limiting](#rate-limiting)). webhooker warns at startup
whenever `TRUSTED_PROXIES` is empty
- Prometheus metrics behind basic auth - Prometheus metrics behind basic auth
- Static assets embedded in binary (no filesystem access needed at - Static assets embedded in binary (no filesystem access needed at
runtime) runtime)
@@ -3169,14 +3161,14 @@ version is fixed independently of the compiler's:
`make fmt-check`, then `golangci-lint config verify` and `make fmt-check`, then `golangci-lint config verify` and
`golangci-lint run`, both with `--network=none`. `golangci-lint run`, both with `--network=none`.
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint 2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
stage passing (it copies a file from it), runs `make test` and stage passing (it copies a file from it), runs `script/fetch-assets`
`make build` (both extract Alpine.js from `3p/` first), and finally to download and verify the third-party browser assets, then runs
rebuilds the binary with `CGO_ENABLED=1` and static linking so it `make test` and `make build`, and finally rebuilds the binary with
runs on musl. Both builds go through `make build`, the relink adding `CGO_ENABLED=1` and static linking so it runs on musl. Both builds
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that go through `make build`, the relink adding its `-extldflags` via
stamps the version. The version arrives as the `VERSION` build arg, `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
since the context has no `.git` (see The version arrives as the `VERSION` build arg, since the context
[Version stamping](#version-stamping)). has no `.git` (see [Version stamping](#version-stamping)).
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and 3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
directory for all SQLite databases, exposes port 8080, and includes directory for all SQLite databases, exposes port 8080, and includes
+1 -1
View File
@@ -387,7 +387,7 @@ point of the branch.
- 2026-03-05 security headers middleware, session regeneration on - 2026-03-05 security headers middleware, session regeneration on
login, request body size limits (#41) login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages - 2026-03-04 tests for delivery, middleware, and session packages
(#32); removed the build-architecture global (#31) (#32); removed globals.Buildarch (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core - 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
delivery engine with bounded worker pool and circuit breaker, delivery engine with bounded worker pool and circuit breaker,
parallel fan-out, per-webhook event databases, management UI (#16) parallel fan-out, per-webhook event databases, management UI (#16)
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.26.1
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0 github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
@@ -30,6 +29,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
+60 -22
View File
@@ -75,11 +75,6 @@ const (
// internet-exposed endpoint. // internet-exposed endpoint.
defaultReceiverRateLimit = 120 defaultReceiverRateLimit = 120
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
// RFC 1918 private ranges, which a reverse proxy reaching the
// process over a Docker network or a private LAN connects from.
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// maxPort is the highest valid TCP port number. The lower // maxPort is the highest valid TCP port number. The lower
// bound (at least 1) is enforced by envPositiveInt. // bound (at least 1) is enforced by envPositiveInt.
maxPort = 65535 maxPort = 65535
@@ -177,14 +172,13 @@ type Config struct {
// TrustedProxies is the set of networks whose members are // TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the // allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. Unless TRUSTED_PROXIES is set it // only forwarded header read. It is empty unless
// is the RFC 1918 private ranges (defaultTrustedProxies); a set // TRUSTED_PROXIES is set, and empty means no peer is
// value replaces them. If any client can reach the process, or // trusted: forwarded headers are then ignored entirely and
// the proxy in front of it, from an RFC 1918 source address // clients are identified by the connection's own address.
// (directly, or through anything that can rewrite source // Members can choose their own rate-limit key, so this must
// addresses, such as NAT or a published container port), it // name proxy hosts only, never a block that also covers
// must be set to the proxy's address alone, or every rate limit // clients.
// can be bypassed by those clients.
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target // AllowedEgressCIDRs is the set of networks a delivery target
@@ -466,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
// envPrefixList returns the value of the named environment variable // envPrefixList returns the value of the named environment variable
// parsed as a comma-separated list of CIDR blocks (bare addresses // parsed as a comma-separated list of CIDR blocks (bare addresses
// allowed). An unset, empty, or blank value is read as defaultValue // allowed). An unset, empty, or blank value yields an empty list. A
// instead. A set value containing an unparseable entry is a hard // set value containing an unparseable entry is a hard error naming
// error naming the key and the bad entry, so startup fails loudly // the key and the bad entry, so startup fails loudly rather than
// rather than silently running with a list the operator did not // silently running with a list the operator did not intend.
// intend. func envPrefixList(key string) ([]netip.Prefix, error) {
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
v = defaultValue return nil, nil
} }
var prefixes []netip.Prefix var prefixes []netip.Prefix
@@ -688,12 +681,12 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies) trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
if err != nil { if err != nil {
return nil, err return nil, err
} }
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "") allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -767,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
) )
} }
// warnSharedRateLimitBucket logs a startup warning whenever
// TRUSTED_PROXIES is empty, in any environment.
//
// With no trusted proxies every rate limiter keys on the connecting
// peer's address. Whether that is harmless or dangerous depends on
// what is in front of the process, which this code cannot observe:
// with nothing in front, the peer is the client and the limits are
// per-client as intended; behind a reverse proxy the peer is the proxy
// for every request, so all clients share one bucket per limiter.
//
// The login endpoint no longer spends budget on arrival — it verifies
// credentials first and charges only failures — so a shared bucket
// cannot deny the operator a correct password. What it does collapse
// is the failure counting: one client's wrong passwords throttle
// everyone else's wrong passwords, and the receiver's limits become
// service-wide ceilings.
//
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
// behind a proxy every client shares one bucket in dev and prod alike.
//
// The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket —
// so this warns rather than failing startup or changing the key.
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
if len(c.TrustedProxies) > 0 {
return
}
log.Warn(
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
"connecting peer's address. With nothing proxying to "+
"this process that is the client itself and the limits "+
"are per-client as intended. Behind a reverse proxy the "+
"peer is the proxy on every request, so all clients "+
"share one bucket per limit: the receiver limits become "+
"service-wide ceilings, and one client's failed logins "+
"throttle every other client's failed logins — a "+
"correct password still gets in. If anything proxies to "+
"this process, set TRUSTED_PROXIES to its address.",
"environment", c.Environment,
"trustedProxies", len(c.TrustedProxies),
)
}
// New creates a Config by reading environment variables. // New creates a Config by reading environment variables.
// //
//nolint:revive // lc parameter is required by fx even if unused. //nolint:revive // lc parameter is required by fx even if unused.
@@ -812,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"hasMetricsAuth", s.MetricsAuthEnabled(), "hasMetricsAuth", s.MetricsAuthEnabled(),
) )
s.warnSharedRateLimitBucket(log)
s.warnEgressAllowlist(log) s.warnEgressAllowlist(log)
return s, nil return s, nil
+101 -14
View File
@@ -551,11 +551,6 @@ func testReceiverRateLimitSuccess(
} }
func TestTrustedProxies(t *testing.T) { func TestTrustedProxies(t *testing.T) {
// Unset, the RFC 1918 private ranges are trusted, so a reverse
// proxy on a Docker network or a private LAN is covered without
// configuration.
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
tests := []struct { tests := []struct {
name string name string
set bool set bool
@@ -564,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
expected []string expected []string
}{ }{
{ {
// The default must be "trust nobody": an empty list
// means forwarded headers are ignored, never that
// every peer may speak for the client.
name: caseUnsetUsesDefault, name: caseUnsetUsesDefault,
set: false, set: false,
expected: defaultProxies, expected: []string{},
}, },
{ {
name: "blank value uses default", name: "blank value trusts nothing",
set: true, set: true,
value: " ", value: " ",
expected: defaultProxies, expected: []string{},
},
{
name: "set value replaces the default entirely",
set: true,
value: "203.0.113.7",
expected: []string{"203.0.113.7/32"},
}, },
{ {
name: caseValidValueParsed, name: caseValidValueParsed,
@@ -853,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
} }
} }
// TestSharedRateLimitBucketWarning covers the startup warning that
// tells an operator a deployment behind a reverse proxy shares one
// rate-limit bucket between every client, which turns the receiver
// limits into service-wide ceilings and collapses login failure
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
// environment, because behind a proxy every client shares one bucket
// in dev and prod alike. It stays quiet once proxies are named.
func TestSharedRateLimitBucketWarning(t *testing.T) {
tests := []struct {
name string
environment string
trustedProxies string
expectWarning bool
}{
{
name: "prod without trusted proxies warns",
environment: config.EnvironmentProd,
expectWarning: true,
},
{
name: "prod with trusted proxies is quiet",
environment: config.EnvironmentProd,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
{
name: "dev without trusted proxies warns",
environment: config.EnvironmentDev,
expectWarning: true,
},
{
name: "dev with trusted proxies is quiet",
environment: config.EnvironmentDev,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
if tt.trustedProxies == "" {
require.NoError(
t, os.Unsetenv("TRUSTED_PROXIES"),
)
} else {
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
}
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
},
))
require.NoError(
t,
config.WarnSharedRateLimitBucketForTest(log),
)
if !tt.expectWarning {
assert.Empty(t, buf.String())
return
}
logged := buf.String()
assert.Contains(t, logged, `"level":"WARN"`)
assert.Contains(t, logged, "TRUSTED_PROXIES")
assert.Contains(t, logged, "share one bucket")
assert.Contains(
t, logged, "throttle every other client's failed logins",
)
// The warning must not claim a lockout the login
// endpoint no longer permits: credentials are verified
// before any budget is spent.
assert.Contains(
t, logged, "a correct password still gets in",
)
// The text must stay accurate for a developer with
// nothing in front of the process, where an empty
// list costs nothing.
assert.Contains(
t, logged, "nothing proxying to this process",
)
})
}
}
// metricsEnv describes what one subtest below puts in the // metricsEnv describes what one subtest below puts in the
// environment for a single METRICS_ variable. A variable that is // environment for a single METRICS_ variable. A variable that is
// set to the empty string and one that is not set at all are // set to the empty string and one that is not set at all are
+15
View File
@@ -6,6 +6,21 @@ import "log/slog"
// the external config_test package so each helper can be covered by // the external config_test package so each helper can be covered by
// its own table-driven test without weakening the package API. // its own table-driven test without weakening the package API.
// WarnSharedRateLimitBucketForTest loads a Config from the current
// environment and emits its startup warnings to log. The real logger
// writes to stdout, so this lets the warning's firing condition be
// asserted against a handler the test controls.
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
c, err := loadFromEnv()
if err != nil {
return err
}
c.warnSharedRateLimitBucket(log)
return nil
}
// WarnEgressAllowlistForTest loads a Config from the current // WarnEgressAllowlistForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to // environment and emits its egress-allowlist startup warning to
// log, so a test can assert both that the warning fires only when // log, so a test can assert both that the warning fires only when
-5
View File
@@ -31,11 +31,6 @@ type Event struct {
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"` 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 // ResubmittedFromID names the event this one was copied from by
// an operator resubmit. It is nil for an event that arrived on // an operator resubmit. It is nil for an event that arrived on
// the receiver, which is every event created before the column // the receiver, which is every event created before the column
+2 -47
View File
@@ -1,58 +1,13 @@
package database 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 // User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct { type User struct {
BaseModel BaseModel
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"` Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations // Relations
Webhooks []Webhook `json:"webhooks,omitempty"` Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,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
@@ -1,65 +0,0 @@
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,13 +43,6 @@ var (
// permit specific blocks out of this set with // permit specific blocks out of this set with
// ALLOWED_EGRESS_CIDRS; see Guard. // 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 //nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet var blockedNetworks []*net.IPNet
+26 -9
View File
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
@@ -103,10 +103,9 @@ func (h *Handlers) renderLoginError(
// The credential check runs BEFORE any rate-limit budget is // The credential check runs BEFORE any rate-limit budget is
// consulted, and only a failed check spends budget. That is what // consulted, and only a failed check spends budget. That is what
// keeps the single administrative path reachable: behind the reverse // keeps the single administrative path reachable: behind the reverse
// proxy this deployment requires, when TRUSTED_PROXIES does not cover // proxy this deployment requires, with TRUSTED_PROXIES unset, every
// it, every client shares one bucket, so a limiter spent on arrival // client shares one bucket, so a limiter spent on arrival lets any
// lets any stranger deny the operator's own correct password // stranger deny the operator's own correct password indefinitely.
// indefinitely.
// //
// Verifying first means every login POST costs an Argon2id hash, so // Verifying first means every login POST costs an Argon2id hash, so
// the work is taken under a bounded number of verification slots. // the work is taken under a bounded number of verification slots.
@@ -166,7 +165,11 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password) valid, err := database.VerifyPassword(password, user.Password)
if err != nil { if err != nil {
h.serverError(w, r, "failed to verify password", err) h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err return user, err
} }
@@ -238,14 +241,24 @@ func (h *Handlers) createAuthenticatedSession(
) error { ) error {
oldSess, err := h.session.Get(r) oldSess, err := h.session.Get(r)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get session", err) h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
sess, err := h.session.Regenerate(r, w, oldSess) sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil { if err != nil {
h.serverError(w, r, "failed to regenerate session", err) h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
@@ -254,7 +267,11 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess) err = h.session.Save(r, w, sess)
if err != nil { if err != nil {
h.serverError(w, r, "failed to save session", err) h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err return err
} }
+6 -36
View File
@@ -25,7 +25,7 @@ const (
// sharedProxyPeer is the whole point of this file. Production is // sharedProxyPeer is the whole point of this file. Production is
// required to run behind a TLS-terminating reverse proxy, and // required to run behind a TLS-terminating reverse proxy, and
// when TRUSTED_PROXIES does not cover it every client — attacker // TRUSTED_PROXIES defaults to empty, so every client — attacker
// and operator alike — reaches the process from the proxy's // and operator alike — reaches the process from the proxy's
// address and shares one rate-limit bucket. Both parties in // address and shares one rate-limit bucket. Both parties in
// these tests therefore use the same RemoteAddr. // these tests therefore use the same RemoteAddr.
@@ -115,11 +115,11 @@ func floodFailures(
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150. // done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
// //
// The attacker and the operator share one rate-limit bucket, because // The attacker and the operator share one rate-limit bucket, because
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not // behind the mandated reverse proxy with TRUSTED_PROXIES unset every
// cover it, every client keys on the proxy's address. The attacker // client keys on the proxy's address. The attacker floods the
// floods the operator's own username — a single-admin product has a // operator's own username — a single-admin product has a predictable
// predictable one — far past the failure limit. The operator must // one — far past the failure limit. The operator must still be able
// still be able to log in with the correct password. // to log in with the correct password.
// //
// This fails if credentials stop being verified ahead of the limiter. // This fails if credentials stop being verified ahead of the limiter.
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) { func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
@@ -453,33 +453,3 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
"the issued cookie must carry an authenticated session", "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)
}
+9 -7
View File
@@ -105,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -122,14 +124,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook, webhook database.Webhook,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
@@ -171,7 +173,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"), &original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error ).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return nil, false return nil, false
} }
@@ -193,7 +195,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to count in-flight deliveries", err, w, "failed to count in-flight deliveries", err,
) )
return return
@@ -210,7 +212,7 @@ func (h *Handlers) queueReplay(
err = webhookDB. err = webhookDB.
First(&event, "id = ?", original.EventID).Error First(&event, "id = ?", original.EventID).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to load event for replay", err) h.serverError(w, "failed to load event for replay", err)
return return
} }
@@ -220,7 +222,7 @@ func (h *Handlers) queueReplay(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to create replay delivery", err, w, "failed to create replay delivery", err,
) )
return return
-53
View File
@@ -1,53 +0,0 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client. // steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string, eventID string,
) { ) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
body, found, err := eventBody(webhookDB, webhook.ID, eventID) body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to read event body", err) h.serverError(w, "failed to read event body", err)
return return
} }
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the // row and the whole body is served, or it does not and the
// response is a clean 404. // response is a clean 404.
if !found { if !found {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
+8 -8
View File
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request. // alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID")) eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get webhook database", err) h.serverError(w, "failed to get webhook database", err)
return return
} }
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(), webhookDB, webhook.ID, eventID.String(),
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to load event to resubmit", err) h.serverError(w, "failed to load event to resubmit", err)
return return
} }
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to // A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed. // another webhook, or never existed.
if !found { if !found {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused. // inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID) targets, err := h.loadActiveTargets(webhook.ID)
if err != nil { if err != nil {
h.serverError(w, r, "failed to query targets", err) h.serverError(w, "failed to query targets", err)
return return
} }
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
targets, targets,
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to store resubmitted event", err) h.serverError(w, "failed to store resubmitted event", err)
return return
} }
-1
View File
@@ -204,7 +204,6 @@ func assertEventCopy(
assert.Equal(t, original.Method, fresh.Method) assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body) 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.ContentType, fresh.ContentType)
assert.Equal(t, original.EntrypointID, fresh.EntrypointID) assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
assert.Equal(t, original.WebhookID, fresh.WebhookID) assert.Equal(t, original.WebhookID, fresh.WebhookID)
+2 -12
View File
@@ -1,11 +1,9 @@
package handlers package handlers
import ( import (
"context"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -65,20 +63,12 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int, page int,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil, page, w, webhook, nil, page,
) )
return views return views
} }
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that // AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a // the handlers_test package can drive the render path with a
// template of its own. // template of its own.
@@ -132,5 +122,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter, w http.ResponseWriter,
expiry string, expiry string,
) (string, error) { ) (string, error) {
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry) return s.buildDatabaseTargetConfig(w, expiry)
} }
+20 -89
View File
@@ -28,7 +28,7 @@ const (
// maxBodyShift is the bit shift for 1 MB body limit. // maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20 maxBodyShift = 20
// recentEventLimit is the number of recent events to show. // recentEventLimit is the number of recent events to show.
recentEventLimit = 50 recentEventLimit = 20
// paginationPerPage is the number of items per page. // paginationPerPage is the number of items per page.
paginationPerPage = 25 paginationPerPage = 25
@@ -135,7 +135,6 @@ func New(
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"), "source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
} }
lc.Append(fx.Hook{ lc.Append(fx.Hook{
@@ -147,15 +146,6 @@ func New(
return s, nil return s, nil
} }
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths and
// the CSRF middleware for a refused form.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON( func (s *Handlers) respondJSON(
w http.ResponseWriter, w http.ResponseWriter,
_ *http.Request, _ *http.Request,
@@ -173,76 +163,15 @@ func (s *Handlers) respondJSON(
} }
} }
// serverError logs an error and answers with the 500 error page. // serverError logs an error and sends a 500 response.
func (s *Handlers) serverError( func (s *Handlers) serverError(
w http.ResponseWriter, r *http.Request, msg string, err error, w http.ResponseWriter, msg string, err error,
) { ) {
s.log.Error(msg, "error", err) s.log.Error(msg, "error", err)
s.renderError(w, r, http.StatusInternalServerError) http.Error(
} w, "Internal server error",
http.StatusInternalServerError,
// renderError answers with status and the error page: the normal )
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
// The page names the signed-in user, and some error pages are
// served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store")
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
})
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
} }
// UserInfo represents user information for templates // UserInfo represents user information for templates
@@ -295,17 +224,14 @@ func (s *Handlers) renderTemplate(
"template not found", "template not found",
"template", pageTemplate, "template", pageTemplate,
) )
s.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
}
// pageData adds the fields the shared layout renders to a page's own
// data.
func (s *Handlers) pageData(r *http.Request, data any) any {
userInfo := s.getUserInfo(r) userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r) csrfToken := middleware.CSRFToken(r)
@@ -319,16 +245,19 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
m["User"] = userInfo m["User"] = userInfo
m["CSRFToken"] = csrfToken m["CSRFToken"] = csrfToken
m["Version"] = version m["Version"] = version
s.executeTemplate(w, tmpl, m)
return m return
} }
return templateDataWrapper{ wrapper := templateDataWrapper{
User: userInfo, User: userInfo,
CSRFToken: csrfToken, CSRFToken: csrfToken,
Version: version, Version: version,
Data: data, Data: data,
} }
s.executeTemplate(w, tmpl, wrapper)
} }
// executeTemplate renders the template into a buffer and writes to // executeTemplate renders the template into a buffer and writes to
@@ -341,7 +270,6 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
// this reason. // this reason.
func (s *Handlers) executeTemplate( func (s *Handlers) executeTemplate(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
tmpl *template.Template, tmpl *template.Template,
data any, data any,
) { ) {
@@ -352,7 +280,10 @@ func (s *Handlers) executeTemplate(
s.log.Error( s.log.Error(
"failed to execute template", "error", err, "failed to execute template", "error", err,
) )
s.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
+2 -6
View File
@@ -307,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code, t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500", "a failed render must report a 500",
) )
assert.NotContains( assert.Equal(
t, w.Body.String(), partialPageMarker, t, "Internal server error\n", w.Body.String(),
"the response must carry no part of the aborted page", "the response must carry no part of the aborted page",
) )
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
} }
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+5 -3
View File
@@ -339,9 +339,11 @@ const storedUserPassword = "correct-horse-battery-staple"
// storedFillBytes is the raw length of the client-chosen value in // storedFillBytes is the raw length of the client-chosen value in
// those accounts' usernames. It is well past the 512-byte field // those accounts' usernames. It is well past the 512-byte field
// budget, so the line is still truncated, but short enough that the // budget, so the line is still truncated, but short enough that the
// whole username, markers and fill name included, stays within // session cookie a successful login writes stays inside
// database.MaxUsernameBytes. // securecookie's 4 KB limit: the cookie is written BEFORE the
const storedFillBytes = 960 // "user logged in" line, so an 8 KB username answers 500 and never
// reaches it.
const storedFillBytes = 1024
// storedFill builds a username fill of storedFillBytes raw bytes out // storedFill builds a username fill of storedFillBytes raw bytes out
// of repetitions of ch, with both markers at its far end. // of repetitions of ch, with both markers at its far end.
+28 -16
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.log.Error("failed to parse form", "error", err) h.log.Error("failed to parse form", "error", err)
h.renderError(w, r, http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
successMessage, errorMessage, handled := h.applyPasswordChange( successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w, w,
r,
sessionUsername, sessionUsername,
// PostFormValue, not FormValue: the credential must // PostFormValue, not FormValue: the credential must
// come from the body, never from the query string. // come from the body, never from the query string.
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success, // applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that // persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages // bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes the // to display on the profile page. On an internal failure it writes a
// error page itself and returns handled=false, signalling the caller // 500 response itself and returns handled=false, signalling the caller
// to stop without re-rendering the page. // to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange( func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string, username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) { ) (string, string, bool) {
// This endpoint verifies one password and hashes another, at // This endpoint verifies one password and hashes another, at
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving // endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held // this path outside it would leave a hole in it. The slot is held
// across both hashes. // across both hashes.
release, ok := h.mw.BeginPasswordVerification(r.Context()) release, ok := h.mw.BeginPasswordVerification(ctx)
if !ok { if !ok {
h.log.Warn("password verification capacity exhausted") h.log.Warn("password verification capacity exhausted")
h.renderError(w, r, http.StatusServiceUnavailable) http.Error(
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
return "", "", false return "", "", false
} }
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error ).First(&user).Error
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to load user for password change", err, w, "failed to load user for password change", err,
) )
return "", "", false return "", "", false
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password, currentPassword, user.Password,
) )
if err != nil { if err != nil {
h.serverError(w, r, "failed to verify password", err) h.serverError(w, "failed to verify password", err)
return "", "", false return "", "", false
} }
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword) hashedPassword, err := database.HashPassword(newPassword)
if err != nil { if err != nil {
h.serverError(w, r, "failed to hash new password", err) h.serverError(w, "failed to hash new password", err)
return "", "", false return "", "", false
} }
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword, "password", hashedPassword,
).Error ).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to update password", err) h.serverError(w, "failed to update password", err)
return "", "", false return "", "", false
} }
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) { ) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username") requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" { if requestedUsername == "" {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return "", "", false return "", "", false
} }
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error. // unexpected retrieval error.
sess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err != nil { if err != nil {
h.serverError(w, r, "failed to get session", err) h.serverError(w, "failed to get session", err)
return "", "", false return "", "", false
} }
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess) sessionUsername, ok := h.session.GetUsername(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing username") h.log.Error("authenticated session missing username")
h.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess) sessionUserID, ok := h.session.GetUserID(sess)
if !ok { if !ok {
h.log.Error("authenticated session missing user ID") h.log.Error("authenticated session missing user ID")
h.renderError(w, r, http.StatusInternalServerError) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", "", false return "", "", false
} }
// Only allow users to act on their own profile. // Only allow users to act on their own profile.
if requestedUsername != sessionUsername { if requestedUsername != sessionUsername {
h.renderError(w, r, http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return "", "", false return "", "", false
} }
+2 -6
View File
@@ -88,8 +88,6 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
h.HandleProfile().ServeHTTP(w, req) h.HandleProfile().ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code) 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) { func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
@@ -128,9 +126,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session var sess *session.Session
var h *handlers.Handlers app := newTestApp(t, &log, &cfg, &sess)
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart() app.RequireStart()
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
@@ -141,7 +137,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter() router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) { router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden))) r.Use(mw.CSRF())
r.Use(mw.RequireAuth()) r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) { r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true handlerReached = true
-293
View File
@@ -1,293 +0,0 @@
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
@@ -1,362 +0,0 @@
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.")
}
+3 -18
View File
@@ -62,23 +62,6 @@ func renderSourceDetailPage(
) string { ) string {
t.Helper() 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( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
@@ -104,7 +87,9 @@ func serveSourceDetailPage(
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.HandleSourceDetail().ServeHTTP(w, req) h.HandleSourceDetail().ServeHTTP(w, req)
return w require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
} }
// TestHandleSourceDetail_MasksSlackWebhookURL is the // TestHandleSourceDetail_MasksSlackWebhookURL is the
+67 -52
View File
@@ -149,7 +149,13 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID, "user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error ).Order("created_at DESC").Find(&webhooks).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to list webhooks", err) h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
@@ -243,7 +249,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -303,7 +311,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook) err := h.commitWebhook(webhook)
if err != nil { if err != nil {
h.serverError(w, r, "failed to create webhook", err) h.serverError(w, "failed to create webhook", err)
return return
} }
@@ -380,7 +388,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -407,23 +415,16 @@ func (h *Handlers) renderSourceDetail(
"webhook_id = ?", webhook.ID, "webhook_id = ?", webhook.ID,
).Find(&targets) ).Find(&targets)
var events []RecentEventView var events []database.Event
if h.dbMgr.DBExists(webhook.ID) { if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
if err != nil { if dbErr == nil {
h.serverError(w, r, "failed to get webhook database", err) webhookDB.Where(
"webhook_id = ?", webhook.ID,
return ).Order("created_at DESC").Limit(
} recentEventLimit,
).Find(&events)
events, err = loadRecentEvents(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, r, "failed to load recent events", err)
return
} }
} }
@@ -474,7 +475,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -509,7 +510,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -518,7 +519,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -572,7 +575,7 @@ func (h *Handlers) applyWebhookEdit(
err := h.db.DB().Save(webhook).Error err := h.db.DB().Save(webhook).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to update webhook", err) h.serverError(w, "failed to update webhook", err)
return return
} }
@@ -602,7 +605,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -629,7 +632,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered. // be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook) err := h.commitWebhookDeletion(&webhook)
if err != nil { if err != nil {
h.serverError(w, r, "failed to delete webhook", err) h.serverError(w, "failed to delete webhook", err)
return return
} }
@@ -655,7 +658,7 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file // redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it. // needs removing by hand, and the logged error names it.
h.serverError( h.serverError(
w, r, "failed to delete webhook event database", err, w, "failed to delete webhook event database", err,
) )
return return
@@ -799,7 +802,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return database.Webhook{}, false return database.Webhook{}, false
} }
@@ -821,7 +824,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a // Without the map every delivery renders through a
// zero redactor, so failing the page is the only // zero redactor, so failing the page is the only
// safe answer. // safe answer.
h.serverError(w, r, "failed to load targets", err) h.serverError(w, "failed to load targets", err)
return return
} }
@@ -829,7 +832,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r) page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries( evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets, page, w, webhook, targets, page,
) )
if !ok { if !ok {
return return
@@ -939,7 +942,6 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further. // caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries( func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
webhook database.Webhook, webhook database.Webhook,
targetMap map[string]eventLogTarget, targetMap map[string]eventLogTarget,
page int, page int,
@@ -953,7 +955,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID) webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to get webhook database", err, w, "failed to get webhook database", err,
) )
return nil, 0, false return nil, 0, false
@@ -990,7 +992,7 @@ func (h *Handlers) loadEventsWithDeliveries(
) )
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to load delivery attempts", err, w, "failed to load delivery attempts", err,
) )
return nil, 0, false return nil, 0, false
@@ -999,7 +1001,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs) resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil { if err != nil {
h.serverError( h.serverError(
w, r, "failed to count event resubmissions", err, w, "failed to count event resubmissions", err,
) )
return nil, 0, false return nil, 0, false
@@ -1222,7 +1224,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -1231,7 +1233,9 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -1247,7 +1251,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error err = h.db.DB().Create(entrypoint).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to create entrypoint", err) h.serverError(w, "failed to create entrypoint", err)
return return
} }
@@ -1278,7 +1282,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -1287,7 +1291,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err = r.ParseForm() err = r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -1358,7 +1364,7 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error err = h.db.DB().Create(target).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to create target", err) h.serverError(w, "failed to create target", err)
return return
} }
@@ -1452,7 +1458,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack: case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL) return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, r, in.Expiry) return h.buildDatabaseTargetConfig(w, in.Expiry)
case database.TargetTypeLog: case database.TargetTypeLog:
return "", nil return "", nil
default: default:
@@ -1502,7 +1508,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err return "", err
} }
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{ return marshalTargetConfig(w, delivery.HTTPTargetConfig{
URL: in.URL, URL: in.URL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
@@ -1524,7 +1530,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err return "", err
} }
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{ return marshalTargetConfig(w, delivery.SlackTargetConfig{
WebhookURL: targetURL, WebhookURL: targetURL,
}) })
} }
@@ -1578,14 +1584,16 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage, // marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot. // writing a 500 itself if it cannot.
func (h *Handlers) marshalTargetConfig( func marshalTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
cfg any, cfg any,
) (string, error) { ) (string, error) {
configBytes, err := json.Marshal(cfg) configBytes, err := json.Marshal(cfg)
if err != nil { if err != nil {
h.serverError(w, r, "failed to encode target config", err) http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return "", err return "", err
} }
@@ -1601,7 +1609,6 @@ func (h *Handlers) marshalTargetConfig(
// expiry yields an empty config (the keep-forever default). // expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig( func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request,
expiry string, expiry string,
) (string, error) { ) (string, error) {
expiry = strings.TrimSpace(expiry) expiry = strings.TrimSpace(expiry)
@@ -1620,8 +1627,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err return "", err
} }
return h.marshalTargetConfig( return marshalTargetConfig(
w, r, map[string]any{"expiry": expiry}, w, map[string]any{"expiry": expiry},
) )
} }
@@ -1675,7 +1682,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
@@ -1685,7 +1692,11 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID, childID, webhook.ID,
).Delete(model) ).Delete(model)
if result.Error != nil { if result.Error != nil {
h.serverError(w, r, errMsg, result.Error) h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
@@ -1775,14 +1786,18 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID, "id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error ).First(&webhook).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return return
} }
err = toggleFn(webhook.ID, childID) err = toggleFn(webhook.ID, childID)
if err != nil { if err != nil {
h.serverError(w, r, errMsg, err) h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return return
} }
+5 -3
View File
@@ -88,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form. // middleware, which runs before CSRF parses the form.
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
h.renderError(w, r, http.StatusBadRequest) http.Error(
w, "Bad request", http.StatusBadRequest,
)
return return
} }
@@ -155,7 +157,7 @@ func (h *Handlers) applyTargetEdit(
err = h.db.DB().Save(target).Error err = h.db.DB().Save(target).Error
if err != nil { if err != nil {
h.serverError(w, r, "failed to update target", err) h.serverError(w, "failed to update target", err)
return return
} }
@@ -218,7 +220,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID, chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error ).First(&target).Error
if err != nil { if err != nil {
h.renderError(w, r, http.StatusNotFound) http.NotFound(w, r)
return database.Webhook{}, nil, false return database.Webhook{}, nil, false
} }
+3 -17
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header) headersJSON, err := json.Marshal(r.Header)
if err != nil { if err != nil {
h.receiverError(w, "failed to serialize headers", err) h.serverError(w, "failed to serialize headers", err)
return return
} }
targets, err := h.loadActiveTargets(entrypoint.WebhookID) targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil { if err != nil {
h.receiverError(w, "failed to query targets", err) h.serverError(w, "failed to query targets", err)
return return
} }
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets, targets,
) )
if err != nil { if err != nil {
h.receiverError(w, "failed to store webhook event", err) h.serverError(w, "failed to store webhook event", err)
return return
} }
@@ -204,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks) h.finishWebhookResponse(w, event, entrypoint, tasks)
} }
// receiverError logs an error and answers the sender with a plain-text
// 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses.
func (h *Handlers) receiverError(
w http.ResponseWriter, msg string, err error,
) {
h.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// eventSource carries the fields a new event is built from. The // eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills // receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an // it from a stored event. Both then go through createAndFanOut, so an
@@ -243,7 +230,6 @@ func (s eventSource) event() *database.Event {
Method: s.Method, Method: s.Method,
Headers: s.HeadersJSON, Headers: s.HeadersJSON,
Body: string(s.Body), Body: string(s.Body),
BodyBytes: int64(len(s.Body)),
ContentType: s.ContentType, ContentType: s.ContentType,
ResubmittedFromID: s.ResubmittedFromID, ResubmittedFromID: s.ResubmittedFromID,
} }
+3 -5
View File
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
// key to sign a CSRF cookie and validates a masked token submitted via // key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on // the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing // POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token are logged and answered by forbidden, which must write the 403. // token receive a 403 Forbidden response.
// //
// The middleware detects the client-facing transport protocol // The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session // per-request via reqtls.IsTLS, the single TLS predicate the session
@@ -36,9 +36,7 @@ func CSRFToken(r *http.Request) string {
// Two gorilla/csrf instances are maintained — one with Secure cookies // Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the // (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request. // csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF( func (m *Middleware) CSRF() func(http.Handler) http.Handler {
forbidden http.Handler,
) func(http.Handler) http.Handler {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route // CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an // group that uses it, so this WARN is reachable by an
@@ -59,7 +57,7 @@ func (m *Middleware) CSRF(
"remote_addr", r.RemoteAddr, "remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r), "reason", csrf.FailureReason(r),
) )
forbidden.ServeHTTP(w, r) http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
}) })
key := m.session.GetKey() key := m.session.GetKey()
+9 -15
View File
@@ -18,12 +18,6 @@ import (
// csrfCookieName is the gorilla/csrf cookie name. // csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf" const csrfCookieName = "_gorilla_csrf"
// forbidden stands in for the error page the server hands CSRF to
// answer a refused request with.
func forbidden(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
// csrfGetToken performs a GET request through the CSRF middleware // csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies. // and returns the token and cookies.
func csrfGetToken( func csrfGetToken(
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string var gotToken string
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc( handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r) gotToken = middleware.CSRFToken(r)
}, },
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev) m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper() t.Helper()
m, _ := testMiddleware(t, env) m, _ := testMiddleware(t, env)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
// GET to establish the CSRF cookie // GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc( getHandler := csrfMW(http.HandlerFunc(
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev) m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
// GET to establish the CSRF cookie // GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc( getHandler := csrfMW(http.HandlerFunc(
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool var called bool
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc( handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) { func(_ http.ResponseWriter, _ *http.Request) {
called = true called = true
}, },
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
t.Helper() t.Helper()
m, _ := testMiddleware(t, env) m, _ := testMiddleware(t, env)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
newReq := func(method string) *http.Request { newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext( r := httptest.NewRequestWithContext(
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel() t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd) m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF(http.HandlerFunc(forbidden)) csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext( getReq := httptest.NewRequestWithContext(
context.Background(), context.Background(),
+1 -3
View File
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
) http.Handler { ) http.Handler {
t.Helper() t.Helper()
return m.CSRF(http.HandlerFunc(forbidden))( return m.CSRF()(unreachable(t))
unreachable(t),
)
}, },
send: postNoToken, send: postNoToken,
wantStatus: http.StatusForbidden, wantStatus: http.StatusForbidden,
+4 -4
View File
@@ -108,10 +108,10 @@ type failureWindow struct {
// //
// A limiter that spends budget on arrival cannot protect a // A limiter that spends budget on arrival cannot protect a
// single-admin product: behind the reverse proxy the deployment // single-admin product: behind the reverse proxy the deployment
// requires, when TRUSTED_PROXIES does not cover it, every client // requires, with TRUSTED_PROXIES unset, every client keys on the
// keys on the proxy, so a stranger trickling five POSTs a minute // proxy, so a stranger trickling five POSTs a minute keeps the one
// keeps the one bucket full and the operator's own correct password // bucket full and the operator's own correct password is answered 429
// is answered 429 forever. There is no second administrative path. // forever. There is no second administrative path.
// //
// So budget is spent only by a FAILED verification. A correct // So budget is spent only by a FAILED verification. A correct
// password is never throttled, whatever the counters say, which is // password is never throttled, whatever the counters say, which is
+3 -2
View File
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
return prefix.String() return prefix.String()
} }
// isTrustedProxy reports whether addr belongs to a network in // isTrustedProxy reports whether addr belongs to a network the
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges. // operator listed in TRUSTED_PROXIES. The list is empty by default,
// so by default nothing is trusted.
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool { func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
for _, prefix := range m.params.Config.TrustedProxies { for _, prefix := range m.params.Config.TrustedProxies {
if prefix.Contains(addr) { if prefix.Contains(addr) {
+9 -10
View File
@@ -384,8 +384,8 @@ const (
// trustedProxyCIDR is the proxy network the forwarded-path // trustedProxyCIDR is the proxy network the forwarded-path
// tests configure, and trustedPeer an address inside it. A // tests configure, and trustedPeer an address inside it. A
// production deployment is required to run behind a reverse // production deployment is required to run behind a reverse
// proxy that TRUSTED_PROXIES covers, either by the default or by // proxy with TRUSTED_PROXIES set, so this is the shape the
// a set value, so this is the shape the bucketing has to hold in. // bucketing has to hold in.
trustedProxyCIDR = "10.0.0.0/8" trustedProxyCIDR = "10.0.0.0/8"
trustedPeer = "10.0.0.1:44444" trustedPeer = "10.0.0.1:44444"
) )
@@ -426,8 +426,8 @@ func assertSharedBucket(
} }
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test // TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
// this gating exists for: from a peer that is not a trusted // this gating exists for: with no trusted proxies configured (the
// proxy, a client that rotates a forwarded header on every // default), a client that rotates a forwarded header on every
// request must stay in one bucket. If forwarded headers were // request must stay in one bucket. If forwarded headers were
// trusted unconditionally, each spoofed value would mint a fresh // trusted unconditionally, each spoofed value would mint a fresh
// bucket and the limit would stop no one. // bucket and the limit would stop no one.
@@ -1097,9 +1097,8 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
// that arrives from trustedPeer — a configured trusted proxy — and // that arrives from trustedPeer — a configured trusted proxy — and
// names forwarded as its client in X-Forwarded-For. That is the // names forwarded as its client in X-Forwarded-For. That is the
// production path: a deployment is required to run behind a reverse // production path: a deployment is required to run behind a reverse
// proxy that TRUSTED_PROXIES covers, either by the default or by a // proxy with TRUSTED_PROXIES set, so the forwarded address, not the
// set value, so the forwarded address, not the peer, is what the // peer, is what the limiters bucket on there.
// limiters bucket on there.
func forwardedKeyFor( func forwardedKeyFor(
t *testing.T, m *middleware.Middleware, forwarded string, t *testing.T, m *middleware.Middleware, forwarded string,
) string { ) string {
@@ -1179,9 +1178,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
// //
// Every existing test of this fallback uses an IPv4 proxy, where // Every existing test of this fallback uses an IPv4 proxy, where
// bucketKey is the identity function, so replacing the call with // bucketKey is the identity function, so replacing the call with
// peer.String() leaves the whole suite green. Only addresses inside // peer.String() leaves the whole suite green. Only operator-listed
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so // addresses reach this line and the fallback is fail-closed, so this
// this pins behaviour rather than fixing a defect. // pins behaviour rather than fixing a defect.
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer( func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
t *testing.T, t *testing.T,
) { ) {
+2 -34
View File
@@ -109,8 +109,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// Recoverer returns middleware that turns a handler panic into one // Recoverer returns middleware that turns a handler panic into one
// structured ERROR record and a 500, rather than a dropped // structured ERROR record and a 500, rather than a dropped
// connection. The 500 is page when page is not nil, and plain text // connection.
// when it is nil or when page panics before writing anything.
// //
// It replaces chi's middleware.Recoverer, which does neither on a // It replaces chi's middleware.Recoverer, which does neither on a
// current Go release. chi v1.5.5's pretty-printer scans the stack for // current Go release. chi v1.5.5's pretty-printer scans the stack for
@@ -139,9 +138,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// set before panicking, because a request that failed must not hand // set before panicking, because a request that failed must not hand
// the client a credential; every other header is left to http.Error. // the client a credential; every other header is left to http.Error.
// See https://git.eeqj.de/sneak/webhooker/issues/193. // See https://git.eeqj.de/sneak/webhooker/issues/193.
func (s *Middleware) Recoverer( func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
page http.Handler,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
w http.ResponseWriter, w http.ResponseWriter,
@@ -174,14 +171,6 @@ func (s *Middleware) Recoverer(
rw.Header().Del("Set-Cookie") rw.Header().Del("Set-Cookie")
if page != nil {
s.servePage(rw, r, page)
}
if rw.committed {
return
}
http.Error( http.Error(
rw, rw,
http.StatusText( http.StatusText(
@@ -196,27 +185,6 @@ func (s *Middleware) Recoverer(
} }
} }
// servePage answers with page. A panic in page itself is logged and
// recovered here, so the Recoverer can still send its plain 500.
func (s *Middleware) servePage(
w http.ResponseWriter,
r *http.Request,
page http.Handler,
) {
defer func() {
rvr := recover()
if rvr != nil {
s.log.Error("error page panic",
"panic", logfield.Truncate(
fmt.Sprint(rvr), maxPanicValueBytes,
),
)
}
}()
page.ServeHTTP(w, r)
}
// logPanic writes the record. Every field it can grow is truncated to // logPanic writes the record. Every field it can grow is truncated to
// a fixed budget, so MaxPanicLogLineBytes holds. // a fixed budget, so MaxPanicLogLineBytes holds.
// //
+2 -58
View File
@@ -76,7 +76,7 @@ func newRecovererProbe(
// Logging outside so the recovered 500 is the status it records. // Logging outside so the recovered 500 is the status it records.
router.Use(chimw.RequestID) router.Use(chimw.RequestID)
router.Use(m.Logging()) router.Use(m.Logging())
router.Use(m.Recoverer(nil)) router.Use(m.Recoverer())
router.Get("/probe", handler) router.Get("/probe", handler)
serverErrors := new(bytes.Buffer) serverErrors := new(bytes.Buffer)
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
m, _ := capturingMiddleware(t) m, _ := capturingMiddleware(t)
handler := m.Recoverer(nil)(http.HandlerFunc( handler := m.Recoverer()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("chunk")) _, _ = w.Write([]byte("chunk"))
@@ -672,59 +672,3 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "chunk", string(body)) assert.Equal(t, "chunk", string(body))
} }
// TestRecovererAnswersWithThePage covers a recoverer given a page:
// the panic is logged as before, and the 500 is that page.
func TestRecovererAnswersWithThePage(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
page := http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("the error page"))
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "the error page", w.Body.String())
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
assert.Contains(t, logs.String(), panicMarker)
}
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
// before writing anything: both panics are logged, and the client
// still gets the plain 500.
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
const pagePanic = "QQERRORPAGEPANICQQ"
page := http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
panic(pagePanic)
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
assert.Contains(t, logs.String(), panicMarker)
assert.Contains(t, logs.String(), pagePanic)
}
-220
View File
@@ -1,220 +0,0 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/server"
)
// The link back the error page offers: to the webhook list for a
// signed-in user, to sign-in for anyone else.
const (
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
`Back to webhooks</a>`
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
`Sign in</a>`
)
// assertErrorPage checks that w is the error page for status, in the
// normal layout, offering link.
func assertErrorPage(
t *testing.T,
w *httptest.ResponseRecorder,
status int,
link string,
) {
t.Helper()
body := w.Body.String()
assert.Equal(t, status, w.Code)
assert.Equal(
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
)
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
assert.Contains(t, body, `<nav class="app-bar"`)
assert.Contains(
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
)
assert.Contains(t, body, link)
}
func TestErrorPage_DeletedWebhook(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Delete(wh).Error)
w := env.get("/source/"+wh.ID, cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_DeletedTarget(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
require.NoError(t, env.db.DB().Delete(tgt).Error)
w := env.get(
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_UnknownPath(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
assertErrorPage(
t, env.get("/no-such-page", nil),
http.StatusNotFound, backToSignIn,
)
// Outside every route group there is no form token, so the
// page leaves out the logout form rather than offer one that
// would be refused.
w := env.get("/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
// Inside a route group the page has a token, and logout works.
wh := env.seedWebhook(t, userID)
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
}
func TestErrorPage_BadCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("username", "someone")
form.Set("password", "irrelevant")
form.Set("csrf_token", "not-a-token")
assertErrorPage(
t, env.post("/pages/login", form, nil),
http.StatusForbidden, backToSignIn,
)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
edit := url.Values{}
edit.Set("name", "renamed")
assertErrorPage(
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
http.StatusForbidden, backToWebhooks,
)
}
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
// admin page route group through the real router, with error
// tracking on: the client gets the 500 error page, and the tracker
// still gets the panic, once. The same panic outside the admin page
// route groups keeps the plain 500.
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
transport := &captureTransport{}
opts := server.SentryClientOptionsForTest(
"https://public@sentry.invalid/1", "webhooker-test",
)
opts.Transport = transport
client, err := sentry.NewClient(opts)
require.NoError(t, err)
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
sentry.SetHubOnContext(
context.Background(),
sentry.NewHub(client, sentry.NewScope()),
),
http.MethodGet, path, nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := serve(
server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.PageProbePattern,
)
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
w = serve(
server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.ProbePattern,
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
require.Len(t, transport.events, 2)
for _, event := range transport.events {
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
}
}
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
// the web UI only: a sender posting to an entrypoint that does not
// exist still gets the plain-text answer.
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
t.Parallel()
// newTestEnv leaves the receiver rate limit at zero, which
// refuses every request before it reaches the receiver.
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: 10,
})
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "404 page not found\n", w.Body.String())
}
-37
View File
@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
@@ -102,39 +101,3 @@ func NewRouterWithProbeForTest(
return s.router return s.router
} }
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
// probe: inside the /pages route group, the admin page group a
// request reaches without signing in.
const PageProbePattern = "/pages/probe"
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
// probe added to the /pages route group once SetupRoutes has built
// it, so the probe runs behind that group's own middleware exactly as
// the group's real routes do.
func NewRouterWithPageProbeForTest(
log *slog.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
sentryEnabled bool,
probe http.HandlerFunc,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes()
for _, route := range s.router.Routes() {
pages, ok := route.SubRoutes.(chi.Router)
if ok && route.Pattern == "/pages/*" {
pages.Get("/probe", probe)
}
}
return s.router
}
+17 -48
View File
@@ -16,8 +16,8 @@ import (
// submission while preventing abuse from oversized payloads. // submission while preventing abuse from oversized payloads.
// //
// Every route group below installs MaxBodySize(maxFormBodySize) as // Every route group below installs MaxBodySize(maxFormBodySize) as
// its first middleware after the recoverer, ahead of both CSRF and // its FIRST middleware, ahead of both CSRF and RequireAuth. Both
// RequireAuth. Both orderings are deliberate. // orderings are deliberate.
// //
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to // Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs // be installed before anything reads the body, or the parse runs
@@ -46,14 +46,6 @@ const requestTimeout = 60 * time.Second
// server's router. // server's router.
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
// An unknown path gets the error page. Registered before the
// global middleware, because chi wraps a not-found handler in the
// middleware already on its router, which would then run twice.
// The route groups below wrap it in their own middleware the same
// way; running theirs twice is harmless.
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
s.setupGlobalMiddleware() s.setupGlobalMiddleware()
s.setupRoutes() s.setupRoutes()
} }
@@ -77,33 +69,23 @@ func (s *Server) setupGlobalMiddleware() {
// Panic recovery, deliberately here rather than first. It has to // Panic recovery, deliberately here rather than first. It has to
// run inside every middleware that observes the response, so the // run inside every middleware that observes the response, so the
// 500 it writes is the status the access log records and the // 500 it writes is the status the access log records and the
// metrics count, and outside the sentryhttp handler, whose // metrics count, and outside the sentryhttp handler below, whose
// Repanic option needs something further out to catch what it // Repanic option needs something further out to catch what it
// re-raises. chi's own middleware.Recoverer held the first slot // re-raises. chi's own middleware.Recoverer held the first slot
// until it was measured: on a current Go release it crashes // until it was measured: on a current Go release it crashes
// inside its stack pretty-printer instead of recovering, so the // inside its stack pretty-printer instead of recovering, so the
// connection dropped and the original panic was never reported. // connection dropped and the original panic was never reported.
// See https://git.eeqj.de/sneak/webhooker/issues/187. // See https://git.eeqj.de/sneak/webhooker/issues/187.
s.recoverPanics(s.router, nil) s.router.Use(s.mw.Recoverer())
}
// recoverPanics installs on r the recoverer, answering a panic with
// page (a plain 500 when page is nil), and inside it the Sentry error
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
// bubble up to the recoverer.
//
// Each admin page route group installs its own, with the error page,
// as its first middleware. A panic there is logged, reported and
// answered inside the group and never reaches the global recoverer,
// which keeps the plain 500 for every other route.
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
r.Use(s.mw.Recoverer(page))
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
// true so panics still bubble up to the Recoverer middleware
// registered immediately above.
if s.sentryEnabled.Load() { if s.sentryEnabled.Load() {
sentryHandler := sentryhttp.New(sentryhttp.Options{ sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true, Repanic: true,
}) })
r.Use(sentryHandler.Handle) s.router.Use(sentryHandler.Handle)
} }
} }
@@ -165,22 +147,18 @@ func (s *Server) setupRoutes() {
func (s *Server) setupPageRoutes() { func (s *Server) setupPageRoutes() {
s.router.Route("/pages", func(r chi.Router) { s.router.Route("/pages", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
// The login POST carries no pre-emptive rate limiter. Behind // The login POST carries no pre-emptive rate limiter. Behind
// the reverse proxy production requires, when TRUSTED_PROXIES // the reverse proxy production requires, with TRUSTED_PROXIES
// does not cover it, every client shares one bucket, so a // unset, every client shares one bucket, so a limiter spent
// limiter spent on arrival lets any stranger deny the operator // on arrival lets any stranger deny the operator the only
// the only administrative path. The handler verifies // administrative path. The handler verifies credentials first
// credentials first and charges only failures; see // and charges only failures; see Handlers.authenticateUser.
// Handlers.authenticateUser.
r.Get("/login", s.h.HandleLoginPage()) r.Get("/login", s.h.HandleLoginPage())
r.Post("/login", s.h.HandleLoginSubmit()) r.Post("/login", s.h.HandleLoginSubmit())
@@ -190,13 +168,10 @@ func (s *Server) setupPageRoutes() {
func (s *Server) setupUserRoutes() { func (s *Server) setupUserRoutes() {
s.router.Route("/user/{username}", func(r chi.Router) { s.router.Route("/user/{username}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile()) r.Get("/", s.h.HandleProfile())
@@ -208,13 +183,10 @@ func (s *Server) setupUserRoutes() {
func (s *Server) setupSourceRoutes() { func (s *Server) setupSourceRoutes() {
s.router.Route("/sources", func(r chi.Router) { s.router.Route("/sources", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList()) r.Get("/", s.h.HandleSourceList())
@@ -223,13 +195,10 @@ func (s *Server) setupSourceRoutes() {
}) })
s.router.Route("/source/{sourceID}", func(r chi.Router) { s.router.Route("/source/{sourceID}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden))) r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache()) r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail()) r.Get("/", s.h.HandleSourceDetail())
+3 -3
View File
@@ -13,9 +13,9 @@ import (
// TestBaseTemplateScriptsAreServed walks every /s/ script the base // TestBaseTemplateScriptsAreServed walks every /s/ script the base
// template loads on each page and fetches it through the real router. // template loads on each page and fetches it through the real router.
// Alpine.js is extracted from its tarball in 3p/ at build time, so the // Alpine.js is fetched at build time rather than committed, so nothing
// file is not in the tree: this is the check that the page still gets // in the repo guarantees it is present: this is the check that the page
// the JavaScript it asks for. // still gets the JavaScript it asks for.
func TestBaseTemplateScriptsAreServed(t *testing.T) { func TestBaseTemplateScriptsAreServed(t *testing.T) {
t.Parallel() t.Parallel()
-16
View File
@@ -1,16 +0,0 @@
#!/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.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
>static/js/alpine.min.js
}
main "$@"
+8 -1
View File
@@ -4,7 +4,9 @@
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git, # or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is deliberately not installed: linting runs # make, or go). golangci-lint is deliberately not installed: linting runs
# only in docker, via script/lint and Dockerfile.lint. # 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.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -67,6 +69,11 @@ main() {
go mod download 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" echo "bootstrap complete"
} }
+1 -2
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. # extension to scripts-to-rule-them-all. Must not modify any files.
# Writes only the ignored static/js/alpine.min.js, through script/test.
# Generic: usually needs no adaptation. # Generic: usually needs no adaptation.
set -eu set -eu
+104
View File
@@ -0,0 +1,104 @@
#!/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,7 +28,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/assets"
go test -v -race -timeout 90s ./... go test -v -race -timeout 90s ./...
} }
+1
View File
@@ -0,0 +1 @@
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
+92
View File
@@ -0,0 +1,92 @@
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
}
-15
View File
@@ -1,15 +0,0 @@
{{template "base" .}}
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12">
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
<p class="text-gray-600 mb-6">{{.Message}}</p>
{{if .User}}
<a href="/sources" class="btn-secondary">Back to webhooks</a>
{{else}}
<a href="/pages/login" class="btn-primary">Sign in</a>
{{end}}
</div>
{{end}}
-6
View File
@@ -24,14 +24,10 @@
</svg> </svg>
{{.User.Username}} {{.User.Username}}
</a> </a>
{{/* An error page can be served before a form token is issued,
and a logout without one is refused. */}}
{{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-text">Logout</button>
</form> </form>
{{end}}
{{else}} {{else}}
<a href="/pages/login" class="btn-primary">Login</a> <a href="/pages/login" class="btn-primary">Login</a>
{{end}} {{end}}
@@ -44,12 +40,10 @@
{{if .User}} {{if .User}}
<a href="/sources" class="btn-text w-full text-left">Webhooks</a> <a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> <a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button> <button type="submit" class="btn-text w-full text-left">Logout</button>
</form> </form>
{{end}}
{{else}} {{else}}
<a href="/pages/login" class="btn-primary w-full">Login</a> <a href="/pages/login" class="btn-primary w-full">Login</a>
{{end}} {{end}}
+4
View File
@@ -41,6 +41,10 @@
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt> <dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
<dd class="text-sm text-gray-900">{{.User.Username}}</dd> <dd class="text-sm text-gray-900">{{.User.Username}}</dd>
</div> </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> </dl>
</div> </div>
</div> </div>
+5 -17
View File
@@ -181,30 +181,18 @@
<!-- Recent Events --> <!-- Recent Events -->
<div class="card mt-6"> <div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2> <h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a> <a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
<div class="p-4"> <div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-3"> <div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span> <span class="text-sm text-gray-500">{{.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> </div>
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
</div> </div>
</div> </div>
{{else}} {{else}}