11 Commits
Author SHA1 Message Date
clawbot a8fc0c5d32 Merge main into next after 416 (closes #497)
check / check (push) Successful in 4m34s
#416 put a `main`-only version of fixes `next` already had onto `main`, so `next` no longer merged into `main`: `script/test` conflicted. This merge makes `main` an ancestor of `next` and keeps `next`'s files throughout, which already hold everything 416 changed, so `next`'s tree is unchanged.

Model: opus-5-5
2026-10-03 14:29:50 +02:00
clawbot 7963188c1e Merge main into next after 416 (closes #497)
check / check (push) Successful in 6m8s
Makes main an ancestor of next, so the milestone PR merges again.
script/test conflicted and keeps next's version, which already runs
without -v, caps memory with -p 4 -parallel 8, adds coverage and reruns
failed tests with -v. password.go, password_test.go, export_test.go and
resetpw_test.go merged on their own: main's lines there are the same
as next's. The merged tree equals next's.

Model: opus-5-5
2026-10-03 12:17:30 +00:00
clawbotandsneak 16ed356b68 main side of 414: cheaper test hashing, and failing tests visible in the build log (closes #414) (#416)
check / check (push) Successful in 3m21s
The `main` side of #414: the two changes that make `next` green, and nothing else from `next`. Each is its own commit, so it can be compared with its `next` counterpart.

- #404, as merged to `next`: a test binary hashes passwords at a 1 MB Argon2id cost instead of 64 MB, `TestHashPassword_ShippedParameters` keeps the shipped cost covered, and `script/test` runs at most four packages and eight parallel tests at once. Every test that starts a database hashed the admin password at 64 MB, which on a busy host made `internal/handlers` overrun its application start and its 90-second timeout. That is what turned `main` red.
- #415: `script/test` runs without `-v`, so the build log, which the Docker build cuts off at 2 MiB, carries one result line per package and, for a package that fails, everything its tests wrote, application log lines included, instead of only passing packages.

What the diff does not show: `script/test` differs from `next` by one line. `main` has no `script/assets` yet, so it is not called. Several packages failing at once can still reach the 2 MiB limit.

- Judgement call: both commits keep their subjects from `next`, including their `closes` references.
- Deviation and not fixed here: the same two as on #415 (no `-v` rerun on failure, #315; remaining sensitivity to extreme CPU load, #225).

Model: opus-5-5
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #416
Co-authored-by: clawbot <35+clawbot@noreply.example.org>
2026-10-03 14:08:51 +02:00
clawbot fe5e0d4173 Install ESLint and prettier with yarn 4 through corepack (closes #493)
check / check (push) Successful in 3m48s
The `js-deps` stage installed ESLint and prettier with yarn 1, which is no longer developed and made node print a `url.parse()` deprecation warning on every install. `package.json` now pins yarn 4 by version and hash in its `packageManager` field; the stage enables it through the node image's own corepack and installs with `yarn install --immutable`. `yarn.lock` is regenerated in yarn 4's format with every package at its previously locked version, and `.yarnrc.yml` keeps the install in `node_modules/`, where the lint and Markdown stages run the tools from. The install prints no warning and stays cached until the manifests change.

Model: opus-5-5
2026-10-03 07:03:31 +02:00
clawbot 9ccaa8ce01 Break long values on the webhook page and event log at phone width (closes #391)
check / check (push) Successful in 3m17s
At phone width, the event log cut off long event IDs and content types along with the statuses and times after them, and the webhook page ran long names past their cards and scrolled sideways. Elements that hold only a long value (a webhook, target or entrypoint name or description, an event ID, a content type) now break inside it, and the event log's title row wraps; rows themselves still wrap rather than squeeze. Wide screens are unchanged. A 390-pixel browser check of both pages, an event's attempts open, fails when anything runs past the page's or a card's edge or the page scrolls sideways.

Model: opus-5-5
2026-10-03 06:56:43 +02:00
clawbot 935e18c6f1 Format the Markdown with prettier in make fmt and make fmt-check (closes #215)
check / check (push) Successful in 3m28s
`make fmt` formatted only Go, so the org's Markdown settings were unenforced and Markdown was wrapped by hand. prettier, pinned in `package.json` and `yarn.lock` beside ESLint, now formats the Markdown with `.prettierrc` (4-space tabs, `proseWrap: always`). It runs only in Docker: `make fmt` writes the formatted files back without a bind mount, and `make fmt-check`, `make check` and the image build fail on unformatted Markdown. The image build's lint stage now runs the Go format check directly and no longer installs `make`. `README.md` and `TODO.md` are reformatted with no word changed: the README reflows from 72 to 80 columns.

Model: opus-5-5
2026-10-03 06:32:27 +02:00
clawbot af91d8a723 Split source_management.go along its CRUD seams (closes #274)
check / check (push) Successful in 3m22s
`internal/handlers/source_management.go` had grown to about 2,370 lines holding the webhook, event log, entrypoint and target handlers, so unrelated units had to wait on each other to touch it. It is split, as pure code movement, into files named for what they hold: `webhook_list.go`, `webhook_create.go`, `webhook_detail.go`, `webhook_edit.go`, `webhook_delete.go`, `event_log.go`, `entrypoint.go`, `target_create.go`, `target_delete.go`, `target_toggle.go` and `shared.go`. No function body, signature, comment or behaviour changed. The README's file tree and log-line caveat, and one middleware comment, name the new files.

Model: opus-5-5
2026-10-03 06:19:46 +02:00
clawbot 7e779f7fce Event log: show only the events with a failed or a pending delivery (closes #390)
check / check (push) Successful in 3m18s
The event log had no way to list only the events whose delivery failed, and once it showed only the 50 newest, an older failure could not be found at all. It now has All, Failed (N) and Pending (N) links, carried in a `show` query parameter, so they work without the page's script library. Each filtered list keeps the 50-row limit and newest-first order, and lists an event once. It finds matching deliveries through `idx_deliveries_status` and looks their events up by ID, so its cost follows the matches, not the webhook's size. Replay returns to the list it was pressed in. The heading line says what a filter counts.

Model: opus-5-5
2026-10-03 05:40:34 +02:00
clawbot 9079a3219d Show an event's entrypoint and request headers in the event log and on its page (closes #389)
check / check (push) Successful in 3m26s
The receiver stored each event's request headers and entrypoint, but no page showed them, so with several entrypoints the operator could not tell which sender sent an event. An expanded event in the event log, and the event's own page, now show the entrypoint by its description ("Entrypoint" without one, "deleted entrypoint" once deleted), never its URL, and the headers as one escaped block, sorted, whitespace kept. A resubmitted copy says the request it copies arrived there. The event log reads at most 32 KiB of headers per event, the body's limit, and links to the event's page beyond it. Both pages share one template, `event_request.html`.

Model: opus-5-5
2026-10-03 05:22:14 +02:00
clawbot b9ec91c0f7 Use one name for each thing the UI shows (closes #399)
check / check (push) Successful in 3m21s
The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged.

Model: opus-5-5
2026-10-03 05:07:41 +02:00
sneak f703b72ce0 Next (#364)
check / check (push) Successful in 3m59s
Reviewed-on: #364
2026-09-29 13:05:57 +02:00
45 changed files with 6593 additions and 5519 deletions
+2 -2
View File
@@ -15,8 +15,8 @@ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context. # needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
# The js-deps stage installs ESLint; a host copy would overwrite it at the # The js-deps stage installs ESLint and prettier; a host copy would overwrite
# js-lint stage's `COPY . .`. # them at the `COPY . .` of the stages built on it.
node_modules/ node_modules/
.env .env
.env.* .env.*
+1 -1
View File
@@ -33,5 +33,5 @@ jobs:
# and built cannot report success from cache. # and built cannot report success from cache.
run: git rev-parse HEAD > .ci-fingerprint run: git rev-parse HEAD > .ci-fingerprint
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, ESLint, make test, make build) - name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build)
run: script/cibuild run: script/cibuild
+1 -1
View File
@@ -15,7 +15,7 @@ bin/
# Go vendor directory # Go vendor directory
vendor/ vendor/
# ESLint and its dependencies, installed from yarn.lock # ESLint, prettier and their dependencies, installed from yarn.lock
node_modules/ node_modules/
# IDE specific files # IDE specific files
+4
View File
@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+3
View File
@@ -0,0 +1,3 @@
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
# ESLint and prettier from node_modules/.bin.
nodeLinker: node-modules
+42 -16
View File
@@ -4,8 +4,6 @@
# compile on Alpine musl (off64_t is a glibc type). # compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # Copy go mod files first for better layer caching
@@ -19,12 +17,14 @@ RUN go mod download
# .dockerignore. # .dockerignore.
COPY . . COPY . .
# Run formatting check and linter. golangci-lint is invoked directly rather # Run the Go formatting check and the linter. gofmt and golangci-lint are
# than through `make lint`: this stage is already the pinned linter image, and # invoked directly rather than through `make fmt-check` and `make lint`: this
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here # stage is already the pinned linter image, and both scripts build docker
# would need a docker daemon inside the build. Keep these steps in step with # stages, so calling them here would need a docker daemon inside the build.
# Dockerfile.lint, including --network=none (see its header for why). # The Markdown half of `make fmt-check` is the markdown-check stage below.
RUN make fmt-check # Keep the golangci-lint steps in step with Dockerfile.lint, including
# --network=none (see its header for why).
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
RUN script/assets RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
@@ -66,30 +66,56 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
} }
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock # JavaScript lint stages: ESLint, at the version package.json and yarn.lock
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it and # pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
# stays cached until those two files change. script/lint forces only js-lint # prettier for the Markdown stages below, and stays cached until package.json,
# to re-run, and the build stage below runs it too. COPY . . brings in the CI # yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run,
# cache barrier described in the lint stage above. # and the build stage below runs it too. COPY . . brings in the CI cache
# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18 # barrier described in the lint stage above.
#
# The image's own corepack runs the yarn that package.json's packageManager
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
# hash there. The image also ships yarn 1, which `corepack enable yarn`
# replaces.
# node:24.21.0-alpine (LTS), 2026-09-18
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
WORKDIR /src WORKDIR /src
COPY package.json yarn.lock ./ COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --frozen-lockfile --ignore-scripts RUN corepack enable yarn && yarn install --immutable --mode=skip-build
FROM js-deps AS js-lint FROM js-deps AS js-lint
COPY . . COPY . .
RUN --network=none node_modules/.bin/eslint static/js RUN --network=none node_modules/.bin/eslint static/js
# Markdown stages: prettier, at the version package.json and yarn.lock pin,
# formats every Markdown file in the tree with the settings in .prettierrc.
# `make fmt` (script/fmt) writes the formatted files out from markdown-output.
# markdown-check fails on any file prettier would change; `make fmt-check`
# runs it, and so does the build stage below.
FROM js-deps AS markdown
COPY . .
RUN --network=none node_modules/.bin/prettier --write '**/*.md' \
&& mkdir /out \
&& find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \;
FROM scratch AS markdown-output
COPY --from=markdown /out /
FROM js-deps AS markdown-check
COPY . .
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
# Using Debian-based image because gorm.io/driver/sqlite pulls in # Using Debian-based image because gorm.io/driver/sqlite pulls in
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Depend on the lint, stylesheet check and JavaScript lint stages passing # Depend on the lint, stylesheet check, JavaScript lint and Markdown check
# stages passing
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /dev/null COPY --from=css-check /out/tailwind.css /dev/null
COPY --from=js-lint /src/yarn.lock /dev/null COPY --from=js-lint /src/yarn.lock /dev/null
COPY --from=markdown-check /src/yarn.lock /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test # jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. git is what script/version derives the version with. # suite executes. git is what script/version derives the version with.
+2080 -2299
View File
File diff suppressed because it is too large Load Diff
+313 -349
View File
@@ -2,403 +2,367 @@
One issue per unit of work, one branch and one PR per issue: One issue per unit of work, one branch and one PR per issue:
* ensure a tracked issue exists with a definition of done - ensure a tracked issue exists with a definition of done
* branch from `next` (never from `main`) - branch from `next` (never from `main`)
* do the work; open a PR based on `next` (never on `main`) - do the work; open a PR based on `next` (never on `main`)
* pass an independent review, then the manager squash-merges into `next` - pass an independent review, then the manager squash-merges into `next`
* push; nothing stays local-only - push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and `next` is the branch for the next milestone and must stay green and mergeable to
mergeable to `main` without notice. One `next` -> `main` PR accumulates `main` without notice. One `next` -> `main` PR accumulates the milestone;
the milestone; releases are cut from `main` separately. releases are cut from `main` separately.
Issue branches do NOT touch this file — the manager maintains it on Issue branches do NOT touch this file — the manager maintains it on `next`.
`next`. Every branch editing `TODO.md` conflicts with every other Every branch editing `TODO.md` conflicts with every other (#112).
(#112).
# Status # Status
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
authoritative list, and the only place to read a count or a state of authoritative list, and the only place to read a count or a state of play from.
play from. This file records where the project is, not what is in This file records where the project is, not what is in flight: a sentence whose
flight: a sentence whose truth depends on a branch being unmerged is truth depends on a branch being unmerged is wrong the moment it merges, and this
wrong the moment it merges, and this file has been wrong that way file has been wrong that way before.
before.
The durability defect that held the tag has landed The durability defect that held the tag has landed
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). (https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
Every SQLite handle opens with WAL journaling and a busy timeout, a handle opens with WAL journaling and a busy timeout, a bookkeeping write that
bookkeeping write that fails leaves its delivery in a recoverable fails leaves its delivery in a recoverable state rather than a lying one, and
state rather than a lying one, and recovery skips a delivery that recovery skips a delivery that already has a successful result row. Final
already has a successful result row. Final pre-tag verification pre-tag verification exercised it and confirmed it holds. Whatever the milestone
exercised it and confirmed it holds. Whatever the milestone still still shows open is what remains before `v1.0.0`.
shows open is what remains before `v1.0.0`.
Delivery is at-least-once by design, not by accident: a send whose Delivery is at-least-once by design, not by accident: a send whose result row
result row does not land is attempted again, so a receiver can see a does not land is attempted again, so a receiver can see a duplicate. That is
duplicate. That is deliberate — the alternative is a silent lost deliberate — the alternative is a silent lost delivery — and the README says so
delivery — and the README says so under Rationale. It is not a defect under Rationale. It is not a defect to re-file.
to re-file.
# Next Step # Next Step
Clear the rest of the open 1.0.0 milestone Clear the rest of the open 1.0.0 milestone
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. (https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
Merging `next` into `main` is a separate act from tagging and waits on `next` into `main` is a separate act from tagging and waits on neither of those:
neither of those: `next` is kept mergeable at all times, which is the `next` is kept mergeable at all times, which is the point of the branch.
point of the branch.
# Completed Steps # Completed Steps
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via - 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
`BIND_ADDRESS` defaulting to `127.0.0.1`, and document the defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
reverse-proxy deployment. A hostname, an empty value or a value hostname, an empty value or a value carrying a port is a startup error, and
carrying a port is a startup error, and the `Dockerfile` sets the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
`0.0.0.0` because a loopback bind inside a container is unreachable unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
(https://git.eeqj.de/sneak/webhooker/issues/268). The same commit removed the shutdown race: `httpServer` is built in the constructor rather
removed the shutdown race: `httpServer` is built in the constructor than assigned from the serving goroutine, which orders the write before every
rather than assigned from the serving goroutine, which orders the fx hook and rules out the nil dereference a SIGTERM arriving first would have
write before every fx hook and rules out the nil dereference a caused, and `sentryEnabled` is an `atomic.Bool`
SIGTERM arriving first would have caused, and `sentryEnabled` is an (https://git.eeqj.de/sneak/webhooker/issues/226)
`atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226) - 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
- 2026-08-24 Remove inbound request signature verification. The is the authentication secret, so the per-entrypoint shared secret, the
entrypoint UUID is the authentication secret, so the per-entrypoint `internal/signature` package, the receiver check, the model fields and the
shared secret, the `internal/signature` package, the receiver check, forms are all gone. This reverses the feature that landed earlier in the same
the model fields and the forms are all gone. This reverses the milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
feature that landed earlier in the same milestone
(https://git.eeqj.de/sneak/webhooker/issues/67,
https://git.eeqj.de/sneak/webhooker/issues/279) https://git.eeqj.de/sneak/webhooker/issues/279)
- 2026-08-24 Stamp the build version into the binary and render it in - 2026-08-24 Stamp the build version into the binary and render it in the UI
the UI footer. `script/version` is the single source — `$VERSION`, footer. `script/version` is the single source — `$VERSION`, else
else `git describe --tags --always --dirty`, else `unknown` — so a `git describe --tags --always --dirty`, else `unknown` — so a `make build`
`make build` binary and a `make docker` image from one checkout binary and a `make docker` image from one checkout report the same thing, and
report the same thing, and nothing in it varies between two builds nothing in it varies between two builds of the same commit, which the release
of the same commit, which the release gate's byte-identical gate's byte-identical assertion would catch
assertion would catch
(https://git.eeqj.de/sneak/webhooker/issues/253) (https://git.eeqj.de/sneak/webhooker/issues/253)
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the - 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
real TLS proxy with the environment left at its `dev` default, the with the environment left at its `dev` default, the session cookie silently
session cookie silently lost `Secure` while the CSRF cookie on the lost `Secure` while the CSRF cookie on the same response kept it.
same response kept it. `X-Forwarded-Proto` is now matched `X-Forwarded-Proto` is now matched case-insensitively on its first
case-insensitively on its first comma-separated element, so `HTTPS` comma-separated element, so `HTTPS` and `https, http` no longer fall to the
and `https, http` no longer fall to the relaxed CSRF path relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
(https://git.eeqj.de/sneak/webhooker/issues/269) - 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
- 2026-08-24 Roll back a failed webhook deletion instead of committing failing delete committed whatever had already succeeded, hard-deleted the
it. A failing delete committed whatever had already succeeded, per-webhook event database anyway, and redirected as though it had worked —
hard-deleted the per-webhook event database anyway, and redirected as orphaned config plus permanently destroyed history, reported as success. All
though it had worked — orphaned config plus permanently destroyed three delete positions now roll back with the event database intact
history, reported as success. All three delete positions now roll
back with the event database intact
(https://git.eeqj.de/sneak/webhooker/issues/262) (https://git.eeqj.de/sneak/webhooker/issues/262)
- 2026-08-24 Name a deleted target on its historical deliveries, marked - 2026-08-24 Name a deleted target on its historical deliveries, marked
`(deleted)`, rather than leaving the event log unable to say where a `(deleted)`, rather than leaving the event log unable to say where a delivery
delivery went. A deleted target's credentials stay masked exactly as went. A deleted target's credentials stay masked exactly as a live one's, and
a live one's, and it cannot become deliverable again through the it cannot become deliverable again through the receiver, resubmit, replay, the
receiver, resubmit, replay, the edit form or the toggle edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
(https://git.eeqj.de/sneak/webhooker/issues/211) - 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
so the unauthenticated receiver is no longer a memory-exhaustion carries the chi route pattern, and `method` folds anything chi cannot route
vector: `handler` carries the chi route pattern, and `method` folds onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
anything chi cannot route onto a single `(unmatched)` sentinel. Both 300 random method tokens took the series count from 106 to 7,631, and path
were reproduced before the fix — 300 random method tokens took the flooding reached 62,532 — and a label audit across a live scrape found no
series count from 106 to 7,631, and path flooding reached 62,532 — third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
and a label audit across a live scrape found no third unbounded
dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
https://git.eeqj.de/sneak/webhooker/issues/261) https://git.eeqj.de/sneak/webhooker/issues/261)
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` - 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
and `-5` silently became 0 — fire-and-forget — including on the edit silently became 0 — fire-and-forget — including on the edit path, where it
path, where it destroyed a working value, and `999999999` stored destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
verbatim. The ceiling of 20 is the `max` both templates already is the `max` both templates already declared
declared (https://git.eeqj.de/sneak/webhooker/issues/221) (https://git.eeqj.de/sneak/webhooker/issues/221)
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a - 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
backend under development can be tested against real captured under development can be tested against real captured traffic. Per-delivery
traffic. Per-delivery replay cannot serve that: it re-sends one replay cannot serve that: it re-sends one finished delivery to its own
finished delivery to its own original target, and a target created original target, and a target created for a dev backend has no prior delivery
for a dev backend has no prior delivery to replay. Resubmit to replay. Resubmit re-injects the stored event at the top of the receiver
re-injects the stored event at the top of the receiver path and fans path and fans it out to whatever targets are active now
it out to whatever targets are active now
(https://git.eeqj.de/sneak/webhooker/issues/250) (https://git.eeqj.de/sneak/webhooker/issues/250)
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two - 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
instances on one directory cannot both deliver on one directory cannot both deliver
(https://git.eeqj.de/sneak/webhooker/issues/201) (https://git.eeqj.de/sneak/webhooker/issues/201)
- 2026-08-20 Shut down the app when the HTTP listener fails. The - 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
`OnStart` hook returned as soon as the serving goroutine was returned as soon as the serving goroutine was spawned, so a failed listen left
spawned, so a failed listen left fx reporting RUNNING and a live fx reporting RUNNING and a live process with nothing bound — invisible to
process with nothing bound — invisible to systemd and Docker restart systemd and Docker restart policies
policies (https://git.eeqj.de/sneak/webhooker/issues/200) (https://git.eeqj.de/sneak/webhooker/issues/200)
- 2026-08-20 Stop target credentials leaking into the per-webhook event - 2026-08-20 Stop target credentials leaking into the per-webhook event
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
with placeholders rather than bound values placeholders rather than bound values
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
half-set metrics auth credentials metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
(https://git.eeqj.de/sneak/webhooker/issues/205) - 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps trace recorder in for the logging adapter, and that recorder does not
GORM's own trace recorder in for the logging adapter, and that implement `gorm.ParamsFilter`, so those statements logged their bound values
recorder does not implement `gorm.ParamsFilter`, so those statements interpolated and bypassed the suppression above. The two units gated green
logged their bound values interpolated and bypassed the suppression against a `next` that lacked the other, and `next` went red when both landed
above. The two units gated green against a `next` that lacked the
other, and `next` went red when both landed
(https://git.eeqj.de/sneak/webhooker/issues/234) (https://git.eeqj.de/sneak/webhooker/issues/234)
- 2026-08-20 Render per-attempt delivery detail in the event log - 2026-08-20 Render per-attempt delivery detail in the event log
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a (https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
terminally failed delivery terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
(https://git.eeqj.de/sneak/webhooker/issues/203)
- 2026-08-20 Expose delivery metrics on `/metrics` - 2026-08-20 Expose delivery metrics on `/metrics`
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the (https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
backup, restore and upgrade procedures restore and upgrade procedures
(https://git.eeqj.de/sneak/webhooker/issues/210) (https://git.eeqj.de/sneak/webhooker/issues/210)
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap - 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
banner. The admin bootstrap password was printed once among roughly admin bootstrap password was printed once among roughly 45 fx lines, and under
45 fx lines, and under `docker run -d` went to container logs subject `docker run -d` went to container logs subject to rotation; there was no reset
to rotation; there was no reset path at all, so recovery meant path at all, so recovery meant hand-deleting the users row, documented
hand-deleting the users row, documented nowhere. The password is read nowhere. The password is read from stdin or generated, never from argv where
from stdin or generated, never from argv where `/proc` would publish `/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
it (https://git.eeqj.de/sneak/webhooker/issues/208) - 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch SSRF guard, so a self-hosted proxy can forward into the operator's own
for the SSRF guard, so a self-hosted proxy can forward into the network. The guard's always-blocked set cannot be reopened by configuration
operator's own network. The guard's always-blocked set cannot be
reopened by configuration
(https://git.eeqj.de/sneak/webhooker/issues/204) (https://git.eeqj.de/sneak/webhooker/issues/204)
- 2026-08-20 Harden operator-set target headers, which were carried - 2026-08-20 Harden operator-set target headers, which were carried unsafely
unsafely across a redirect across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
(https://git.eeqj.de/sneak/webhooker/issues/233)
- 2026-08-20 Add a target edit form with headers and timeout fields - 2026-08-20 Add a target edit form with headers and timeout fields
(https://git.eeqj.de/sneak/webhooker/issues/127) (https://git.eeqj.de/sneak/webhooker/issues/127)
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, - 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
matching the org-wide backstop. `go test` applies `-timeout` per the org-wide backstop. `go test` applies `-timeout` per package, and
package, and `internal/handlers` had grown past the old budget: a `internal/handlers` had grown past the old budget: a cache-defeated build
cache-defeated build failed outright at `GOMAXPROCS=4`, and every run failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
under deliberate host load breached 30s. The measurement table lives breached 30s. The measurement table lives in the script (#194)
in the script (#194) - 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy and still mandated a 20s test target with a 30s timeout, which the org
was stale and still mandated a 20s test target with a 30s timeout, replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
which the org replaced with a 60s cap and a 90s backstop. A synced reading it as one nearly produced a PR against `prompts` proposing a change
copy is not a source; reading it as one nearly produced a PR against already merged there (#196)
`prompts` proposing a change already merged there (#196) - 2026-08-18 Report handler panics through the logger and answer 500. chi
- 2026-08-18 Report handler panics through the logger and answer 500. v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
longer emits, then indexes `pkg[-1:]`, so it panicked inside its own writing a byte: the recovery never ran, the client got a dropped connection
stack printer before writing a byte: the recovery never ran, the instead of a 500, and the original panic was lost. A local middleware replaces
client got a dropped connection instead of a 500, and the original it, bounded by `MaxPanicLogLineBytes` (#187)
panic was lost. A local middleware replaces it, bounded by - 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
`MaxPanicLogLineBytes` (#187) left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every false, so **every record-not-found printed the fully interpolated SQL to
`gorm.Open` left `logger.Default` in place at `Warn` with stdout** — including the client-chosen path on `/webhook/{uuid}` and the
`IgnoreRecordNotFoundError` false, so **every record-not-found submitted username on the login form, at no level the operator set and outside
printed the fully interpolated SQL to stdout** — including the `internal/logger` entirely. Three call sites, not the two the issue named
client-chosen path on `/webhook/{uuid}` and the submitted username on (#178)
the login form, at no level the operator set and outside - 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
`internal/logger` entirely. Three call sites, not the two the issue reachable unauthenticated, found by reading every `slog` call in the tree
named (#178) rather than only the one reported; the budget moved to a shared
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight `internal/logfield` so no second truncation exists. `DEBUG` being off by
sites reachable unauthenticated, found by reading every `slog` call in default is not a bound and is not treated as one (#176)
the tree rather than only the one reported; the budget moved to a - 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
shared `internal/logfield` so no second truncation exists. `DEBUG` non-fatal `assert` on an acquire result was dereferenced on the next line, so
being off by default is not a bound and is not treated as one (#176) one timing miss killed the whole `internal/middleware` binary and reddened CI
- 2026-08-18 Stop a slow host turning a login-guard test into a for unrelated PRs. The fix also removed a real production race — `acquire`
segfault. A non-fatal `assert` on an acquire result was dereferenced could shed a request with a slot standing free, because Go picks uniformly
on the next line, so one timing miss killed the whole among ready `select` cases (#186)
`internal/middleware` binary and reddened CI for unrelated PRs. The - 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
fix also removed a real production race — `acquire` could shed a The receiver's path carries the entrypoint capability token, so every Sentry
request with a slot standing free, because Go picks uniformly among event from `/webhook/{uuid}` shipped a live credential to a third party.
ready `select` cases (#186) Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
- 2026-08-18 Send the chi route pattern to Sentry rather than the reduced to an allowlist (#179)
concrete path. The receiver's path carries the entrypoint capability - 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
token, so every Sentry event from `/webhook/{uuid}` shipped a live query string, so a login could be driven by URL parameters — putting the
credential to a third party. Request `Data`, `QueryString`, `Cookies` password somewhere that lands in access logs, proxy logs and browser history
and `Env` are dropped and headers reduced to an allowlist (#179) (#160)
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` - 2026-08-18 Verify login credentials before spending rate-limit budget, so a
merges the query string, so a login could be driven by URL parameters flood of wrong passwords cannot lock out the account it is guessing at. The
— putting the password somewhere that lands in access logs, proxy manager took this decision rather than stall the queue; it is flagged on the
logs and browser history (#160) issue for reversal (#150)
- 2026-08-18 Verify login credentials before spending rate-limit - 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
budget, so a flood of wrong passwords cannot lock out the account it wrong in both directions from version skew and shared caches. `script/lint`
is guessing at. The manager took this decision rather than stall the asserts the summary line, because `--no-cache-filter` silently ignores a stage
queue; it is flagged on the issue for reversal (#150) name it does not match — the flag that makes the gate meaningful fails open
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint (#109)
was wrong in both directions from version skew and shared caches. - 2026-08-18 Serve an event's full stored body over HTTP. The list query
`script/lint` asserts the summary line, because `--no-cache-filter` truncates for rendering, and that truncated value was the only way to read a
silently ignores a stage name it does not match — the flag that makes body, so the full payload was unreachable (#157)
the gate meaningful fails open (#109)
- 2026-08-18 Serve an event's full stored body over HTTP. The list
query truncates for rendering, and that truncated value was the only
way to read a body, so the full payload was unreachable (#157)
- 2026-08-18 Bound the access log line against client-chosen text. - 2026-08-18 Bound the access log line against client-chosen text.
`internal/logfield` budgets by *encoded* bytes, not runes, so a `internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
handler's JSON escaping cannot multiply a field past its allowance escaping cannot multiply a field past its allowance (#146)
(#146) - 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
- 2026-08-18 Mark superseded CI commits `failure` rather than skipped run rolls up green, so a commit that was never tested reported success
`skipped`. A skipped run rolls up green, so a commit that was never (#152)
tested reported success (#152) - 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so hooks are bounded by a deadline the orchestrator will actually honour rather
shutdown hooks are bounded by a deadline the orchestrator will than being killed mid-flush (#134)
actually honour rather than being killed mid-flush (#134) - 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation 2^64 addresses, so per-address keying let one client mint unlimited buckets.
hands out 2^64 addresses, so per-address keying let one client mint Manager decision, recorded on the issue (#125)
unlimited buckets. Manager decision, recorded on the issue (#125) - 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
- 2026-08-17 Correct release-blocking README and startup-warning including claims about behaviour the code does not have (#151)
inaccuracies, including claims about behaviour the code does not have
(#151)
- 2026-08-17 Fetch and verify Alpine.js at build time against - 2026-08-17 Fetch and verify Alpine.js at build time against
`static/vendor.sha256` instead of committing the minified blob, so `static/vendor.sha256` instead of committing the minified blob, so the
the dependency is pinned by hash rather than by trust (#145) dependency is pinned by hash rather than by trust (#145)
- 2026-08-17 Bound the event log's rendered bodies in the query itself, - 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
so a large stored payload cannot be read into memory just to be large stored payload cannot be read into memory just to be truncated for
truncated for display (#135) display (#135)
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can - 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
carry a bearer credential in its path or query, and was rendered bearer credential in its path or query, and was rendered verbatim. Manager
verbatim. Manager decision to mask unconditionally (#115) decision to mask unconditionally (#115)
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that - 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
hangs cannot hold the process past its grace period (#102) cannot hold the process past its grace period (#102)
- 2026-08-14 Render templates via a buffer rather than the - 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
`ResponseWriter`, so a template error part-way through cannot commit a template error part-way through cannot commit a 200 and then fail — the
a 200 and then fail — the response is written only once it is whole response is written only once it is whole (#123)
(#123) - 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
- 2026-08-14 Align the session codec's max-age with the 7-day absolute codec accepted cookies the session layer considered expired, so the cap was
cap. The codec accepted cookies the session layer considered expired, enforced in one place and not the other (#108)
so the cap was enforced in one place and not the other (#108) - 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where default silently discards forwarded headers and every client rate-limits as
the safe default silently discards forwarded headers and every client the proxy's address (#149)
rate-limits as the proxy's address (#149) - 2026-08-12 Bound the receiver rate limit per client IP across the whole
- 2026-08-12 Bound the receiver rate limit per client IP across the `/webhook/*` route. The existing limiter keyed on the request path and
whole `/webhook/*` route. The existing limiter keyed on the request `/webhook/{uuid}` matches any single segment, so a client that invented a
path and `/webhook/{uuid}` matches any single segment, so a client fresh path per request minted a fresh bucket per request: the limit on the
that invented a fresh path per request minted a fresh bucket per only unauthenticated endpoint bounded nothing in aggregate, and every request
request: the limit on the only unauthenticated endpoint bounded still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
nothing in aggregate, and every request still cost an entrypoint client address alone now bounds that, chained in front of the unchanged
lookup before it 404ed. An outer limiter keyed on the client address
alone now bounds that, chained in front of the unchanged
per-entrypoint limiter (#139) per-entrypoint limiter (#139)
- 2026-08-12 Correct release-blocking documentation inaccuracies: the - 2026-08-12 Correct release-blocking documentation inaccuracies: the README
README promised manual redelivery in the present tense in three promised manual redelivery in the present tense in three places when nothing
places when nothing implements it (the same false claim also sat in implements it (the same false claim also sat in the doc comment that was its
the doc comment that was its source text), the env table omitted source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
`RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed itself omitted five landed units (#141)
units (#141) - 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
- 2026-08-12 Make the CI gate execute the checks it reports on. The writes a build-context fingerprint before calling `script/cibuild`, so a code
workflow now writes a build-context fingerprint before calling commit invalidates the `COPY` layer of the lint and builder stages while a
`script/cibuild`, so a code commit invalidates the `COPY` layer of docs-only commit still replays from cache; a superseding run also rewrites the
the lint and builder stages while a docs-only commit still replays `failure` status Gitea leaves on commits it cancelled and never tested.
from cache; a superseding run also rewrites the `failure` status Verified by pushing a deliberately broken test and watching CI go red (#119)
Gitea leaves on commits it cancelled and never tested. Verified by - 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
pushing a deliberately broken test and watching CI go red (#119) reached `time.NewTicker` in both the retention reaper and the archive sweeper,
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a panicking two goroutines with no recover after startup had already reported
non-positive value reached `time.NewTicker` in both the retention success (#140)
reaper and the archive sweeper, panicking two goroutines with no - 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
recover after startup had already reported success (#140) reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
cap: the reverse walk cuts entries with `strings.LastIndexByte` on the unauthenticated receiver. Semantics proven unchanged by differential
instead of joining and splitting, so a 1 MB header allocates 16 bytes testing against the previous implementation (#133)
rather than 1.6 MB per request on the unauthenticated receiver.
Semantics proven unchanged by differential testing against the
previous implementation (#133)
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
attacker-supplied chain cannot burn unbounded CPU in the rate-limit attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
key function; running off the end falls back to the peer address function; running off the end falls back to the peer address (#124)
(#124) - 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR all three rate limiters key on the connection's own address unless the direct
list: all three rate limiters key on the connection's own address peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
unless the direct peer is a configured proxy, in which case left for the first non-proxy hop. Default trusts nothing, and a
`X-Forwarded-For` is walked right to left for the first non-proxy hop. set-but-unparseable value aborts startup. Before this, any client could mint a
Default trusts nothing, and a set-but-unparseable value aborts fresh bucket or drain another's by rotating a spoofed header (#88)
startup. Before this, any client could mint a fresh bucket or drain - 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
another's by rotating a spoofed header (#88) settings placeholder removed, a progressive-enhancement copy button for the
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the entrypoint URL, and retention form copy that states the actual policy
Profile settings placeholder removed, a progressive-enhancement copy (deletion by the reaper, 0 retains forever) (#57)
button for the entrypoint URL, and retention form copy that states the - 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
actual policy (deletion by the reaper, 0 retains forever) (#57) the request URL in `*url.Error`, so every transport failure persisted the full
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Slack webhook URL into the per-webhook event database via
Go embeds the request URL in `*url.Error`, so every transport failure `DeliveryResult.Error`, a field a future REST API would have served.
persisted the full Slack webhook URL into the per-webhook event `maskURLError` drops path, query and userinfo while preserving the wrapped
database via `DeliveryResult.Error`, a field a future REST API would cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
have served. `maskURLError` drops path, query and userinfo while failures still read differently (#118)
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
still work and DNS, TLS and timeout failures still read differently
(#118)
- 2026-08-11 Rate-limit the public webhook receiver endpoint - 2026-08-11 Rate-limit the public webhook receiver endpoint
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
entrypoint path so one entrypoint cannot exhaust another's budget; path so one entrypoint cannot exhaust another's budget; over-limit requests
over-limit requests get 429 with `Retry-After`. It was the one get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
unauthenticated, internet-facing endpoint with no limit at all (#64) endpoint with no limit at all (#64)
- 2026-08-11 Enforce the body size limit before CSRF parses the form: - 2026-08-11 Enforce the body size limit before CSRF parses the form:
`MaxBodySize` is now first in all four form-parsing route groups, so `MaxBodySize` is now first in all four form-parsing route groups, so an
an oversized request is rejected with 413 instead of being read in oversized request is rejected with 413 instead of being read in full by the
full by the CSRF middleware before any cap applied (#90) CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which - 2026-08-11 Mask target config on the source detail page, which rendered the
rendered the stored blob verbatim and so exposed the Slack stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
incoming-webhook URL — a bearer credential that cannot be revoked credential that cannot be revoked per-holder. Config reaches the template only
per-holder. Config reaches the template only as a `TargetView` of as a `TargetView` of labelled fields, and header values are rendered as a
labelled fields, and header values are rendered as a count (#113) count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a - 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
sentinel written in `BeforeSave` so the GORM column default cannot written in `BeforeSave` so the GORM column default cannot win the race. Also
win the race. Also bounds the reaper's cutoff arithmetic: day counts bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
above 106751 overflowed `time.Duration` and wrapped the cutoff into `time.Duration` and wrapped the cutoff into the future, where every row
the future, where every row matched and the sweep deleted everything matched and the sweep deleted everything (#79)
(#79)
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - 2026-08-09 Inactivity-based session timeout: sliding idle expiry
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
requests, with the 7-day absolute cap kept as an independent with the 7-day absolute cap kept as an independent backstop that activity
backstop that activity never extends (#66) never extends (#66)
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
orphaned `retrying` delivery whose target type no longer supports orphaned `retrying` delivery whose target type no longer supports retries,
retries, recording a `DeliveryResult` with the reason instead of recording a `DeliveryResult` with the reason instead of leaving the delivery
leaving the delivery stuck forever (#82) stuck forever (#82)
- 2026-08-09 Root the delivery engine's worker pool and the retention - 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
reaper's sweep loop at `context.Background()` rather than the fx sweep loop at `context.Background()` rather than the fx `OnStart` hook context
`OnStart` hook context (#97), which carries fx's 15s start timeout and (#97), which carries fx's 15s start timeout and killed both roughly fifteen
killed both roughly fifteen seconds after boot: the proxy silently seconds after boot: the proxy silently stopped delivering webhooks entirely,
stopped delivering webhooks entirely, and the reaper never ran a and the reaper never ran a single sweep under its default one-hour interval
single sweep under its default one-hour interval - 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its `database` target) evicts the cached archive writer and closes its handle
last `database` target) evicts the cached archive writer and closes while deliberately leaving `archive-{webhookID}.db` on disk, and a new
its handle while deliberately leaving `archive-{webhookID}.db` on `ArchiveSweeper` prunes idle archives on the existing
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file `RETENTION_SWEEP_INTERVAL` without ever creating an archive file
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
environment values: `envInt` removed in favour of `envPositiveInt` environment values: `envInt` removed in favour of `envPositiveInt` plus a
plus a `PORT` range check, `envBool` now parses with `PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
`strconv.ParseBool`, and defaults apply only to unset variables (#80) defaults apply only to unset variables (#80)
- 2026-08-07 Automatic event retention cleanup based on - 2026-08-07 Automatic event retention cleanup based on `retention_days`,
`retention_days`, deleting expired events, deliveries, and delivery deleting expired events, deliveries, and delivery results from each
results from each per-webhook event database (#63) per-webhook event database (#63)
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
all newly surfaced lint findings surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
Makefile shims, README Entrypoints section shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55) - 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over - 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
plain HTTP and behind reverse proxies (#54) HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52) - 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets - 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
with DNS rebinding defense, and per-IP login rate limiting (#42) rebinding defense, and per-IP login rate limiting (#42)
- 2026-03-17 Slack target type for incoming webhook notifications (#47) - 2026-03-17 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49); - 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
absolute dev DATA_DIR default and clarified env docs (#46) DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on - 2026-03-05 security headers middleware, session regeneration on login, request
login, request body size limits (#41) 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
(#32); removed the build-architecture global (#31) the build-architecture global (#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
delivery engine with bounded worker pool and circuit breaker, engine with bounded worker pool and circuit breaker, parallel fan-out,
parallel fan-out, per-webhook event databases, management UI (#16) per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded - 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
into README (#6) (#6)
# Future Steps # Future Steps
- Delivery status and retry management UI. Replay of a terminally - Delivery status and retry management UI. Replay of a terminally failed
failed delivery and per-attempt detail already landed delivery and per-attempt detail already landed
(https://git.eeqj.de/sneak/webhooker/issues/203, (https://git.eeqj.de/sneak/webhooker/issues/203,
https://git.eeqj.de/sneak/webhooker/issues/202) https://git.eeqj.de/sneak/webhooker/issues/202)
- Per-webhook rate limiting in the receiver handler (per-webhook config - Per-webhook rate limiting in the receiver handler (per-webhook config plus
plus handler enforcement; global limits must not apply to receiver handler enforcement; global limits must not apply to receiver endpoints)
endpoints) - API key authentication for programmatic access (APIKey model exists; Bearer
- API key authentication for programmatic access (APIKey model exists; token middleware does not)
Bearer token middleware does not)
- REST API v1 - REST API v1
- CRUD for webhooks, entrypoints, targets - CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints - event viewing and filtering endpoints
@@ -406,9 +370,9 @@ point of the branch.
- OpenAPI specification - OpenAPI specification
- Analytics dashboard: success rates, response times, volume - Analytics dashboard: success rates, response times, volume
- A remember-me option at login - A remember-me option at login
- Password reset flow for a forgotten password over the web. The - Password reset flow for a forgotten password over the web. The authenticated
authenticated password *change* flow already landed, and a lost password _change_ flow already landed, and a lost password is recoverable from
password is recoverable from the console with `webhooker resetpw` the console with `webhooker resetpw`
(https://git.eeqj.de/sneak/webhooker/issues/208) (https://git.eeqj.de/sneak/webhooker/issues/208)
- Later, nice to have - Later, nice to have
- email delivery target type - email delivery target type
@@ -149,6 +149,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)") Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
} }
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
// Failed and Pending lists, of the newest events with a delivery in
// given statuses, and for their counts (eventsWithStatus and
// countEventsWithStatus in the handlers). The lists must also reach
// the events table only by ID: from the matching deliveries, then from
// the newest of those events.
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
pending := []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
var (
rows []struct{ ID string }
count int64
)
matching := dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending)
newest := dry.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(50).
Select("events.id AS event_id")
// Each step of the plan is printed in braces, so these name the
// lookup that follows each scan.
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id").
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending).Count(&count),
byStatus)
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook // TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in // page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must // progress, each target's deliveries finished since a time, which must
+14 -4
View File
@@ -329,15 +329,25 @@ func replayBody(body string) *string {
} }
// redirectToEventLog redirects a replay or resubmit back to the event // redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice. // log it was triggered from, carrying the outcome as its notice. A
// Replay form carries the list it was pressed in as show, so a replay
// returns to the Failed or Pending list; a Resubmit form carries none,
// so a resubmit returns to the full log, where its new event is the
// newest.
func redirectToEventLog( func redirectToEventLog(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
code noticeCode, code noticeCode,
) { ) {
http.Redirect( location := withNotice("/hook/"+webhook.ID+"/events", code)
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
http.StatusSeeOther, show := r.PostFormValue(showParam)
if eventLogStatuses(show) != nil {
location += "&" + showParam + "=" + show
}
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
w, r, location, http.StatusSeeOther,
) )
} }
+61
View File
@@ -1,6 +1,7 @@
package handlers_test package handlers_test
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -471,6 +472,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
) )
} }
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
// Replay pressed in the Failed list carries that list in its form and
// returns to it, and that a show value the event log does not know
// returns to the full log.
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?show=failed",
), `name="show" value="failed"`)
// The second replay is refused, as the first is still queued.
for _, tc := range []struct{ show, location string }{
{"failed", "/hook/" + wh.ID +
"/events?notice=replay-queued&show=failed"},
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
} {
req := postRequest(
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
map[string]string{
paramSourceID: wh.ID,
paramDeliveryID: original.ID,
},
)
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleDeliveryReplay().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
}
}
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action // TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
// reaches the page it belongs on: a finished delivery renders a POST // reaches the page it belongs on: a finished delivery renders a POST
// form carrying a CSRF token, and the outcome code a refusal redirects // form carrying a CSRF token, and the outcome code a refusal redirects
+169
View File
@@ -0,0 +1,169 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleEntrypointCreate handles adding a new entrypoint.
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
description := r.PostFormValue("description")
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: description,
Active: true,
}
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, r, "failed to create entrypoint", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
http.StatusSeeOther,
)
}
}
// HandleEntrypointEdit handles changing an entrypoint's description.
// It writes only the description column, so the entrypoint keeps its
// URL, and an activate or deactivate saved since the page was shown
// is not undone.
func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
entrypointID := chi.URLParam(r, "entrypointID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
result := h.db.DB().Model(&database.Entrypoint{}).Where(
"id = ? AND webhook_id = ?", entrypointID, webhook.ID,
).Update("description", r.PostFormValue("description"))
if result.Error != nil {
h.serverError(
w, r, "failed to edit entrypoint", result.Error,
)
return
}
// The id came from the URL and may name another webhook's
// entrypoint, which this webhook does not have.
if result.RowsAffected == 0 {
h.renderError(w, r, http.StatusNotFound)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, entrypointSaved),
http.StatusSeeOther,
)
}
}
// HandleEntrypointDelete handles deleting an entrypoint.
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
return h.deleteChildResource(
"entrypointID", &database.Entrypoint{},
"failed to delete entrypoint",
nil,
entrypointDeleted,
)
}
// HandleEntrypointToggle handles toggling an entrypoint's
// active state.
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource(
"entrypointID",
func(webhookID, childID string) (bool, error) {
var ep database.Entrypoint
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&ep).Error
if err != nil {
return false, err
}
// Only the active column: saving the whole row would
// write back the description read above over an edit
// saved since.
active := !ep.Active
return active, h.db.DB().Model(&ep).
Update("active", active).Error
},
"failed to toggle entrypoint",
entrypointActivated, entrypointDeactivated,
)
}
+3 -1
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"math"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return return
} }
// The page shows every request header.
views, ok := h.eventLogViews( views, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targets, w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
) )
if !ok { if !ok {
return return
+620
View File
@@ -0,0 +1,620 @@
package handlers
import (
"net/http"
"slices"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// DeliveryView is the display-safe projection of a delivery
// for the event log page. Its target is a TargetView, so the
// stored configuration blob — which holds the target's
// credential — has no path to the template.
type DeliveryView struct {
ID string
Status database.DeliveryStatus
Target delivery.TargetView
// Replay is set on a delivery the Replay action created.
Replay bool
// Created is how long ago the delivery was created, and
// CreatedUTC the full timestamp the page shows on hover.
Created string
CreatedUTC string
// Results is this delivery's attempts in attempt order,
// bounded by maxRenderedAttempts. Without them a failure
// renders as the status word alone and says nothing about
// why.
Results []DeliveryResultView
// AttemptCount is how many attempts were recorded, which
// is more than len(Results) once the middle was dropped.
AttemptCount int
// AttemptsOmitted is how many attempts were dropped from
// the middle of Results. The page must show it, or the
// bound would hide history rather than fold it.
AttemptsOmitted int
// Paused is set while the delivery is retrying and its
// target's circuit breaker is open, and nil otherwise.
Paused *PausedView
}
// eventLogTarget is what the event log needs to know about
// one target: the display-safe view its template renders, and
// the redactor that keeps that target's own credential out of
// the text its remote peer chose. The two are kept together
// so a caller cannot pick up one without the other, and apart
// from TargetView so the secrets never reach a template.
type eventLogTarget struct {
View delivery.TargetView
Redactor delivery.Redactor
}
// The event log's show query parameter and its two values: the events
// with a failed delivery, and those with a delivery still pending or
// retrying.
const (
showParam = "show"
showFailed = "failed"
showPending = "pending"
)
// HandleSourceLogs shows the request/response logs for a
// webhook.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
targets, err := h.loadTargetMap(webhook.ID)
if err != nil {
// Without the map every delivery renders through a
// zero redactor, so failing the page is the only
// safe answer.
h.serverError(w, r, "failed to load targets", err)
return
}
// Any other value of show lists every event, as no value
// does.
show := r.URL.Query().Get(showParam)
statuses := eventLogStatuses(show)
if statuses == nil {
show = ""
}
evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets, statuses,
)
if !ok {
return
}
failed, pending, err := h.countFailedAndPendingEvents(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to count events", err)
return
}
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"TotalEvents": total,
"Show": show,
"FailedEvents": failed,
"PendingEvents": pending,
}
h.renderTemplate(w, r, "source_logs.html", data)
}
}
// loadTargetMap loads targets into a map of display-safe
// views keyed by target ID, each paired with its redactor.
// The projection happens here so that no caller can hand a
// raw target, configuration blob and all, to a template: the
// raw rows do not leave this function.
//
// The load is Unscoped because deleting a target only soft
// deletes the row while its deliveries survive in the
// per-webhook database. Both halves of the map need those rows:
// a scoped load leaves an old delivery with a zero redactor,
// which renders its response bodies unredacted, and with a zero
// view, which renders its target as a blank name.
//
// This map is historical display only. It is built for the event
// log and an event's own page, and reaches nothing but
// DeliveryView.Target: the target list on the source detail page,
// the edit form and the replay path each resolve targets
// themselves, and a deleted row is refused there as before.
func (h *Handlers) loadTargetMap(
webhookID string,
) (map[string]eventLogTarget, error) {
var targets []database.Target
err := h.db.DB().Unscoped().Where(
"webhook_id = ?", webhookID,
).Find(&targets).Error
if err != nil {
return nil, err
}
targetMap := make(
map[string]eventLogTarget, len(targets),
)
for i := range targets {
targetMap[targets[i].ID] = eventLogTarget{
Redactor: delivery.NewRedactor(&targets[i]),
}
}
// The views come from NewTargetViews rather than being
// rebuilt here, so the masking rules stay in one place and a
// deleted target's configuration is masked by the same code
// that masks a live one's.
for _, v := range delivery.NewTargetViews(targets) {
entry := targetMap[v.ID]
entry.View = v
targetMap[v.ID] = entry
}
return targetMap, nil
}
// loadEventsWithDeliveries loads the recentEventLimit newest events
// and their deliveries from the per-webhook database, and the total
// number of events stored. Given delivery statuses, both cover only
// the events with a delivery in one of them. Events come back as
// capped projections rather than database.Event rows: see
// eventLogColumns for why the cut happens in SQL.
//
// The bool reports whether the load succeeded. It is false
// once this has answered the request with an error, and the
// caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
statuses []database.DeliveryStatus,
) ([]EventLogView, int64, bool) {
if !h.dbMgr.DBExists(webhook.ID) {
return nil, 0, true
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, r, "failed to get webhook database", err,
)
return nil, 0, false
}
rows, totalEvents, err := loadEventLogRows(
webhookDB, webhook.ID, statuses,
)
if err != nil {
h.serverError(w, r, "failed to load events", err)
return nil, 0, false
}
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
maxRenderedBodyBytes,
)
return result, totalEvents, ok
}
// eventLogViews projects loaded events for rendering, each with
// its deliveries, how many times it has been resubmitted and the
// entrypoint it arrived at (for a resubmitted copy, the one the
// request it copies arrived at), and with its request headers only
// when their text holds at most maxHeaderBytes. Like
// loadEventsWithDeliveries, it reports false once it has answered
// the request with an error.
func (h *Handlers) eventLogViews(
w http.ResponseWriter,
r *http.Request,
webhookDB *gorm.DB,
webhookID string,
rows []eventLogRow,
targetMap map[string]eventLogTarget,
maxHeaderBytes int,
) ([]EventLogView, bool) {
result := make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
var deliveryIDs []string
eventIDs := make([]string, len(rows))
for i := range rows {
result[i] = rows[i].view(webhookID, maxHeaderBytes)
eventIDs[i] = rows[i].ID
webhookDB.Where(
"event_id = ?", rows[i].ID,
).Find(&eventDeliveries[i])
for j := range eventDeliveries[i] {
deliveryIDs = append(
deliveryIDs, eventDeliveries[i][j].ID,
)
}
}
attempts, err := h.loadDeliveryResults(
webhookDB, deliveryIDs,
)
if err != nil {
h.serverError(
w, r, "failed to load delivery attempts", err,
)
return nil, false
}
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, r, "failed to count event resubmissions", err,
)
return nil, false
}
entrypoints, err := h.entrypointNames(webhookID)
if err != nil {
h.serverError(w, r, "failed to load entrypoints", err)
return nil, false
}
for i := range rows {
result[i].Deliveries = h.newDeliveryViews(
eventDeliveries[i], targetMap, attempts,
)
result[i].ResubmitCount = resubmits[rows[i].ID]
name, ok := entrypoints[rows[i].EntrypointID]
if !ok {
name = "deleted entrypoint"
}
result[i].Entrypoint = name
}
return result, true
}
// loadEventLogRows reads the event log projection of the
// recentEventLimit newest events, newest first, and the total number
// of events stored, both narrowed by statuses as eventsWithStatus
// narrows them.
func loadEventLogRows(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) ([]eventLogRow, int64, error) {
totalEvents, err := countEventsWithStatus(
webhookDB, webhookID, statuses,
)
if err != nil {
return nil, 0, err
}
var rows []eventLogRow
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents, err
}
// eventLogStatuses returns the delivery statuses the event log's show
// value lists events by, or nil for one that lists every event.
func eventLogStatuses(show string) []database.DeliveryStatus {
switch show {
case showFailed:
return []database.DeliveryStatus{database.DeliveryStatusFailed}
case showPending:
return []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
default:
return nil
}
}
// eventsWithStatus selects the webhook's events, or, given statuses,
// the recentEventLimit newest of those with at least one delivery in
// one of them.
//
// Given statuses, its cost follows the matching deliveries. SQLite
// never reorders a CROSS JOIN, so it reads each join's left side
// first: the distinct event IDs of the matching deliveries, through
// idx_deliveries_status; then each of those events by ID, sorted to
// keep the newest; then the rows of only the events kept, so no other
// event's body is read. With a plain "id IN (matching deliveries)"
// condition instead, SQLite, which keeps no statistics on these
// tables, walks every event newest first.
func eventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) *gorm.DB {
if statuses == nil {
return webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
)
}
matching := webhookDB.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", statuses)
newest := webhookDB.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(recentEventLimit).
Select("events.id AS event_id")
return webhookDB.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id")
}
// countEventsWithStatus counts the webhook's events with at least one
// delivery in one of the statuses, or every event when statuses is
// nil. Given statuses, it counts the distinct events of the matching
// deliveries and reads nothing but those deliveries, through
// idx_deliveries_status, where counting the events would read every
// event row. That is the same number, because retention deletes an
// event's deliveries with it.
func countEventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) (int64, error) {
var count int64
if statuses == nil {
err := webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&count).Error
return count, err
}
err := webhookDB.Model(&database.Delivery{}).Distinct("event_id").
Where("status IN ?", statuses).Count(&count).Error
return count, err
}
// countFailedAndPendingEvents returns how many of the webhook's events
// the event log lists when it shows only those with a failed delivery,
// and when it shows only those with a delivery pending or retrying.
func (h *Handlers) countFailedAndPendingEvents(
webhookID string,
) (int64, int64, error) {
if !h.dbMgr.DBExists(webhookID) {
return 0, 0, nil
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
return 0, 0, err
}
failed, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showFailed),
)
if err != nil {
return 0, 0, err
}
pending, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showPending),
)
if err != nil {
return 0, 0, err
}
return failed, pending, nil
}
// resubmitCounts reports, for each of the page's events, how many
// events have been resubmitted from it.
//
// One grouped query covers the page rather than one query per event.
// The page shows at most recentEventLimit events, far below SQLite's
// bound parameter ceiling, so it needs no chunking as the delivery
// result load does.
func resubmitCounts(
webhookDB *gorm.DB, eventIDs []string,
) (map[string]int, error) {
counts := make(map[string]int, len(eventIDs))
if len(eventIDs) == 0 {
return counts, nil
}
var rows []struct {
ResubmittedFromID string
Total int
}
err := webhookDB.Model(&database.Event{}).
Select("resubmitted_from_id, count(*) AS total").
Where("resubmitted_from_id IN ?", eventIDs).
Group("resubmitted_from_id").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
counts[row.ResubmittedFromID] = row.Total
}
return counts, nil
}
// deliveryIDChunkSize bounds how many delivery IDs go into one
// IN clause. SQLite refuses a statement carrying more than
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
// page holds one delivery per target per event, so a webhook
// with enough targets would turn the whole query into an error
// and the page into zero attempts.
const deliveryIDChunkSize = 500
// loadDeliveryResults loads the recorded attempts for the
// page's deliveries, keyed by delivery ID.
//
// Each response body is cut by SQLite rather than in Go, for
// the reason deliveryResultColumns gives. How many attempts a
// delivery has is the target's MaxRetries, which the
// authenticated operator sets; how many of them reach the page
// is bounded again by maxRenderedAttempts.
func (h *Handlers) loadDeliveryResults(
webhookDB *gorm.DB,
deliveryIDs []string,
) (map[string][]deliveryResultRow, error) {
byDelivery := make(map[string][]deliveryResultRow)
for chunk := range slices.Chunk(
deliveryIDs, deliveryIDChunkSize,
) {
var rows []deliveryResultRow
err := webhookDB.Model(
&database.DeliveryResult{},
).Select(
deliveryResultColumns, maxRenderedResponseBytes,
).Where(
"delivery_id IN ?", chunk,
).Order("attempt_num ASC").Find(&rows).Error
if err != nil {
// Returning what was loaded so far renders the
// deliveries in the failed chunk as never having run,
// which is indistinguishable from ones that really
// never ran. The page fails instead.
return nil, err
}
for i := range rows {
byDelivery[rows[i].DeliveryID] = append(
byDelivery[rows[i].DeliveryID], rows[i],
)
}
}
return byDelivery, nil
}
// newDeliveryViews projects deliveries for rendering,
// resolving each one's target to its display-safe view and
// each one's attempts through that target's redactor. A
// retrying delivery also reads its target's circuit breaker.
func (h *Handlers) newDeliveryViews(
deliveries []database.Delivery,
targetMap map[string]eventLogTarget,
attempts map[string][]deliveryResultRow,
) []DeliveryView {
views := make([]DeliveryView, len(deliveries))
for i := range deliveries {
target := targetMap[deliveries[i].TargetID]
rows := attempts[deliveries[i].ID]
created := deliveries[i].CreatedAt
results, omitted := renderedAttempts(
rows, target.Redactor,
)
views[i] = DeliveryView{
ID: deliveries[i].ID,
Status: deliveries[i].Status,
Target: target.View,
Replay: deliveries[i].Replay,
Created: humanize.Time(created),
CreatedUTC: created.UTC().Format(time.DateTime) + " UTC",
Results: results,
AttemptCount: len(rows),
AttemptsOmitted: omitted,
}
if deliveries[i].Status == database.DeliveryStatusRetrying {
views[i].Paused = h.deliveryPausedView(
deliveries[i].TargetID, rows,
)
}
}
return views
}
// maxRenderedAttempts bounds how many of one delivery's
// attempts the page renders. Past it the middle is dropped and
// counted, keeping the first attempts and the last ones: how
// the delivery started failing and how it ended are what a
// reader needs, and the count says plainly that the rest was
// dropped rather than never recorded.
const (
renderedAttemptsHead = 10
renderedAttemptsTail = 10
maxRenderedAttempts = renderedAttemptsHead +
renderedAttemptsTail
)
// renderedAttempts projects a delivery's attempts through the
// target's redactor, at most maxRenderedAttempts of them, and
// reports how many it dropped.
func renderedAttempts(
rows []deliveryResultRow,
redactor delivery.Redactor,
) ([]DeliveryResultView, int) {
omitted := 0
if len(rows) > maxRenderedAttempts {
omitted = len(rows) - maxRenderedAttempts
kept := make(
[]deliveryResultRow, 0, maxRenderedAttempts,
)
kept = append(kept, rows[:renderedAttemptsHead]...)
kept = append(
kept, rows[len(rows)-renderedAttemptsTail:]...,
)
rows = kept
}
views := make([]DeliveryResultView, len(rows))
for i := range rows {
views[i] = rows[i].view(redactor)
}
return views, omitted
}
+115 -9
View File
@@ -1,10 +1,15 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http"
"slices"
"strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/database"
) )
// eventLogColumns is the event log's projection. The casts to // eventLogColumns is the event log's projection. The casts to
@@ -12,16 +17,20 @@ import (
// bytes rather than characters, so the cap bounds the page in // bytes rather than characters, so the cap bounds the page in
// bytes whatever the payload's encoding. Cutting in SQLite // bytes whatever the payload's encoding. Cutting in SQLite
// rather than in Go is the point of the projection — an // rather than in Go is the point of the projection — an
// oversized body never becomes a Go string at all. // oversized body or set of request headers never becomes a Go
// string at all.
const eventLogColumns = "id, created_at, method, content_type, " + const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " + "resubmitted_from_id, entrypoint_id, " +
"substr(cast(headers as blob), 1, ?) AS headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"substr(cast(body as blob), 1, ?) AS body, " + "substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which // eventColumns is eventLogColumns for the event's own page, which
// shows the whole body. // shows the whole body and every request header.
const eventColumns = "id, created_at, method, content_type, " + const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " + "resubmitted_from_id, entrypoint_id, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " + "cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
@@ -40,6 +49,22 @@ type EventLogView struct {
Body BodyView Body BodyView
// Entrypoint names the entrypoint the event arrived at. A
// resubmitted copy, even a copy of a copy, did not arrive; it
// names the one the request it copies arrived at. The name is
// the entrypoint's description, "Entrypoint" when it has none,
// or "deleted entrypoint", never its URL, which is the
// entrypoint's secret.
Entrypoint string
// Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than
// maxRenderedBodyBytes, stored or as text; only the event log
// leaves them out.
Headers string
HeadersCut bool
// ResubmittedFromID names the event this one was copied // ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver. // from, empty for an event that arrived on the receiver.
ResubmittedFromID string ResubmittedFromID string
@@ -60,27 +85,35 @@ func (v EventLogView) ResubmittedFrom() bool {
} }
// eventLogRow is one row of the event log projection, or of // eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its body column arrives // eventColumns. In the event log its headers and body columns
// already cut to the cap by SQLite, with the true size beside // arrive already cut to the cap by SQLite, each with its true
// it. // size beside it.
type eventLogRow struct { type eventLogRow struct {
ID string ID string
CreatedAt time.Time CreatedAt time.Time
Method string Method string
ContentType string ContentType string
ResubmittedFromID *string ResubmittedFromID *string
EntrypointID string
Headers string
HeadersBytes int64
Body []byte Body []byte
BodyBytes int64 BodyBytes int64
} }
// view projects a loaded row of the webhook's events for // view projects a loaded row of the webhook's events for
// rendering. // rendering. It shows the request headers when the row holds them
func (r *eventLogRow) view(webhookID string) EventLogView { // whole and their text holds at most maxHeaderBytes.
func (r *eventLogRow) view(
webhookID string, maxHeaderBytes int,
) EventLogView {
var from string var from string
if r.ResubmittedFromID != nil { if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID from = *r.ResubmittedFromID
} }
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
return EventLogView{ return EventLogView{
ID: r.ID, ID: r.ID,
Method: r.Method, Method: r.Method,
@@ -90,10 +123,83 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
Body: newBodyView( Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
), ),
Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from, ResubmittedFromID: from,
} }
} }
// requestHeaderLines turns an event's stored request headers, the
// JSON the receiver writes, into one "Name: value" line per value,
// sorted by name. Headers that do not parse, as when the event log
// has cut them, show as none. It reports false, with no lines, when
// the lines, each with the newline that follows it, would hold more
// than maxBytes: a header sent many times is stored with its name
// once but shown with it on every line.
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
var headers http.Header
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
return nil, true
}
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
slices.Sort(names)
var lines []string
size := 0
for _, name := range names {
for _, value := range headers[name] {
line := name + ": " + value
size += len(line) + len("\n")
if size > maxBytes {
return nil, false
}
lines = append(lines, line)
}
}
return lines, true
}
// entrypointNames maps each of the webhook's entrypoints to the name
// an event that arrived at it shows: its description, or "Entrypoint"
// when it has none, as the webhook page names it. A deleted
// entrypoint is left out.
func (h *Handlers) entrypointNames(
webhookID string,
) (map[string]string, error) {
var entrypoints []database.Entrypoint
err := h.db.DB().Where(
"webhook_id = ?", webhookID,
).Find(&entrypoints).Error
if err != nil {
return nil, err
}
names := make(map[string]string, len(entrypoints))
for i := range entrypoints {
name := entrypoints[i].Description
if name == "" {
name = "Entrypoint"
}
names[entrypoints[i].ID] = name
}
return names, nil
}
// trimPartialRune drops a trailing UTF-8 sequence that the // trimPartialRune drops a trailing UTF-8 sequence that the
// byte-wise cut left incomplete, so a multi-byte rune severed // byte-wise cut left incomplete, so a multi-byte rune severed
// at the cap does not surface as a mojibake tail. // at the cap does not surface as a mojibake tail.
+333
View File
@@ -0,0 +1,333 @@
package handlers_test
import (
"encoding/json"
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900 wrap-anywhere">` + name +
`</span>`
}
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// the entrypoint the request it copies arrived at.
func copiedRequestArrivedAt(name string) string {
return `The request it copies arrived at ` +
`<span class="text-gray-900 wrap-anywhere">` + name + `</span>`
}
// headerBox is how a page shows an event's request header lines: as
// one block of text in a single box.
func headerBox(lines ...string) string {
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
`break-all">` + strings.Join(lines, "\n") + `</pre>`
}
// showHeadersLink is the event log's link to an event's own page for
// request headers it leaves out.
func showHeadersLink(webhookID, eventID string) string {
return `<a href="/hook/` + webhookID + `/events/` + eventID +
`" class="btn-small">Show the request headers</a>`
}
// entrypoint records one of the fixture webhook's entrypoints.
func (f *recentEventsFixture) entrypoint(
t *testing.T, description string,
) *database.Entrypoint {
t.Helper()
ep := &database.Entrypoint{
WebhookID: f.webhook.ID,
Path: uuid.NewString(),
Description: description,
Active: true,
}
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
// eventAt records an event that arrived at the entrypoint with the
// given request headers, stored as JSON as the receiver stores them.
func (f *recentEventsFixture) eventAt(
t *testing.T,
ep *database.Entrypoint,
headersJSON string,
receivedAt time.Time,
) *database.Event {
t.Helper()
event := &database.Event{
WebhookID: f.webhook.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
Headers: headersJSON,
Body: "{}",
BodyBytes: 2,
ContentType: contentTypeJSON,
}
event.CreatedAt = receivedAt
require.NoError(t, f.webhookDB.Omit(
clause.Associations,
).Create(event).Error)
return event
}
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
// events that arrived at two entrypoints each show their own
// entrypoint and request headers, in the event log and on their own
// pages, with the headers sorted by name, escaped and keeping their
// whitespace, and never the entrypoint's URL.
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t *testing.T,
) {
t.Parallel()
f := newRecentEventsFixture(t)
billing := f.entrypoint(t, "Billing sender")
unnamed := f.entrypoint(t, "")
// Stored in reverse name order.
older := f.eventAt(t, billing,
`{"X-Shop-Event":["order.created"],`+
`"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
time.Now().Add(-time.Minute))
newer := f.eventAt(t, unnamed,
`{"X-Shop-Event":["order.paid"],"X-Note":["<b>hi</b>"]}`,
time.Now())
olderShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, headerBox(
"Accept: */*",
"User-Agent: shop/1 build\t7",
"X-Shop-Event: order.created",
), "headers are sorted by name")
assert.NotContains(t, page, "order.paid")
assert.NotContains(t, page, billing.Path)
}
newerShows := func(t *testing.T, page string) {
t.Helper()
assert.Contains(t, page, arrivedAt("Entrypoint"))
assert.Contains(t, page, headerBox(
"X-Note: &lt;b&gt;hi&lt;/b&gt;",
"X-Shop-Event: order.paid",
))
assert.NotContains(t, page, "<b>hi</b>")
assert.NotContains(t, page, "order.created")
assert.NotContains(t, page, unnamed.Path)
}
// The log lists the newer event first, so everything between
// the two events' first mentions belongs to the newer one.
_, rest, found := strings.Cut(renderSourceLogsPage(
t, f.h, f.sess, f.webhook.ID,
), newer.ID)
require.True(t, found)
newerPart, olderPart, found := strings.Cut(rest, older.ID)
require.True(t, found)
newerShows(t, newerPart)
olderShows(t, olderPart)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
require.Equal(t, http.StatusOK, w.Code)
newerShows(t, w.Body.String())
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
require.Equal(t, http.StatusOK, w.Code)
olderShows(t, w.Body.String())
}
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
// has since been deleted says so in the event log and on its own page.
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Retired sender")
event := f.eventAt(t, ep, `{}`, time.Now())
require.NoError(t, f.db.DB().Delete(ep).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
assert.NotContains(t, page, "Retired sender")
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
assert.NotContains(t, w.Body.String(), "Retired sender")
}
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
// of that copy each say the request they copy arrived at the
// entrypoint, in the event log and on their own pages, and never that
// they did.
func TestEventRequest_ResubmittedCopy(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
require.NoError(t, f.webhookDB.Model(copied).Update(
"resubmitted_from_id", original.ID,
).Error)
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
"resubmitted_from_id", copied.ID,
).Error)
// The log lists the newest event first, and each event's Resubmit
// form comes before its entrypoint, so cutting the page at the
// copy's and the original's forms leaves each event's entrypoint
// in its own part.
copyOfCopyPart, rest, found := strings.Cut(
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
"/events/"+copied.ID+"/resubmit",
)
require.True(t, found)
copyPart, originalPart, found := strings.Cut(
rest, "/events/"+original.ID+"/resubmit",
)
require.True(t, found)
for _, part := range []string{copyOfCopyPart, copyPart} {
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, part, arrivedAt("Billing sender"))
}
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
assert.NotContains(t, originalPart,
copiedRequestArrivedAt("Billing sender"))
for _, event := range []*database.Event{copied, copyOfCopy} {
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(),
copiedRequestArrivedAt("Billing sender"))
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
}
}
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
// request headers that hold more than it shows of a body, whether
// stored or as lines, and links to the event's own page, which shows
// them all.
func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
t.Parallel()
// The receiver stores each "<" as six bytes of JSON, so this
// header is over the limit stored but not as a line.
const lessThans = bodyCap/6 + 1
// A header sent many times is stored with its name once, and
// shown with it on every line.
repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
tests := map[string]struct {
headers http.Header
line string
}{
"stored": {
headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
line: "X-Long: " + strings.Repeat("&lt;", lessThans),
},
"as lines": {
headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
line: repeatedName + ": ",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
headersJSON, err := json.Marshal(tc.headers)
require.NoError(t, err)
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, string(headersJSON), time.Now())
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
assert.NotContains(t, page, tc.line)
assert.Less(t, len(page), 4*bodyCap)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), tc.line)
assert.NotContains(t, w.Body.String(), "Show the request headers")
})
}
}
// TestEventRequest_ManyShortHeaderLines proves that for many short
// request header lines the event log writes no more than its limit,
// apart from escaping: lines that fill the limit show as one block of
// text, and one line more is left out with a link to the event's own
// page.
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
t.Parallel()
// Each "A: " line and the newline after it hold four bytes, so
// this many lines fill the limit exactly. Each line in its own
// element would make the page many times the limit.
const fill = bodyCap / len("A: \n")
tests := map[string]struct {
lines int
shown bool
}{
"filling the limit": {lines: fill, shown: true},
"one over the limit": {lines: fill + 1, shown: false},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
headersJSON, err := json.Marshal(http.Header{
"A": slices.Repeat([]string{""}, tc.lines),
})
require.NoError(t, err)
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, string(headersJSON), time.Now())
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
link := showHeadersLink(f.webhook.ID, event.ID)
assert.Equal(t, tc.shown, strings.Contains(page, box))
assert.Equal(t, !tc.shown, strings.Contains(page, link))
assert.Less(t, len(page), 4*bodyCap)
})
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
webhook database.Webhook, webhook database.Webhook,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil, w, newRequestForTest(), webhook, nil, nil,
) )
return views return views
+4 -4
View File
@@ -167,12 +167,12 @@ func New(
), ),
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate( "source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_row.html", "source_logs.html", "event_request.html", "event_body.html",
"delivery_attempts.html", "delivery_row.html", "delivery_attempts.html",
), ),
"event_detail.html": parsePageTemplate( "event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_row.html", "event_detail.html", "event_request.html", "event_body.html",
"delivery_attempts.html", "delivery_row.html", "delivery_attempts.html",
), ),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"), "error.html": parsePageTemplate("error.html"),
+230
View File
@@ -0,0 +1,230 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// parseRetentionDays interprets a retention_days form value. It
// returns the number of days, or, for a value it refuses, the message
// the create and edit forms show; the message is empty when the value
// is accepted.
//
// An empty value yields fallback, which lets the create path apply the
// default and the edit path leave the stored value unchanged. A value
// of 0 is returned as 0 and is rewritten to the retain-forever
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
// or negative is refused rather than silently given a default.
//
// The upper bound is not cosmetic. The reaper computes its cutoff as a
// time.Duration, an int64 nanosecond count, so a day count above
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
// future, and deletes every event the webhook has. A finite value
// above that ceiling is therefore refused, and the message names the
// ceiling rather than implying the input was not a number.
//
// A value at or above the retain-forever sentinel is not out of range:
// it is what the edit form pre-fills for a retain-forever webhook, so
// submitting the form back unchanged has to keep meaning "forever"
// rather than being rejected.
func parseRetentionDays(raw string, fallback int) (int, string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, ""
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, "Retention must be a whole number of days, or 0 to " +
"retain events forever."
}
if v >= database.RetentionForeverDays {
return database.RetentionForeverDays, ""
}
if v > database.MaxFiniteRetentionDays {
return 0, "Retention must be at most " +
strconv.Itoa(database.MaxFiniteRetentionDays) +
" days, or 0 to retain events forever."
}
return v, ""
}
// ownedWebhook resolves the request's sourceID parameter to a
// webhook the session's user owns.
//
// Ownership and existence are decided by one query, so a
// webhook belonging to another user is indistinguishable from
// one that does not exist: both are a 404, and neither confirms
// the id. Callers that reach further into a webhook's data —
// the event log page and the event body download — share this
// one check rather than restating it, so the download cannot
// come to authorize differently from the page that links to it.
//
// It reports false once it has written the response, which is a
// redirect to the login page for an unauthenticated request and
// a 404 otherwise. The caller returns without writing more.
func (h *Handlers) ownedWebhook(
w http.ResponseWriter,
r *http.Request,
) (database.Webhook, bool) {
var webhook database.Webhook
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return database.Webhook{}, false
}
sourceID := chi.URLParam(r, "sourceID")
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, false
}
return webhook, true
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the child's id once the
// delete has removed it, before the redirect, which carries done as
// its notice.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(childID string),
done noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
childID := chi.URLParam(r, idParam)
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
result := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.serverError(w, r, errMsg, result.Error)
return
}
// Only for a row this webhook really had: the id came from
// the URL and may name another webhook's child.
if afterDelete != nil && result.RowsAffected > 0 {
afterDelete(childID)
}
http.Redirect(
w, r,
withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther,
)
}
}
// toggleChildResource returns a handler that toggles the active
// state of a child resource belonging to a webhook. toggleFn returns
// the new state, and the redirect carries activated or deactivated as
// its notice to match.
func (h *Handlers) toggleChildResource(
idParam string,
toggleFn func(webhookID, childID string) (bool, error),
errMsg string,
activated, deactivated noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
childID := chi.URLParam(r, idParam)
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
active, err := toggleFn(webhook.ID, childID)
if err != nil {
h.serverError(w, r, errMsg, err)
return
}
done := deactivated
if active {
done = activated
}
http.Redirect(
w, r,
withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther,
)
}
}
// getUserID extracts the user ID from the session.
func (h *Handlers) getUserID(
r *http.Request,
) (string, bool) {
sess, err := h.session.Get(r)
if err != nil {
return "", false
}
if !h.session.IsAuthenticated(sess) {
return "", false
}
return h.session.GetUserID(sess)
}
+147
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -186,6 +187,152 @@ func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
assert.Contains(t, body, "50 most recent of 51 events") assert.Contains(t, body, "50 most recent of 51 events")
} }
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
// Failed list holds exactly the events with a failed delivery, the
// Pending list exactly those with a delivery pending or retrying, each
// once, and any other show value every event; that each link, and the
// line beside the heading, counts the events its list holds; and that
// the shown link is marked.
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
const (
failed = database.DeliveryStatusFailed
delivered = database.DeliveryStatusDelivered
pending = database.DeliveryStatusPending
retrying = database.DeliveryStatusRetrying
)
// Each event is named by its content type. The first failed and
// was then replayed and delivered. The second failed, and so did
// its replay, and the fifth has one delivery pending and another
// retrying: each must still be listed and counted once.
events := []struct {
contentType string
deliveries []database.DeliveryStatus
}{
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
{"application/x-pending", []database.DeliveryStatus{pending}},
{"application/x-retrying", []database.DeliveryStatus{retrying}},
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
{"application/x-delivered", []database.DeliveryStatus{delivered}},
{"application/x-no-delivery", nil},
}
all := make([]string, len(events))
for i, e := range events {
event := f.event(
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
)
for _, status := range e.deliveries {
f.delivery(t, event, target.ID, status)
}
all[i] = e.contentType
}
for _, tc := range []struct {
query string
current string
heading string
listed []string
}{
{"", "All", "7 total events", all},
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
[]string{"application/x-failed", "application/x-failed-twice"}},
{"?show=pending", "Pending (3)",
"3 events with a delivery pending or retrying", []string{
"application/x-pending", "application/x-retrying",
"application/x-pending-retrying",
}},
{"?show=unknown", "All", "7 total events", all},
} {
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, tc.query,
)
// One row per listed event, so with each listed event shown
// no event is listed twice.
assert.Equal(t, len(tc.listed),
strings.Count(body, `role="button"`), tc.query)
for _, contentType := range all {
assert.Equal(t,
slices.Contains(tc.listed, contentType),
strings.Contains(body, ">"+contentType+"<"),
tc.query+" "+contentType)
}
assert.Contains(t, body, ">"+tc.heading+"<", tc.query)
assert.Contains(t, body, "Failed (2)", tc.query)
assert.Contains(t, body, "Pending (3)", tc.query)
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
assert.Contains(t, body,
`aria-current="page">`+tc.current+"</a>", tc.query)
}
}
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
// list holds the 50 newest matching events, as the full log does,
// while its link and heading count every matching event.
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
base := time.Now().Add(-time.Hour)
for i := range 51 {
event := f.event(
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
}
// The newest event has no failed delivery.
f.event(t, "application/x-no-delivery", "{}", time.Now())
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
)
assert.Equal(t, 50, strings.Count(body, `role="button"`))
assert.NotContains(t, body, "application/x-failed-00")
assert.NotContains(t, body, "application/x-no-delivery")
assert.Contains(t, body, "Failed (51)")
assert.Contains(t, body,
"50 most recent of 51 events with a failed delivery")
}
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
// says that no event matches rather than that none was recorded.
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()),
target.ID, database.DeliveryStatusDelivered,
)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
), "No event has a failed delivery.")
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=pending",
), "No event has a delivery pending or retrying.")
}
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the // TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
// events in the log only the newest starts expanded. // events in the log only the newest starts expanded.
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) { func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// HandleTargetCreate handles adding a new target to a webhook.
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
h.renameMu.Lock()
defer h.renameMu.Unlock()
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
h.processTargetCreate(w, r, webhook)
}
}
// processTargetCreate validates and creates a new target. A refused
// submission shows the webhook page again, with the add target form
// open on the chosen type, the values entered, and the reason.
func (h *Handlers) processTargetCreate(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
in := targetFormInputFrom(r)
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
h.renderSourceDetail(w, r, webhook, in, errMsg)
return
}
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, r, "failed to create target", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
http.StatusSeeOther,
)
}
// newTarget validates a new target for a webhook and returns the row
// to create, or, when it refuses the target, the message the form
// shows. An error is the server's fault, not a refusal: the accepted
// configuration could not be encoded. Every form that creates a
// target goes through here, so they all accept and refuse the same
// things.
func (h *Handlers) newTarget(
ctx context.Context,
webhookID string,
in targetFormInput,
) (*database.Target, string, error) {
target := &database.Target{
WebhookID: webhookID,
Type: in.Type,
Active: true,
}
errMsg, err := h.setTargetFromForm(ctx, target, in)
if err != nil || errMsg != "" {
return nil, errMsg, err
}
return target, "", nil
}
// setTargetFromForm validates a target form against the target's type
// and, when it accepts it, sets the target's name, configuration and
// retry count from it. It returns the message the form shows for
// anything it refuses, an unknown type among them, and then leaves the
// target unchanged; an error is the server's fault, as for newTarget.
// The add target form and the target edit form both go through here,
// so the two cannot come to disagree about what a target may be.
func (h *Handlers) setTargetFromForm(
ctx context.Context,
target *database.Target,
in targetFormInput,
) (string, error) {
if in.Name == "" {
return "Name is required", nil
}
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
if err != nil || errMsg != "" {
return errMsg, err
}
// An empty max_retries keeps the target's count: the
// fire-and-forget default of 0 for a new target, and the stored
// count for an edited one, since the forms for target types that
// do not retry have no such field. A value that is filled in but
// invalid is refused rather than becoming that count, so a typo
// cannot destroy the count a target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil {
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
}
target.Name = in.Name
target.Config = configJSON
target.MaxRetries = maxRetries
return "", nil
}
// targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to setTargetFromForm,
// so neither can come to validate a target differently from the
// other. Both forms are filled from one: the edit form with the
// stored values, and a refused form with the values submitted.
type targetFormInput struct {
// Name is the target's name.
Name string
// Type is the type chosen on the add target form. The edit form
// has none: a target's stored type decides.
Type database.TargetType
// URL is the destination for an HTTP target and the webhook URL
// for a Slack target.
URL string
// Headers is an HTTP target's headers, one "Name: value" per
// line.
Headers string
// Timeout is an HTTP target's per-request timeout in seconds.
Timeout string
// MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string
// Expiry is a database (archive) target's row expiry.
Expiry string
// Rotation is a database (archive) target's rotation.
Rotation string
}
// targetFormInputFrom reads a target form from a request body. The
// body size cap is enforced by the MaxBodySize middleware, which runs
// before CSRF parses the form.
//
// Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and the
// request line, unlike the body, is what logs, proxies, Referer
// headers and error trackers record. The headers field is under the
// same rule and for the same reason: its values are authorization
// tokens.
func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{
Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"),
MaxRetries: r.PostFormValue("max_retries"),
Expiry: r.PostFormValue("expiry"),
Rotation: r.PostFormValue("rotation"),
}
}
// buildTargetConfig builds the JSON config string for a target from
// the submitted form values, or returns the message the form shows
// for a value it refuses. An error is the server's fault, not a
// refusal: the accepted configuration could not be encoded. Which
// fields of in apply depends on the target type; a type without a URL
// ignores any URL submitted.
func (h *Handlers) buildTargetConfig(
ctx context.Context,
targetType database.TargetType,
in targetFormInput,
) (string, string, error) {
switch targetType {
case database.TargetTypeHTTP:
return h.buildHTTPTargetConfig(ctx, in)
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(ctx, in.URL)
case database.TargetTypeDatabase:
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
case database.TargetTypeLog:
return "", "", nil
default:
return "", "Invalid target type", nil
}
}
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
// SSRF-validated destination plus the optional headers and timeout
// the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context,
in targetFormInput,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, in.URL, "URL is required for HTTP targets",
)
if errMsg != "" {
return "", errMsg, nil
}
headers, err := delivery.ParseTargetHeaders(in.Headers)
if err != nil {
return "", fmt.Sprintf("Invalid headers: %v", err), nil
}
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
if err != nil {
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
}
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
})
return configJSON, "", err
}
// buildSlackTargetConfig builds config JSON for a Slack target,
// whose whole configuration is one SSRF-validated webhook URL.
func (h *Handlers) buildSlackTargetConfig(
ctx context.Context,
targetURL string,
) (string, string, error) {
errMsg := h.validateTargetURL(
ctx, targetURL,
"Webhook URL is required for Slack targets",
)
if errMsg != "" {
return "", errMsg, nil
}
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
return configJSON, "", err
}
// validateTargetURL refuses an empty or SSRF-blocked destination,
// returning the message the form shows, or "" when the destination
// is accepted. missingMsg is the message for no URL at all.
//
// It is the single point at which a user-supplied destination enters
// the SSRF guard, on create and on edit alike. An edit path that
// reached storage without passing through here would reopen the hole
// the guard closes.
func (h *Handlers) validateTargetURL(
ctx context.Context,
targetURL, missingMsg string,
) string {
if targetURL == "" {
return missingMsg
}
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
if err != nil {
// The submitted URL can be a credential (a Slack
// incoming webhook URL is a bearer token), so the log
// records only its scheme and host.
h.log.Warn(
"target URL blocked by SSRF protection",
"url", delivery.MaskURL(targetURL),
"error", err,
)
msg := "Invalid target URL: " + err.Error()
// Only a private or reserved address's refusal says how
// to allow it. Other refusals never do: link-local, the
// unspecified addresses and the unconditional metadata
// addresses cannot be opened, and the default
// blocklist's public addresses, which listing does open,
// hand out credentials.
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
msg += ". Private and reserved addresses are refused " +
"by default; the server's ALLOWED_EGRESS_CIDRS " +
"setting allows named networks (see \"Allowing " +
"egress to your own network\" in the README)."
}
return msg
}
return ""
}
// marshalTargetConfig serialises a target configuration for storage.
func marshalTargetConfig(cfg any) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return string(configBytes), nil
}
// buildDatabaseTargetConfig builds config JSON for a database
// (archive) target. The optional expiry and rotation are validated
// here, at creation time, so a bad value is refused instead of
// failing every subsequent delivery. Each is stored only when set,
// and with neither the config is empty (the keep-forever, one-file
// default).
func buildDatabaseTargetConfig(
expiry, rotation string,
) (string, string, error) {
expiry = strings.TrimSpace(expiry)
err := delivery.ValidateArchiveExpiry(expiry)
if err != nil {
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
}
err = delivery.ValidateArchiveRotation(rotation)
if err != nil {
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
}
cfg := map[string]any{}
if expiry != "" {
cfg["expiry"] = expiry
}
if rotation != "" {
cfg["rotation"] = rotation
}
if len(cfg) == 0 {
return "", "", nil
}
configJSON, err := marshalTargetConfig(cfg)
return configJSON, "", err
}
+31
View File
@@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleTargetDelete handles deleting a target. A deleted
// database target's archive writer is evicted and its handle
// closed; the archive file is left on disk.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return h.deleteChildResource(
"targetID", &database.Target{},
"failed to delete target",
h.evictTargetArchiveWriter,
targetDeleted,
)
}
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
// target, and leaves its archive file on disk for the same reason.
// A target that is not a database target has no writer, and
// evicting it does nothing.
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
if h.archives == nil {
return
}
h.archives.EvictTarget(targetID)
}
+2 -1
View File
@@ -91,7 +91,8 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
assert.Contains(t, log, "t-http: delivered") assert.Contains(t, log, "t-http: delivered")
assert.Regexp(t, waiting+cooldownEnds, log) assert.Regexp(t, waiting+cooldownEnds, log)
assert.Contains(t, log, waiting+backoffEnds) assert.Contains(t, log, waiting+backoffEnds)
assert.NotContains(t, log, "retrying") // No delivery shows as retrying; the Pending link's title says it.
assert.NotRegexp(t, `t-http: retrying|>retrying<`, log)
page := eventPage(t, h, sess, wh.ID, retrying.ID) page := eventPage(t, h, sess, wh.ID, retrying.ID)
assert.Regexp(t, waiting+cooldownEnds, page) assert.Regexp(t, waiting+cooldownEnds, page)
+35
View File
@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleTargetToggle handles toggling a target's active state.
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return h.toggleChildResource(
"targetID",
func(webhookID, childID string) (bool, error) {
var tgt database.Target
err := h.db.DB().Where(
"id = ? AND webhook_id = ?",
childID, webhookID,
).First(&tgt).Error
if err != nil {
return false, err
}
// Only the active column: saving the whole row would
// write back the name and settings read above over an
// edit saved since.
active := !tgt.Active
return active, h.db.DB().Model(&tgt).
Update("active", active).Error
},
"failed to toggle target",
targetActivated, targetDeactivated,
)
}
+263
View File
@@ -0,0 +1,263 @@
package handlers
import (
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleSourceCreate shows the form to create a new webhook.
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(
w, r, "sources_new.html",
newSourceFormData("", sourceFormInput{
RetentionDays: strconv.Itoa(
database.DefaultRetentionDays,
),
}),
)
}
}
// sourceFormInput carries the raw values of the new webhook form. A
// refused submission is shown again from it, so every value entered
// comes back, retention included.
type sourceFormInput struct {
Name string
Description string
RetentionDays string
// HTTPURL, when not empty, asks for an HTTP target with this
// destination.
HTTPURL string
// Archive asks for a database (archive) target, whose rows expire
// after ArchiveExpiry and whose files rotate by ArchiveRotation.
Archive bool
ArchiveExpiry string
ArchiveRotation string
}
// newSourceFormData builds the template data for the webhook creation
// form. It carries the retention default, which the form's help text
// names, from database.DefaultRetentionDays rather than a hardcoded
// copy of the same policy.
func newSourceFormData(
errMsg string, in sourceFormInput,
) map[string]any {
return map[string]any{
tmplKeyError: errMsg,
"Form": in,
"DefaultRetentionDays": database.DefaultRetentionDays,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
in.ArchiveExpiry,
),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
in.ArchiveRotation,
),
}
}
// HandleSourceCreateSubmit handles the webhook creation form
// submission.
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
in := sourceFormInput{
Name: r.PostFormValue("name"),
Description: r.PostFormValue("description"),
RetentionDays: r.PostFormValue("retention_days"),
HTTPURL: r.PostFormValue("http_url"),
Archive: r.PostFormValue("archive") != "",
ArchiveExpiry: r.PostFormValue("archive_expiry"),
ArchiveRotation: r.PostFormValue("archive_rotation"),
}
refuse := func(errMsg string) {
h.renderTemplateStatus(
w, r, "sources_new.html",
newSourceFormData(errMsg, in),
http.StatusBadRequest,
)
}
if in.Name == "" {
refuse("Name is required")
return
}
retentionDays, errMsg := parseRetentionDays(
in.RetentionDays, database.DefaultRetentionDays,
)
if errMsg != "" {
refuse(errMsg)
return
}
targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
refuse(errMsg)
return
}
h.createWebhookWithEntrypoint(w, r, &database.Webhook{
UserID: userID,
Name: in.Name,
Description: in.Description,
RetentionDays: retentionDays,
}, targets)
}
}
// newWebhookTargets validates the targets the new webhook form asks
// for and returns the rows to create with the webhook, or the message
// the form shows for the first one it refuses. A filled-in HTTP URL
// asks for an HTTP target named "HTTP", and the archive checkbox for a
// database target named "Archive". Each goes through newTarget, as on
// the webhook page's add target form. The rows have no WebhookID yet:
// the webhook has no ID until it is created.
func (h *Handlers) newWebhookTargets(
ctx context.Context,
in sourceFormInput,
) ([]*database.Target, string, error) {
var requested []targetFormInput
if in.HTTPURL != "" {
requested = append(requested, targetFormInput{
Name: "HTTP",
Type: database.TargetTypeHTTP,
URL: in.HTTPURL,
})
}
if in.Archive {
requested = append(requested, targetFormInput{
Name: "Archive",
Type: database.TargetTypeDatabase,
Expiry: in.ArchiveExpiry,
Rotation: in.ArchiveRotation,
})
}
targets := make([]*database.Target, 0, len(requested))
for _, form := range requested {
target, errMsg, err := h.newTarget(ctx, "", form)
if err != nil || errMsg != "" {
return nil, errMsg, err
}
targets = append(targets, target)
}
return targets, "", nil
}
// createWebhookWithEntrypoint creates a webhook, its default
// entrypoint and the given targets in a transaction.
func (h *Handlers) createWebhookWithEntrypoint(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
targets []*database.Target,
) {
err := h.commitWebhook(webhook, targets)
if err != nil {
h.serverError(w, r, "failed to create webhook", err)
return
}
err = h.dbMgr.CreateDB(webhook.ID)
if err != nil {
h.log.Error(
"failed to create webhook event database",
"webhook_id", webhook.ID, "error", err,
)
}
h.log.Info("webhook created",
"webhook_id", webhook.ID,
"name", webhook.Name, "user_id", webhook.UserID,
)
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
http.StatusSeeOther,
)
}
// commitWebhook creates a webhook, its default entrypoint and the
// given targets in a transaction. Returns an error on failure (rolls
// back).
func (h *Handlers) commitWebhook(
webhook *database.Webhook,
targets []*database.Target,
) error {
tx := h.db.DB().Begin()
if tx.Error != nil {
return tx.Error
}
err := tx.Create(webhook).Error
if err != nil {
tx.Rollback()
return err
}
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: "Default entrypoint",
Active: true,
}
err = tx.Create(entrypoint).Error
if err != nil {
tx.Rollback()
return err
}
for _, target := range targets {
target.WebhookID = webhook.ID
err = tx.Create(target).Error
if err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
+170
View File
@@ -0,0 +1,170 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleSourceDelete handles webhook deletion.
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
h.deleteWebhookResources(w, r, webhook, userID)
}
}
// The messages deleteWebhookResources logs when a file of the event
// database cannot be removed: the database file itself, or only a
// sidecar once the database file is gone.
const (
eventDBLeftMsg = "webhook deleted, but its event database file is " +
"still on disk; remove it by hand"
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
"-wal or -shm sidecar of its event database is " +
"still on disk; remove it by hand"
)
// deleteWebhookResources soft-deletes config and hard-deletes
// the per-webhook event database.
func (h *Handlers) deleteWebhookResources(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
userID string,
) {
// The configuration delete commits before the event database
// is touched. No transaction spans the main database and the
// filesystem, so one side has to go first: committing the
// configuration first means a later failure leaves an unused
// event database file on disk, while removing the event
// database first would mean a failed commit destroys the
// history of a webhook that still exists. A leftover file can
// be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook)
if err != nil {
h.serverError(w, r, "failed to delete webhook", err)
return
}
h.log.Info(
"webhook deleted",
"webhook_id", webhook.ID,
"user_id", userID,
)
// Release the delivery engine's per-webhook archiving state
// so a deleted webhook's archive writer (and any handle open
// within its debounce window) does not linger for the
// process lifetime. The archive file itself is deliberately
// left on disk; see evictArchiveWriter.
h.evictArchiveWriter(webhook.ID)
err = h.dbMgr.DeleteDB(webhook.ID)
if err != nil {
// The configuration is committed, so the webhook is gone,
// but a file of its event database is still on disk with
// nothing referencing it. Report the failure rather than
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
// When only a sidecar is left, the events are already
// gone, and the message must not suggest they survive.
msg := eventDBLeftMsg
if errors.Is(err, database.ErrSidecarNotRemoved) {
msg = sidecarLeftMsg
}
h.serverError(w, r, msg, err)
return
}
http.Redirect(
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
)
}
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
// targets and the webhook row in one transaction. Every
// statement is checked and any failure rolls the whole
// transaction back, so a caller that gets an error knows the
// configuration is untouched and the event database must be
// left alone.
func (h *Handlers) commitWebhookDeletion(
webhook *database.Webhook,
) error {
tx := h.db.DB().Begin()
if tx.Error != nil {
return tx.Error
}
err := tx.Where(
"webhook_id = ?", webhook.ID,
).Delete(&database.Entrypoint{}).Error
if err != nil {
tx.Rollback()
return err
}
err = tx.Where(
"webhook_id = ?", webhook.ID,
).Delete(&database.Target{}).Error
if err != nil {
tx.Rollback()
return err
}
err = tx.Delete(webhook).Error
if err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// evictArchiveWriter asks the delivery engine to drop the cached
// archive writers of a webhook's database targets, closing their
// archive file handles.
//
// The archive database files are NOT deleted. Unlike the event
// database — which is per-webhook working storage and is
// hard-deleted with the webhook — an archive is explicitly
// long-term storage that an operator may want to keep or move
// away for offline retention. Destroying it as a side effect of
// deleting a webhook would be a surprising and unrecoverable
// data loss, so the file is left for the operator to handle.
func (h *Handlers) evictArchiveWriter(webhookID string) {
if h.archives == nil {
return
}
h.archives.EvictWebhook(webhookID)
}
+133
View File
@@ -0,0 +1,133 @@
package handlers
import (
"net/http"
"time"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/reqtls"
)
// HandleSourceDetail shows details for a specific webhook.
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
}
}
// renderSourceDetail loads and renders a source detail page. With a
// targetErr, it is the page shown again for a refused add target
// form: it answers 400, and the form opens on targetForm's type with
// its values and the message.
func (h *Handlers) renderSourceDetail(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetForm targetFormInput,
targetErr string,
) {
var entrypoints []database.Entrypoint
h.db.DB().Where(
"webhook_id = ?", webhook.ID,
).Find(&entrypoints)
var targets []database.Target
h.db.DB().Where(
"webhook_id = ?", webhook.ID,
).Find(&targets)
entrypointViews := NewEntrypointViews(entrypoints)
var events []RecentEventView
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
return
}
events, err = loadRecentEvents(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, r, "failed to load recent events", err)
return
}
err = addEntrypointEvents(
webhookDB, &webhook, entrypointViews, time.Now(),
)
if err != nil {
h.serverError(w, r, "failed to count entrypoint events", err)
return
}
}
scheme := "http"
if reqtls.IsTLS(r) {
scheme = "https"
}
// The host is the client's Host header, unvalidated. It is
// inert only because source_detail.html renders BaseURL as
// text, inside a <code> element and in an entrypoint's delete
// prompt; putting it in an href or any other URL context
// needs it constrained first.
baseURL := scheme + "://" + r.Host
// The template calls Webhook methods, which take pointer
// receivers; html/template cannot address a value stored in a map.
data := map[string]any{
tmplKeyWebhook: &webhook,
// Targets are projected to a display-safe view: a
// target's stored config blob holds a credential, and it
// must never reach a template.
"Entrypoints": entrypointViews,
"Targets": h.targetRows(&webhook, targets),
"Events": events,
"BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
tmplKeyTargetForm: targetForm,
"TargetError": targetErr,
// The add target form's selects start on its expiry and
// rotation through Alpine, so no choice is selected here.
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
tmplKeyArchiveRotationChoices: archiveRotationChoices(),
}
status := http.StatusOK
if targetErr != "" {
status = http.StatusBadRequest
}
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
}
+245
View File
@@ -0,0 +1,245 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// HandleSourceEdit shows the form to edit a webhook.
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
h.renderWebhookEdit(
w, r, &webhook,
webhook.Name, webhook.Description,
strconv.Itoa(webhook.RetentionDays),
"", http.StatusOK,
)
}
}
// HandleSourceEditSubmit handles the webhook edit form
// submission.
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
sourceID := chi.URLParam(r, "sourceID")
h.renameMu.Lock()
defer h.renameMu.Unlock()
var webhook database.Webhook
err := h.db.DB().Where(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
h.renderError(w, r, http.StatusNotFound)
return
}
// The body size cap is enforced by the MaxBodySize
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
h.renderError(w, r, http.StatusBadRequest)
return
}
h.applyWebhookEdit(w, r, &webhook)
}
}
// applyWebhookEdit validates and saves webhook edits. A refused save
// shows the edit form again with the values submitted and the reason.
func (h *Handlers) applyWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
) {
// The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form.
name := r.PostFormValue("name")
description := r.PostFormValue("description")
retention := r.PostFormValue("retention_days")
if name == "" {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
"Name is required", http.StatusBadRequest,
)
return
}
// An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone.
retentionDays, errMsg := parseRetentionDays(
retention, webhook.RetentionDays,
)
if errMsg != "" {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
errMsg, http.StatusBadRequest,
)
return
}
// edited is the webhook as the submission leaves it; webhook stays
// as stored, for the page shown again when the save is refused.
edited := *webhook
edited.Name = name
edited.Description = description
edited.RetentionDays = retentionDays
// A new name renames the archive files before it is saved (see
// delivery.Engine.Rename). If either step fails, the same targets'
// archives go back to the name that is still stored, without
// reading the main database again.
targets, err := h.renameWebhookArchives(
webhook.ID, webhook.Name, edited.Name,
)
if err == nil {
err = h.db.DB().Save(&edited).Error
}
if err != nil {
restoreErr := h.renameArchives(targets, webhook.Name)
if restoreErr != nil {
h.log.Error(
"failed to rename archives back",
"webhook_id", webhook.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderWebhookEdit(
w, r, webhook, name, description, retention,
"Not saved: "+err.Error()+
". Move that archive out of the data directory, "+
"its .db together with any -wal and -shm beside "+
"it, then save again.",
http.StatusConflict,
)
return
}
h.serverError(w, r, "failed to update webhook", err)
return
}
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
http.StatusSeeOther,
)
}
// renderWebhookEdit renders the webhook edit page for the webhook as
// stored, its form showing name, description and retentionDays, with
// an optional error message above it.
func (h *Handlers) renderWebhookEdit(
w http.ResponseWriter,
r *http.Request,
webhook *database.Webhook,
name, description, retentionDays, errMsg string,
status int,
) {
data := map[string]any{
tmplKeyWebhook: webhook,
tmplKeyError: errMsg,
"Name": name,
"Description": description,
"RetentionDays": retentionDays,
}
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
}
// renameWebhookArchives renames the archive file of every database
// target of a webhook from the webhook name oldName to newName,
// keeping each target's own name. It does nothing when the name is
// unchanged. It returns the targets it read, so that a failed edit can
// move those same archives back with renameArchives.
func (h *Handlers) renameWebhookArchives(
webhookID, oldName, newName string,
) ([]database.Target, error) {
if h.archives == nil || oldName == newName {
return nil, nil
}
var targets []database.Target
err := h.db.DB().
Where(
"webhook_id = ? AND type = ?",
webhookID, database.TargetTypeDatabase,
).
Find(&targets).Error
if err != nil {
return nil, err
}
return targets, h.renameArchives(targets, newName)
}
// renameArchives renames the archive file of each of the given
// database targets to the webhook name webhookName, keeping each
// target's own name. It tries every target even after one fails, so
// that moving the archives back after a failed edit leaves none under
// the new name, and returns every failure joined.
func (h *Handlers) renameArchives(
targets []database.Target, webhookName string,
) error {
var errs []error
for i := range targets {
err := h.archives.Rename(
targets[i].ID, webhookName, targets[i].Name,
)
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+178
View File
@@ -0,0 +1,178 @@
package handlers
import (
"fmt"
"net/http"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// WebhookListItem holds data for the webhook list view.
type WebhookListItem struct {
database.Webhook
EntrypointCount int
InactiveEntrypointCount int
TargetCount int
InactiveTargetCount int
// EventCount is how many events the webhook holds, LastEventAt
// when the newest arrived (nil before the first), and
// FailedLast24Hours how many of its deliveries failed in the last
// 24 hours. When the webhook's event database could not be read,
// EventsUnreadable is set and these three are not known.
EventCount int64
LastEventAt *time.Time
FailedLast24Hours int64
EventsUnreadable bool
}
// HandleSourceList shows a list of user's webhooks.
func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
var webhooks []database.Webhook
err := h.db.DB().Where(
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
items, err := h.buildWebhookListItems(webhooks)
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
data := map[string]any{
"Webhooks": items,
}
h.renderTemplate(w, r, "sources_list.html", data)
}
}
// buildWebhookListItems builds the list's entry for each webhook. It
// fails when the main database cannot be read. A webhook whose event
// database cannot be read is marked on its own entry, and the error is
// logged.
func (h *Handlers) buildWebhookListItems(
webhooks []database.Webhook,
) ([]WebhookListItem, error) {
items := make([]WebhookListItem, len(webhooks))
since := time.Now().Add(-longWindow)
for i := range webhooks {
item := &items[i]
item.Webhook = webhooks[i]
var err error
item.EntrypointCount, item.InactiveEntrypointCount, err =
h.countWithInactive(&database.Entrypoint{}, item.ID)
if err != nil {
return nil, err
}
item.TargetCount, item.InactiveTargetCount, err =
h.countWithInactive(&database.Target{}, item.ID)
if err != nil {
return nil, err
}
// Opening an event database that does not exist would create
// it, and it would hold nothing to count.
if !h.dbMgr.DBExists(item.ID) {
continue
}
err = h.readListEventFigures(item, since)
if err != nil {
h.log.Error(
"failed to read webhook list figures",
"webhook_id", item.ID,
"error", err,
)
item.EventsUnreadable = true
}
}
return items, nil
}
// countWithInactive returns how many entrypoints or targets, as model
// says, a webhook has, and how many of them are inactive.
func (h *Handlers) countWithInactive(
model any, webhookID string,
) (int, int, error) {
var active []bool
err := h.db.DB().Model(model).
Where("webhook_id = ?", webhookID).
Pluck("active", &active).Error
if err != nil {
return 0, 0, fmt.Errorf(
"reading active flags of webhook %s: %w", webhookID, err,
)
}
inactive := 0
for _, a := range active {
if !a {
inactive++
}
}
return len(active), inactive, nil
}
// readListEventFigures fills in the figures the list shows from the
// webhook's event database, with the statistics pane's own queries:
// the event count and last arrival from the event totals row, and the
// deliveries that failed since the given time from the deliveries'
// status index.
func (h *Handlers) readListEventFigures(
item *WebhookListItem, since time.Time,
) error {
webhookDB, err := h.dbMgr.GetDB(item.ID)
if err != nil {
return err
}
var totals database.EventTotals
err = webhookDB.Take(&totals).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
item.EventCount = totals.Events - totals.EventsRemoved
item.LastEventAt = totals.LastEventAt
byTarget, err := finishedByTarget(webhookDB, since)
if err != nil {
return err
}
for _, f := range byTarget {
item.FailedLast24Hours += f.Failed
}
return nil
}
+6 -4
View File
@@ -131,8 +131,9 @@ const (
// //
// - Lines carrying an AUTHENTICATED operator's own input, which // - Lines carrying an AUTHENTICATED operator's own input, which
// are not truncated at all: the webhook name on "webhook // are not truncated at all: the webhook name on "webhook
// created" and the target host on "target URL blocked by SSRF // created" (internal/handlers/webhook_create.go) and the target
// protection" (both internal/handlers/source_management.go), // host on "target URL blocked by SSRF protection"
// (internal/handlers/target_create.go),
// and target_name in internal/delivery/engine.go and // and target_name in internal/delivery/engine.go and
// target_http.go. Each is bounded only by the 1 MB form body // target_http.go. Each is bounded only by the 1 MB form body
// cap, so a 100 KB name writes one line of roughly 600 KB. // cap, so a 100 KB name writes one line of roughly 600 KB.
@@ -279,8 +280,9 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// this branch as bounded as the pattern branches below. // this branch as bounded as the pattern branches below.
// //
// Nothing debuggable is lost. The only query parameters the service // Nothing debuggable is lost. The only query parameters the service
// reads are the login page's `next`, the page to return to, and // reads are the login page's `next`, the page to return to,
// `notice`, which names the line a page shows after an action. The // `notice`, which names the line a page shows after an action, and
// the event log's `show`, which picks the events it lists. The
// alternatives that would preserve more (a key count, a key // alternatives that would preserve more (a key count, a key
// allowlist) all require parsing an attacker-sized query on every // allowlist) all require parsing an attacker-sized query on every
// request, which is work an unauthenticated client would then be // request, which is work an unauthenticated client would then be
+73 -8
View File
@@ -98,20 +98,25 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
} }
// seedBrowserWebhook seeds the webhook the browser test loads, owned by // seedBrowserWebhook seeds the webhook the browser test loads, owned by
// userID: an entrypoint, two events, and a target whose delivery of the // userID: an entrypoint, two events, and a target whose delivery of the
// newer event failed once with a 502. It returns the webhook, the older // newer event failed once with a 502. The webhook's name and the newer
// and the newer event, and the target. // event's content type are each too long for one line on a phone. It
// returns the webhook, the older and the newer event, and the target.
func seedBrowserWebhook( func seedBrowserWebhook(
t *testing.T, env *testEnv, userID string, t *testing.T, env *testEnv, userID string,
) (*database.Webhook, *database.Event, *database.Event, *database.Target) { ) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
t.Helper() t.Helper()
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Model(webhook).Update(
"name", "payment_provider_production_notifications",
).Error)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create( require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{ &database.Entrypoint{
WebhookID: webhook.ID, WebhookID: webhook.ID,
@@ -126,6 +131,9 @@ func seedBrowserWebhook(
webhookDB, err := env.dbMgr.GetDB(webhook.ID) webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, webhookDB.Model(event).Update(
"content_type", "application/vnd.paymentprovider.event+json",
).Error)
require.NoError(t, webhookDB.Omit(clause.Associations).Create( require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{ &database.DeliveryResult{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -947,15 +955,15 @@ func checkEventSelection(
} }
// checkEventKeyboard loads the event log and checks that Tab from the // checkEventKeyboard loads the event log and checks that Tab from the
// page's Back link reaches the row of the newest event, the first after // page's Pending link, the last link above the list, reaches the row of
// it, and that Enter then collapses that event, which starts expanded, // the newest event, the first after it, and that Enter then collapses
// and Space expands it again. // that event, which starts expanded, and Space expands it again.
func checkEventKeyboard( func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string, ctx context.Context, t *testing.T, url, eventID string,
) { ) {
t.Helper() t.Helper()
back := `//a[contains(text(), "Back to")]` pending := `//a[starts-with(text(), "Pending")]`
expanded := `form[action$="/` + eventID + `/resubmit"]` expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string var focused string
@@ -963,12 +971,12 @@ func checkEventKeyboard(
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
loadPage(url), loadPage(url),
chromedp.Focus(back, chromedp.BySearch), chromedp.Focus(pending, chromedp.BySearch),
chromedp.KeyEvent(kb.Tab), chromedp.KeyEvent(kb.Tab),
chromedp.Evaluate(`document.activeElement.textContent`, &focused), chromedp.Evaluate(`document.activeElement.textContent`, &focused),
)) ))
require.Contains(t, focused, eventID, require.Contains(t, focused, eventID,
"Tab from the Back link does not reach the event's row") "Tab from the Pending link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter))) require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event") assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
@@ -1292,3 +1300,60 @@ func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
click(ctx, t, button) click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu") assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
} }
// scrollsSideways reports whether the page is wider than the window. A
// page's clientWidth is the window's width less its scroll bar.
const scrollsSideways = `document.documentElement.scrollWidth >
document.documentElement.clientWidth`
// cutOffElements lists each element, without elements inside it, that
// is shown but runs past the page's edge or its card's, by more than a
// pixel of rounding. A card hides what runs past its edge.
const cutOffElements = `[...document.querySelectorAll("body *")]
.filter((el) => {
const box = el.getBoundingClientRect();
const card = el.closest(".card")?.getBoundingClientRect();
const left = card ? card.left : 0;
const right = card ? card.right : document.documentElement.clientWidth;
return el.children.length === 0 && box.width > 0 &&
(box.left < left - 1 || box.right > right + 1);
})
.map((el) => el.outerHTML.slice(0, 120))`
// checkPhoneWidth loads the webhook page, url, and its event log,
// eventLog, in a phone-sized window, the event log with the attempts of
// the newest event's delivery to targetName shown. It checks that
// neither page scrolls sideways and that nothing shown on either, no
// status, time or control, is cut off at the page's or its card's edge.
func checkPhoneWidth(
ctx context.Context, t *testing.T, url, eventLog, targetName string,
) {
t.Helper()
var (
sideways bool
cutOff []string
)
measure := chromedp.Tasks{
chromedp.Evaluate(scrollsSideways, &sideways),
chromedp.Evaluate(cutOffElements, &cutOff),
}
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
measure,
))
assert.False(t, sideways, "the webhook page scrolls sideways on a phone")
assert.Empty(t, cutOff, "the webhook page cuts these off on a phone")
require.NoError(t, chromedp.Run(ctx, loadPage(eventLog)))
click(ctx, t, `//span[text()="`+targetName+`"]`)
require.True(t, shown(ctx, `//span[text()="Attempt 1"]`),
"clicking the delivery does not show its attempts")
require.NoError(t, chromedp.Run(ctx, measure))
assert.False(t, sideways, "the event log scrolls sideways on a phone")
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
}
+2 -2
View File
@@ -219,8 +219,8 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
// sentryKeepsHeader reports whether a request header is routing or // sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept // content metadata rather than client-chosen payload. Referer is kept
// on the reasoning that it is browser-set, that the only query // on the reasoning that it is browser-set, that the only query
// parameters in this service's own URLs are the login page's `next` // parameters in this service's own URLs are the login page's `next`,
// and `notice`, and that Referrer-Policy is set to // `notice` and the event log's `show`, and that Referrer-Policy is set to
// strict-origin-when-cross-origin. X-Request-Id ties the event to the // strict-origin-when-cross-origin. X-Request-Id ties the event to the
// local access log line, which holds the rest of the detail. // local access log line, which holds the rest of the detail.
func sentryKeepsHeader(name string) bool { func sentryKeepsHeader(name string) bool {
+4 -2
View File
@@ -1,6 +1,8 @@
{ {
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"eslint": "10.11.0" "eslint": "10.11.0",
} "prettier": "3.9.9"
},
"packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f"
} }
+5 -4
View File
@@ -3,8 +3,8 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# 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, node and ESLint are deliberately not # make, or go). golangci-lint, node, ESLint and prettier are deliberately
# installed: linting runs only in docker, via script/lint. # not installed: they run only in docker, via script/lint and script/fmt.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -60,9 +60,10 @@ main() {
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# Not installed here: docker is platform-specific and out of scope for a # Not installed here: docker is platform-specific and out of scope for a
# package-manager bootstrap, but script/lint and script/css need it. # package-manager bootstrap, but script/lint, script/fmt and script/css
# need it.
if missing docker; then if missing docker; then
echo "bootstrap: docker not found; script/lint and script/css require it" >&2 echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2
fi fi
go mod download go mod download
+4 -4
View File
@@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs the checks # script/cibuild: run the CI build. The Dockerfile runs the checks (the
# (make fmt-check, lint, test), so a successful build implies a green # gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown
# repo. Generic: needs no adaptation. The Gitea workflow runs this on # check, make test), so a successful build implies a green repo. Generic:
# push. # needs no adaptation. The Gitea workflow runs this on push.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+4 -1
View File
@@ -1,5 +1,7 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes). # script/fmt: format all files (writes): the Go code with gofmt and
# goimports, the Markdown with prettier. prettier is never installed
# locally: it runs in docker, in the Dockerfile's Markdown stages.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -10,6 +12,7 @@ main() {
if command -v goimports >/dev/null 2>&1; then if command -v goimports >/dev/null 2>&1; then
goimports -w . goimports -w .
fi fi
docker build --target markdown-output --output type=local,dest=. .
} }
main "$@" main "$@"
+1
View File
@@ -12,6 +12,7 @@ main() {
gofmt -s -l . gofmt -s -l .
exit 1 exit 1
fi fi
docker build --target markdown-check --output type=cacheonly .
} }
main "$@" main "$@"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
an event's own page. Spans only, since a button may hold no div. --> an event's own page. Spans only, since a button may hold no div. -->
<span class="flex flex-1 flex-wrap items-center justify-between gap-3"> <span class="flex flex-1 flex-wrap items-center justify-between gap-3">
<span class="flex flex-wrap items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span> <span class="text-sm text-gray-700 wrap-anywhere">{{.Target.DisplayName}}</span>
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}} {{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span> <span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
</span> </span>
+9
View File
@@ -50,6 +50,15 @@
</dl> </dl>
</div> </div>
<div class="card mt-6">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Request</h2>
</div>
<div class="p-4">
{{template "event_request" .}}
</div>
</div>
<div class="card mt-6"> <div class="card mt-6">
<div class="p-4 border-b border-gray-200"> <div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Body</h2> <h2 class="text-lg font-medium text-gray-900">Body</h2>
+22
View File
@@ -0,0 +1,22 @@
{{define "event_request"}}
<!-- The entrypoint an event arrived at and its request headers, as
handlers.EventLogView carries them: the same in the event log and
the event's own page. The entrypoint's URL is never shown. A
resubmitted copy, even a copy of a copy, did not arrive at an
entrypoint; the request it copies did. -->
<div class="space-y-2 text-xs">
{{if .ResubmittedFrom}}
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
{{else}}
<p class="text-gray-500">Arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
{{end}}
{{if .HeadersCut}}
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
{{else if .Headers}}
<p class="text-gray-500">Request headers</p>
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Headers}}</pre>
{{else}}
<p class="text-gray-500">No request headers.</p>
{{end}}
</div>
{{end}}
+8 -5
View File
@@ -6,15 +6,18 @@
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the <!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
event log, the navbar and the footer, so an entrypoint URL fits on event log, the navbar and the footer, so an entrypoint URL fits on
one line. An inline style, because the committed tailwind.css has one line. An inline style, because the committed tailwind.css has
no class this wide. --> no class this wide. wrap-anywhere goes only on names and
descriptions: a row too wide for a phone must still run past the
edge, where the browser test sees it, rather than break its
controls mid-word. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem"> <div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="btn-small">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<div> <div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900 wrap-anywhere">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}} {{if .Webhook.Description}}
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p> <p class="text-sm text-gray-500 mt-1 wrap-anywhere">{{.Webhook.Description}}</p>
{{end}} {{end}}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -60,7 +63,7 @@
{{range .Entrypoints}} {{range .Entrypoints}}
<div class="p-4" x-data="collapsible"> <div class="p-4" x-data="collapsible">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span x-show="closed" class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span> <span x-show="closed" class="text-sm font-medium text-gray-900 wrap-anywhere">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
<!-- Edit shows this form in place of the <!-- Edit shows this form in place of the
description and hides until it closes, and description and hides until it closes, and
Cancel resets what was typed. With Cancel resets what was typed. With
@@ -249,7 +252,7 @@
{{range .Targets}} {{range .Targets}}
<div class="p-4"> <div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span> <span class="text-sm font-medium text-gray-900 wrap-anywhere">{{.Name}}</span>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span> <span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
{{if .Active}} {{if .Active}}
+24 -7
View File
@@ -3,12 +3,24 @@
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}} {{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}} {{define "content"}}
<!-- wrap-anywhere goes only on names, IDs and content types: a row too
wide for a phone must still run past the edge, where the browser
test sees it, rather than break its statuses, times or controls
mid-word. So a target's name in an event's row, which shares its
element with the delivery's status, goes without. -->
<div class="max-w-6xl mx-auto px-6 py-8"> <div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small wrap-anywhere">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1> <h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span> <!-- Under a filter, this counts the events the filter lists. -->
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span>
</div>
<!-- Plain links, so they work without the page's script library. The current one is marked. -->
<div class="mt-3 flex flex-wrap gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small{{if eq .Show ""}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show ""}} aria-current="page"{{end}}>All</a>
<a href="/hook/{{.Webhook.ID}}/events?show=failed" title="Events with at least one failed delivery" class="btn-small{{if eq .Show "failed"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "failed"}} aria-current="page"{{end}}>Failed ({{.FailedEvents}})</a>
<a href="/hook/{{.Webhook.ID}}/events?show=pending" title="Events with a delivery still pending or retrying" class="btn-small{{if eq .Show "pending"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "pending"}} aria-current="page"{{end}}>Pending ({{.PendingEvents}})</a>
</div> </div>
</div> </div>
@@ -21,8 +33,8 @@
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle"> <div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<span class="flex flex-wrap items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span> <span class="text-sm font-mono text-gray-700 wrap-anywhere">{{.ID}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span> <span class="text-sm text-gray-500 wrap-anywhere">{{.ContentType}}</span>
{{if .ResubmittedFrom}} {{if .ResubmittedFrom}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span> <span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}} {{end}}
@@ -47,7 +59,7 @@
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md"> <div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a>.{{end}} {{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono wrap-anywhere">{{.ResubmittedFromID}}</a>.{{end}}
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}} {{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
</div> </div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
@@ -55,6 +67,9 @@
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button> <button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form> </form>
</div> </div>
<div class="mb-3">
{{template "event_request" .}}
</div>
{{template "event_body" .Body}} {{template "event_body" .Body}}
{{if .Deliveries}} {{if .Deliveries}}
@@ -73,6 +88,8 @@
{{if and .Status.Terminal (not .Target.Deleted)}} {{if and .Status.Terminal (not .Target.Deleted)}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<!-- The list to return to. -->
<input type="hidden" name="show" value="{{$.Show}}">
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button> <button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
</form> </form>
{{end}} {{end}}
@@ -89,7 +106,7 @@
</div> </div>
</div> </div>
{{else}} {{else}}
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div> <div class="p-12 text-center text-sm text-gray-500">{{if eq $.Show "failed"}}No event has a failed delivery.{{else if eq $.Show "pending"}}No event has a delivery pending or retrying.{{else}}No events recorded yet.{{end}}</div>
{{end}} {{end}}
</div> </div>
</div> </div>
+606 -411
View File
File diff suppressed because it is too large Load Diff