1 Commits
Author SHA1 Message Date
sneak 69587febfc Show an event's entrypoint and request headers (closes #389)
check / check (push) Successful in 3m26s
An expanded event in the event log and the event's own page now show
the entrypoint the event arrived at (its description, "Entrypoint"
when it has none, or "deleted entrypoint"; never its URL), or for a
resubmitted copy the one the request it copies arrived at, and the
request headers as one block of text, one line per value, sorted by
name, whitespace kept. The event log leaves out headers that hold
more than the body's 32 KiB limit, stored or as text, and links to the
event's page, which shows them all. Both pages draw them through one
shared template, event_request.html, and read the webhook's
entrypoints once per page.

Model: opus-5-5
2026-10-03 02:59:46 +00:00
59 changed files with 5608 additions and 6271 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 and prettier; a host copy would overwrite # The js-deps stage installs ESLint; a host copy would overwrite it at the
# them at the `COPY . .` of the stages built on it. # js-lint stage's `COPY . .`.
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 the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build) - name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, ESLint, 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, prettier and their dependencies, installed from yarn.lock # ESLint and its dependencies, installed from yarn.lock
node_modules/ node_modules/
# IDE specific files # IDE specific files
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
-3
View File
@@ -1,3 +0,0 @@
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
# ESLint and prettier from node_modules/.bin.
nodeLinker: node-modules
+16 -42
View File
@@ -4,6 +4,8 @@
# 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
@@ -17,14 +19,12 @@ RUN go mod download
# .dockerignore. # .dockerignore.
COPY . . COPY . .
# Run the Go formatting check and the linter. gofmt and golangci-lint are # Run formatting check and linter. golangci-lint is invoked directly rather
# invoked directly rather than through `make fmt-check` and `make lint`: this # than through `make lint`: this stage is already the pinned linter image, and
# stage is already the pinned linter image, and both scripts build docker # script/lint is a wrapper that builds Dockerfile.lint, so calling it here
# stages, so calling them here would need a docker daemon inside the build. # would need a docker daemon inside the build. Keep these steps in step with
# The Markdown half of `make fmt-check` is the markdown-check stage below. # Dockerfile.lint, including --network=none (see its header for why).
# Keep the golangci-lint steps in step with Dockerfile.lint, including RUN make fmt-check
# --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,56 +66,30 @@ 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
# prettier for the Markdown stages below, and stays cached until package.json, # stays cached until those two files change. script/lint forces only js-lint
# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run, # to re-run, and the build stage below runs it too. COPY . . brings in the CI
# and the build stage below runs it too. COPY . . brings in the CI cache # cache barrier described in the lint stage above.
# barrier described in the lint stage above. # node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
#
# 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 .yarnrc.yml ./ COPY package.json yarn.lock ./
RUN corepack enable yarn && yarn install --immutable --mode=skip-build RUN yarn install --frozen-lockfile --ignore-scripts
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, JavaScript lint and Markdown check # Depend on the lint, stylesheet check and JavaScript lint stages passing
# 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.
+2302 -2086
View File
File diff suppressed because it is too large Load Diff
+349 -313
View File
@@ -2,367 +2,403 @@
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 mergeable to `next` is the branch for the next milestone and must stay green and
`main` without notice. One `next` -> `main` PR accumulates the milestone; mergeable to `main` without notice. One `next` -> `main` PR accumulates
releases are cut from `main` separately. the milestone; releases are cut from `main` separately.
Issue branches do NOT touch this file — the manager maintains it on `next`. Issue branches do NOT touch this file — the manager maintains it on
Every branch editing `TODO.md` conflicts with every other (#112). `next`. Every branch editing `TODO.md` conflicts with every other
(#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 play from. authoritative list, and the only place to read a count or a state of
This file records where the project is, not what is in flight: a sentence whose play from. This file records where the project is, not what is in
truth depends on a branch being unmerged is wrong the moment it merges, and this flight: a sentence whose truth depends on a branch being unmerged is
file has been wrong that way before. wrong the moment it merges, and this file has been wrong that way
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`). Every SQLite (https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
handle opens with WAL journaling and a busy timeout, a bookkeeping write that Every SQLite handle opens with WAL journaling and a busy timeout, a
fails leaves its delivery in a recoverable state rather than a lying one, and bookkeeping write that fails leaves its delivery in a recoverable
recovery skips a delivery that already has a successful result row. Final state rather than a lying one, and recovery skips a delivery that
pre-tag verification exercised it and confirmed it holds. Whatever the milestone already has a successful result row. Final pre-tag verification
still shows open is what remains before `v1.0.0`. exercised it and confirmed it holds. Whatever the milestone still
shows open is what remains before `v1.0.0`.
Delivery is at-least-once by design, not by accident: a send whose result row Delivery is at-least-once by design, not by accident: a send whose
does not land is attempted again, so a receiver can see a duplicate. That is result row does not land is attempted again, so a receiver can see a
deliberate — the alternative is a silent lost delivery — and the README says so duplicate. That is deliberate — the alternative is a silent lost
under Rationale. It is not a defect to re-file. delivery — and the README says so under Rationale. It is not a defect
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`. Merging (https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`.
`next` into `main` is a separate act from tagging and waits on neither of those: Merging `next` into `main` is a separate act from tagging and waits on
`next` is kept mergeable at all times, which is the point of the branch. neither of those: `next` is kept mergeable at all times, which is the
point of the branch.
# Completed Steps # Completed Steps
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS` - 2026-08-24 Bind the plaintext HTTP listener deliberately, via
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A `BIND_ADDRESS` defaulting to `127.0.0.1`, and document the
hostname, an empty value or a value carrying a port is a startup error, and reverse-proxy deployment. A hostname, an empty value or a value
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is carrying a port is a startup error, and the `Dockerfile` sets
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit `0.0.0.0` because a loopback bind inside a container is unreachable
removed the shutdown race: `httpServer` is built in the constructor rather (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
than assigned from the serving goroutine, which orders the write before every removed the shutdown race: `httpServer` is built in the constructor
fx hook and rules out the nil dereference a SIGTERM arriving first would have rather than assigned from the serving goroutine, which orders the
caused, and `sentryEnabled` is an `atomic.Bool` write before every fx hook and rules out the nil dereference a
(https://git.eeqj.de/sneak/webhooker/issues/226) SIGTERM arriving first would have caused, and `sentryEnabled` is an
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID `atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226)
is the authentication secret, so the per-entrypoint shared secret, the - 2026-08-24 Remove inbound request signature verification. The
`internal/signature` package, the receiver check, the model fields and the entrypoint UUID is the authentication secret, so the per-entrypoint
forms are all gone. This reverses the feature that landed earlier in the same shared secret, the `internal/signature` package, the receiver check,
milestone (https://git.eeqj.de/sneak/webhooker/issues/67, the model fields and the forms are all gone. This reverses the
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 the UI - 2026-08-24 Stamp the build version into the binary and render it in
footer. `script/version` is the single source — `$VERSION`, else the UI footer. `script/version` is the single source — `$VERSION`,
`git describe --tags --always --dirty`, else `unknown` — so a `make build` else `git describe --tags --always --dirty`, else `unknown` — so a
binary and a `make docker` image from one checkout report the same thing, and `make build` binary and a `make docker` image from one checkout
nothing in it varies between two builds of the same commit, which the release report the same thing, and nothing in it varies between two builds
gate's byte-identical assertion would catch of the same commit, which the release gate's byte-identical
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 request - 2026-08-24 Derive cookie `Secure` and CSRF strictness from the
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a
with the environment left at its `dev` default, the session cookie silently real TLS proxy with the environment left at its `dev` default, the
lost `Secure` while the CSRF cookie on the same response kept it. session cookie silently lost `Secure` while the CSRF cookie on the
`X-Forwarded-Proto` is now matched case-insensitively on its first same response kept it. `X-Forwarded-Proto` is now matched
comma-separated element, so `HTTPS` and `https, http` no longer fall to the case-insensitively on its first comma-separated element, so `HTTPS`
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269) and `https, http` no longer fall to the relaxed CSRF path
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A (https://git.eeqj.de/sneak/webhooker/issues/269)
failing delete committed whatever had already succeeded, hard-deleted the - 2026-08-24 Roll back a failed webhook deletion instead of committing
per-webhook event database anyway, and redirected as though it had worked — it. A failing delete committed whatever had already succeeded,
orphaned config plus permanently destroyed history, reported as success. All hard-deleted the per-webhook event database anyway, and redirected as
three delete positions now roll back with the event database intact though it had worked — orphaned config plus permanently destroyed
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 delivery `(deleted)`, rather than leaving the event log unable to say where a
went. A deleted target's credentials stay masked exactly as a live one's, and delivery went. A deleted target's credentials stay masked exactly as
it cannot become deliverable again through the receiver, resubmit, replay, the a live one's, and it cannot become deliverable again through the
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211) receiver, resubmit, replay, the edit form or the toggle
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the (https://git.eeqj.de/sneak/webhooker/issues/211)
unauthenticated receiver is no longer a memory-exhaustion vector: `handler` - 2026-08-24 Bound both request-controlled `/metrics` label dimensions,
carries the chi route pattern, and `method` folds anything chi cannot route so the unauthenticated receiver is no longer a memory-exhaustion
onto a single `(unmatched)` sentinel. Both were reproduced before the fix — vector: `handler` carries the chi route pattern, and `method` folds
300 random method tokens took the series count from 106 to 7,631, and path anything chi cannot route onto a single `(unmatched)` sentinel. Both
flooding reached 62,532 — and a label audit across a live scrape found no were reproduced before the fix — 300 random method tokens took the
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254, series count from 106 to 7,631, and path flooding reached 62,532 —
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` and `-5` - 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7`
silently became 0 — fire-and-forget — including on the edit path, where it and `-5` silently became 0 — fire-and-forget — including on the edit
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20 path, where it destroyed a working value, and `999999999` stored
is the `max` both templates already declared verbatim. The ceiling of 20 is the `max` both templates already
(https://git.eeqj.de/sneak/webhooker/issues/221) declared (https://git.eeqj.de/sneak/webhooker/issues/221)
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend - 2026-08-24 Resubmit a stored event as a new undelivered event, so a
under development can be tested against real captured traffic. Per-delivery backend under development can be tested against real captured
replay cannot serve that: it re-sends one finished delivery to its own traffic. Per-delivery replay cannot serve that: it re-sends one
original target, and a target created for a dev backend has no prior delivery finished delivery to its own original target, and a target created
to replay. Resubmit re-injects the stored event at the top of the receiver for a dev backend has no prior delivery to replay. Resubmit
path and fans it out to whatever targets are active now re-injects the stored event at the top of the receiver path and fans
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 instances - 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two
on one directory cannot both deliver instances 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 `OnStart` hook - 2026-08-20 Shut down the app when the HTTP listener fails. The
returned as soon as the serving goroutine was spawned, so a failed listen left `OnStart` hook returned as soon as the serving goroutine was
fx reporting RUNNING and a live process with nothing bound — invisible to spawned, so a failed listen left fx reporting RUNNING and a live
systemd and Docker restart policies process with nothing bound — invisible to systemd and Docker restart
(https://git.eeqj.de/sneak/webhooker/issues/200) policies (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 with databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL
placeholders rather than bound values with placeholders rather than bound values
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205) half-set metrics auth credentials
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own (https://git.eeqj.de/sneak/webhooker/issues/205)
trace recorder in for the logging adapter, and that recorder does not - 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps
implement `gorm.ParamsFilter`, so those statements logged their bound values GORM's own trace recorder in for the logging adapter, and that
interpolated and bypassed the suppression above. The two units gated green recorder does not implement `gorm.ParamsFilter`, so those statements
against a `next` that lacked the other, and `next` went red when both landed logged their bound values interpolated and bypassed the suppression
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 (https://git.eeqj.de/sneak/webhooker/issues/203) terminally failed delivery
(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 backup, (https://git.eeqj.de/sneak/webhooker/issues/209) and document the
restore and upgrade procedures backup, 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 banner. The - 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap
admin bootstrap password was printed once among roughly 45 fx lines, and under banner. The admin bootstrap password was printed once among roughly
`docker run -d` went to container logs subject to rotation; there was no reset 45 fx lines, and under `docker run -d` went to container logs subject
path at all, so recovery meant hand-deleting the users row, documented to rotation; there was no reset path at all, so recovery meant
nowhere. The password is read from stdin or generated, never from argv where hand-deleting the users row, documented nowhere. The password is read
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208) from stdin or generated, never from argv where `/proc` would publish
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the it (https://git.eeqj.de/sneak/webhooker/issues/208)
SSRF guard, so a self-hosted proxy can forward into the operator's own - 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch
network. The guard's always-blocked set cannot be reopened by configuration for the SSRF guard, so a self-hosted proxy can forward into the
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 unsafely - 2026-08-20 Harden operator-set target headers, which were carried
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233) unsafely across a redirect
(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, matching - 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s,
the org-wide backstop. `go test` applies `-timeout` per package, and matching the org-wide backstop. `go test` applies `-timeout` per
`internal/handlers` had grown past the old budget: a cache-defeated build package, and `internal/handlers` had grown past the old budget: a
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load cache-defeated build failed outright at `GOMAXPROCS=4`, and every run
breached 30s. The measurement table lives in the script (#194) under deliberate host load breached 30s. The measurement table lives
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale in the script (#194)
and still mandated a 20s test target with a 30s timeout, which the org - 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy
replaced with a 60s cap and a 90s backstop. A synced copy is not a source; was stale and still mandated a 20s test target with a 30s timeout,
reading it as one nearly produced a PR against `prompts` proposing a change which the org replaced with a 60s cap and a 90s backstop. A synced
already merged there (#196) copy is not a source; reading it as one nearly produced a PR against
- 2026-08-18 Report handler panics through the logger and answer 500. chi `prompts` proposing a change already merged there (#196)
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits, - 2026-08-18 Report handler panics through the logger and answer 500.
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no
writing a byte: the recovery never ran, the client got a dropped connection longer emits, then indexes `pkg[-1:]`, so it panicked inside its own
instead of a 500, and the original panic was lost. A local middleware replaces stack printer before writing a byte: the recovery never ran, the
it, bounded by `MaxPanicLogLineBytes` (#187) client got a dropped connection instead of a 500, and the original
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open` panic was lost. A local middleware replaces it, bounded by
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError` `MaxPanicLogLineBytes` (#187)
false, so **every record-not-found printed the fully interpolated SQL to - 2026-08-18 Route GORM's logger through `slog` and bound it. Every
stdout** — including the client-chosen path on `/webhook/{uuid}` and the `gorm.Open` left `logger.Default` in place at `Warn` with
submitted username on the login form, at no level the operator set and outside `IgnoreRecordNotFoundError` false, so **every record-not-found
`internal/logger` entirely. Three call sites, not the two the issue named printed the fully interpolated SQL to stdout** — including the
(#178) client-chosen path on `/webhook/{uuid}` and the submitted username on
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites the login form, at no level the operator set and outside
reachable unauthenticated, found by reading every `slog` call in the tree `internal/logger` entirely. Three call sites, not the two the issue
rather than only the one reported; the budget moved to a shared named (#178)
`internal/logfield` so no second truncation exists. `DEBUG` being off by - 2026-08-18 Bound every `slog` line against client-chosen text. Eight
default is not a bound and is not treated as one (#176) sites reachable unauthenticated, found by reading every `slog` call in
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A the tree rather than only the one reported; the budget moved to a
non-fatal `assert` on an acquire result was dereferenced on the next line, so shared `internal/logfield` so no second truncation exists. `DEBUG`
one timing miss killed the whole `internal/middleware` binary and reddened CI being off by default is not a bound and is not treated as one (#176)
for unrelated PRs. The fix also removed a real production race — `acquire` - 2026-08-18 Stop a slow host turning a login-guard test into a
could shed a request with a slot standing free, because Go picks uniformly segfault. A non-fatal `assert` on an acquire result was dereferenced
among ready `select` cases (#186) on the next line, so one timing miss killed the whole
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path. `internal/middleware` binary and reddened CI for unrelated PRs. The
The receiver's path carries the entrypoint capability token, so every Sentry fix also removed a real production race — `acquire` could shed a
event from `/webhook/{uuid}` shipped a live credential to a third party. request with a slot standing free, because Go picks uniformly among
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers ready `select` cases (#186)
reduced to an allowlist (#179) - 2026-08-18 Send the chi route pattern to Sentry rather than the
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the concrete path. The receiver's path carries the entrypoint capability
query string, so a login could be driven by URL parameters — putting the token, so every Sentry event from `/webhook/{uuid}` shipped a live
password somewhere that lands in access logs, proxy logs and browser history credential to a third party. Request `Data`, `QueryString`, `Cookies`
(#160) and `Env` are dropped and headers reduced to an allowlist (#179)
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a - 2026-08-18 Read form fields from the POST body only. `r.FormValue`
flood of wrong passwords cannot lock out the account it is guessing at. The merges the query string, so a login could be driven by URL parameters
manager took this decision rather than stall the queue; it is flagged on the — putting the password somewhere that lands in access logs, proxy
issue for reversal (#150) logs and browser history (#160)
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was - 2026-08-18 Verify login credentials before spending rate-limit
wrong in both directions from version skew and shared caches. `script/lint` budget, so a flood of wrong passwords cannot lock out the account it
asserts the summary line, because `--no-cache-filter` silently ignores a stage is guessing at. The manager took this decision rather than stall the
name it does not match — the flag that makes the gate meaningful fails open queue; it is flagged on the issue for reversal (#150)
(#109) - 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint
- 2026-08-18 Serve an event's full stored body over HTTP. The list query was wrong in both directions from version skew and shared caches.
truncates for rendering, and that truncated value was the only way to read a `script/lint` asserts the summary line, because `--no-cache-filter`
body, so the full payload was unreachable (#157) silently ignores a stage name it does not match — the flag that makes
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 handler's JSON `internal/logfield` budgets by *encoded* bytes, not runes, so a
escaping cannot multiply a field past its allowance (#146) handler's JSON escaping cannot multiply a field past its allowance
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A (#146)
skipped run rolls up green, so a commit that was never tested reported success - 2026-08-18 Mark superseded CI commits `failure` rather than
(#152) `skipped`. A skipped run rolls up green, so a commit that was never
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown tested reported success (#152)
hooks are bounded by a deadline the orchestrator will actually honour rather - 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so
than being killed mid-flush (#134) shutdown hooks are bounded by a deadline the orchestrator will
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out actually honour rather than being killed mid-flush (#134)
2^64 addresses, so per-address keying let one client mint unlimited buckets. - 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation
Manager decision, recorded on the issue (#125) hands out 2^64 addresses, so per-address keying let one client mint
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies, unlimited buckets. Manager decision, recorded on the issue (#125)
including claims about behaviour the code does not have (#151) - 2026-08-17 Correct release-blocking README and startup-warning
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 the `static/vendor.sha256` instead of committing the minified blob, so
dependency is pinned by hash rather than by trust (#145) the dependency is pinned by hash rather than by trust (#145)
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a - 2026-08-17 Bound the event log's rendered bodies in the query itself,
large stored payload cannot be read into memory just to be truncated for so a large stored payload cannot be read into memory just to be
display (#135) truncated for display (#135)
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a - 2026-08-17 Mask the `http` target's destination URL in the UI: it can
bearer credential in its path or query, and was rendered verbatim. Manager carry a bearer credential in its path or query, and was rendered
decision to mask unconditionally (#115) verbatim. Manager decision to mask unconditionally (#115)
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs - 2026-08-14 Bound shutdown hooks by their stop context, so a hook that
cannot hold the process past its grace period (#102) hangs cannot hold the process past its grace period (#102)
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so - 2026-08-14 Render templates via a buffer rather than the
a template error part-way through cannot commit a 200 and then fail — the `ResponseWriter`, so a template error part-way through cannot commit
response is written only once it is whole (#123) a 200 and then fail — the response is written only once it is whole
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The (#123)
codec accepted cookies the session layer considered expired, so the cap was - 2026-08-14 Align the session codec's max-age with the 7-day absolute
enforced in one place and not the other (#108) cap. The codec accepted cookies the session layer considered expired,
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe so the cap was enforced in one place and not the other (#108)
default silently discards forwarded headers and every client rate-limits as - 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where
the proxy's address (#149) the safe default silently discards forwarded headers and every client
- 2026-08-12 Bound the receiver rate limit per client IP across the whole rate-limits as the proxy's address (#149)
`/webhook/*` route. The existing limiter keyed on the request path and - 2026-08-12 Bound the receiver rate limit per client IP across the
`/webhook/{uuid}` matches any single segment, so a client that invented a whole `/webhook/*` route. The existing limiter keyed on the request
fresh path per request minted a fresh bucket per request: the limit on the path and `/webhook/{uuid}` matches any single segment, so a client
only unauthenticated endpoint bounded nothing in aggregate, and every request that invented a fresh path per request minted a fresh bucket per
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the request: the limit on the only unauthenticated endpoint bounded
client address alone now bounds that, chained in front of the unchanged nothing in aggregate, and every request still cost an entrypoint
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 README - 2026-08-12 Correct release-blocking documentation inaccuracies: the
promised manual redelivery in the present tense in three places when nothing README promised manual redelivery in the present tense in three
implements it (the same false claim also sat in the doc comment that was its places when nothing implements it (the same false claim also sat in
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md` the doc comment that was its source text), the env table omitted
itself omitted five landed units (#141) `RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now units (#141)
writes a build-context fingerprint before calling `script/cibuild`, so a code - 2026-08-12 Make the CI gate execute the checks it reports on. The
commit invalidates the `COPY` layer of the lint and builder stages while a workflow now writes a build-context fingerprint before calling
docs-only commit still replays from cache; a superseding run also rewrites the `script/cibuild`, so a code commit invalidates the `COPY` layer of
`failure` status Gitea leaves on commits it cancelled and never tested. the lint and builder stages while a docs-only commit still replays
Verified by pushing a deliberately broken test and watching CI go red (#119) from cache; a superseding run also rewrites the `failure` status
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value Gitea leaves on commits it cancelled and never tested. Verified by
reached `time.NewTicker` in both the retention reaper and the archive sweeper, pushing a deliberately broken test and watching CI go red (#119)
panicking two goroutines with no recover after startup had already reported - 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
success (#140) non-positive value reached `time.NewTicker` in both the retention
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the reaper and the archive sweeper, panicking two goroutines with no
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and recover after startup had already reported success (#140)
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request - 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
on the unauthenticated receiver. Semantics proven unchanged by differential cap: the reverse walk cuts entries with `strings.LastIndexByte`
testing against the previous implementation (#133) instead of joining and splitting, so a 1 MB header allocates 16 bytes
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 key attacker-supplied chain cannot burn unbounded CPU in the rate-limit
function; running off the end falls back to the peer address (#124) key function; running off the end falls back to the peer address
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list: (#124)
all three rate limiters key on the connection's own address unless the direct - 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to list: all three rate limiters key on the connection's own address
left for the first non-proxy hop. Default trusts nothing, and a unless the direct peer is a configured proxy, in which case
set-but-unparseable value aborts startup. Before this, any client could mint a `X-Forwarded-For` is walked right to left for the first non-proxy hop.
fresh bucket or drain another's by rotating a spoofed header (#88) Default trusts nothing, and a set-but-unparseable value aborts
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile startup. Before this, any client could mint a fresh bucket or drain
settings placeholder removed, a progressive-enhancement copy button for the another's by rotating a spoofed header (#88)
entrypoint URL, and retention form copy that states the actual policy - 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
(deletion by the reaper, 0 retains forever) (#57) Profile settings placeholder removed, a progressive-enhancement copy
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds button for the entrypoint URL, and retention form copy that states the
the request URL in `*url.Error`, so every transport failure persisted the full actual policy (deletion by the reaper, 0 retains forever) (#57)
Slack webhook URL into the per-webhook event database via - 2026-08-11 Mask the webhook credential in delivery errors and logs:
`DeliveryResult.Error`, a field a future REST API would have served. Go embeds the request URL in `*url.Error`, so every transport failure
`maskURLError` drops path, query and userinfo while preserving the wrapped persisted the full Slack webhook URL into the per-webhook event
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout database via `DeliveryResult.Error`, a field a future REST API would
failures still read differently (#118) have served. `maskURLError` drops path, query and userinfo while
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 entrypoint (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
path so one entrypoint cannot exhaust another's budget; over-limit requests entrypoint path so one entrypoint cannot exhaust another's budget;
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing over-limit requests get 429 with `Retry-After`. It was the one
endpoint with no limit at all (#64) unauthenticated, internet-facing 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 an `MaxBodySize` is now first in all four form-parsing route groups, so
oversized request is rejected with 413 instead of being read in full by the an oversized request is rejected with 413 instead of being read in
CSRF middleware before any cap applied (#90) full by the CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which rendered the - 2026-08-11 Mask target config on the source detail page, which
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer rendered the stored blob verbatim and so exposed the Slack
credential that cannot be revoked per-holder. Config reaches the template only incoming-webhook URL — a bearer credential that cannot be revoked
as a `TargetView` of labelled fields, and header values are rendered as a per-holder. Config reaches the template only as a `TargetView` of
count (#113) labelled fields, and header values are rendered as a count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel - 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
written in `BeforeSave` so the GORM column default cannot win the race. Also sentinel written in `BeforeSave` so the GORM column default cannot
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed win the race. Also bounds the reaper's cutoff arithmetic: day counts
`time.Duration` and wrapped the cutoff into the future, where every row above 106751 overflowed `time.Duration` and wrapped the cutoff into
matched and the sweep deleted everything (#79) the future, where every row matched and the sweep deleted everything
(#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 requests, (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
with the 7-day absolute cap kept as an independent backstop that activity requests, with the 7-day absolute cap kept as an independent
never extends (#66) backstop that activity 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 retries, orphaned `retrying` delivery whose target type no longer supports
recording a `DeliveryResult` with the reason instead of leaving the delivery retries, recording a `DeliveryResult` with the reason instead of
stuck forever (#82) leaving the delivery stuck forever (#82)
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's - 2026-08-09 Root the delivery engine's worker pool and the retention
sweep loop at `context.Background()` rather than the fx `OnStart` hook context reaper's sweep loop at `context.Background()` rather than the fx
(#97), which carries fx's 15s start timeout and killed both roughly fifteen `OnStart` hook context (#97), which carries fx's 15s start timeout and
seconds after boot: the proxy silently stopped delivering webhooks entirely, killed both roughly fifteen seconds after boot: the proxy silently
and the reaper never ran a single sweep under its default one-hour interval stopped delivering webhooks entirely, and the reaper never ran a
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last single sweep under its default one-hour interval
`database` target) evicts the cached archive writer and closes its handle - 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
while deliberately leaving `archive-{webhookID}.db` on disk, and a new last `database` target) evicts the cached archive writer and closes
`ArchiveSweeper` prunes idle archives on the existing its handle while deliberately leaving `archive-{webhookID}.db` on
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` plus a environment values: `envInt` removed in favour of `envPositiveInt`
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and plus a `PORT` range check, `envBool` now parses with
defaults apply only to unset variables (#80) `strconv.ParseBool`, and defaults apply only to unset variables (#80)
- 2026-08-07 Automatic event retention cleanup based on `retention_days`, - 2026-08-07 Automatic event retention cleanup based on
deleting expired events, deliveries, and delivery results from each `retention_days`, deleting expired events, deliveries, and delivery
per-webhook event database (#63) results from each 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`), adopt the `Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
canonical `.golangci.yml` (v2 `linters.settings` layout so adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
surfaced lint findings all newly surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
shims, README Entrypoints section Makefile 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 plain - 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
HTTP and behind reverse proxies (#54) plain 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 with DNS - 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
rebinding defense, and per-IP login rate limiting (#42) with DNS 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); absolute dev - 2026-03-17 Dockerfile absolute paths and static linking (#49);
DATA_DIR default and clarified env docs (#46) absolute dev DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on login, request - 2026-03-05 security headers middleware, session regeneration on
body size limits (#41) login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed - 2026-03-04 tests for delivery, middleware, and session packages
the build-architecture global (#31) (#32); removed the build-architecture global (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery - 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
engine with bounded worker pool and circuit breaker, parallel fan-out, delivery engine with bounded worker pool and circuit breaker,
per-webhook event databases, management UI (#16) parallel fan-out, per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README - 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
(#6) into README (#6)
# Future Steps # Future Steps
- Delivery status and retry management UI. Replay of a terminally failed - Delivery status and retry management UI. Replay of a terminally
delivery and per-attempt detail already landed failed 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 plus - Per-webhook rate limiting in the receiver handler (per-webhook config
handler enforcement; global limits must not apply to receiver endpoints) plus handler enforcement; global limits must not apply to receiver
- API key authentication for programmatic access (APIKey model exists; Bearer endpoints)
token middleware does not) - API key authentication for programmatic access (APIKey model exists;
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
@@ -370,9 +406,9 @@ Clear the rest of the open 1.0.0 milestone
- 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 authenticated - Password reset flow for a forgotten password over the web. The
password _change_ flow already landed, and a lost password is recoverable from authenticated password *change* flow already landed, and a lost
the console with `webhooker resetpw` password is recoverable from 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,62 +149,6 @@ 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
+7 -8
View File
@@ -177,17 +177,16 @@ func httpConfigFields(t *database.Target) []ConfigField {
} }
// maxRetriesField describes a target's retry count, which lives // maxRetriesField describes a target's retry count, which lives
// on the target row rather than in its configuration blob. A // on the target row rather than in its configuration blob.
// stored 0 makes a single attempt, so it is shown as 1.
func maxRetriesField(t *database.Target) ConfigField { func maxRetriesField(t *database.Target) ConfigField {
attempts := strconv.Itoa(t.MaxRetries) retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 { if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)" retries += " (fire-and-forget)"
} }
return ConfigField{ return ConfigField{
Label: "Delivery attempts", Label: "Max Retries",
Value: attempts, Value: retries,
} }
} }
@@ -214,10 +213,10 @@ func databaseConfigFields(configJSON string) []ConfigField {
} }
return []ConfigField{{ return []ConfigField{{
Label: "Archive expiry", Label: "Archive Expiry",
Value: value, Value: value,
}, { }, {
Label: "Archive rotation", Label: "Archive Rotation",
Value: rotation, Value: rotation,
}} }}
} }
+6 -6
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxRetries = "Delivery attempts" viewMaxRetries = "Max Retries"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -190,7 +190,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", viewMaxRetries: "0 (fire-and-forget)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -258,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", viewMaxRetries: "0 (fire-and-forget)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -329,8 +329,8 @@ func TestNewTargetViews_Database(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Archive expiry": tc.want, "Archive Expiry": tc.want,
"Archive rotation": rotationNone, "Archive Rotation": rotationNone,
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -365,7 +365,7 @@ func TestNewTargetViews_DatabaseRotation(t *testing.T) {
}) })
assert.Equal( assert.Equal(
t, want, fieldMap(view.Config)["Archive rotation"], t, want, fieldMap(view.Config)["Archive Rotation"],
) )
}) })
} }
+1 -1
View File
@@ -45,7 +45,7 @@ func expiryShown(
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
return matched( return matched(
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(), `Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
) )
} }
+2 -2
View File
@@ -27,7 +27,7 @@ func rotationShown(
t.Helper() t.Helper()
return matched( return matched(
`Archive rotation:</span>\s*<span>([^<]*)</span>`, `Archive Rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID), renderedPage(t, env, webhookID),
) )
} }
@@ -211,7 +211,7 @@ func TestArchiveFileView_Rotated(t *testing.T) {
assert.Equal(t, "2.0 kB", view.Size) assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID)) page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files") assert.Contains(t, page, "Archive Size: 2.0 kB in 2 files")
} }
// renderedPage returns the webhook page. // renderedPage returns the webhook page.
+1 -1
View File
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
))) )))
h.renderLoginError( h.renderLoginError(
w, r, w, r,
"Too many failed sign-in attempts. Please try again later.", "Too many failed login attempts. Please try again later.",
http.StatusTooManyRequests, http.StatusTooManyRequests,
) )
} }
+4 -14
View File
@@ -329,25 +329,15 @@ 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. A // log it was triggered from, carrying the outcome as its notice.
// 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,
) { ) {
location := withNotice("/hook/"+webhook.ID+"/events", code) http.Redirect(
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
show := r.PostFormValue(showParam) http.StatusSeeOther,
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,7 +1,6 @@
package handlers_test package handlers_test
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -472,66 +471,6 @@ 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
@@ -1,169 +0,0 @@
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,
)
}
-620
View File
@@ -1,620 +0,0 @@
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
}
+3 -4
View File
@@ -17,15 +17,14 @@ import (
// arrivedAt is how a page names the entrypoint an event arrived at. // arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string { func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900 wrap-anywhere">` + name + return `Arrived at <span class="text-gray-900">` + name + `</span>`
`</span>`
} }
// copiedRequestArrivedAt is how a page names, for a resubmitted copy, // copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// the entrypoint the request it copies arrived at. // the entrypoint the request it copies arrived at.
func copiedRequestArrivedAt(name string) string { func copiedRequestArrivedAt(name string) string {
return `The request it copies arrived at ` + return `The request it copies arrived at <span class="text-gray-900">` +
`<span class="text-gray-900 wrap-anywhere">` + name + `</span>` name + `</span>`
} }
// headerBox is how a page shows an event's request header lines: as // headerBox is how a page shows an event's request header lines: as
+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, nil, w, newRequestForTest(), webhook, nil,
) )
return views return views
+1 -1
View File
@@ -96,7 +96,7 @@ func noticeFor(r *http.Request) *notice {
}, },
resubmitNoTargets: { resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " + Text: "Resubmitted: a new event was created, but this " +
"webhook has no active targets, so nothing was queued.", "source has no active targets, so nothing was queued.",
}, },
}[noticeCode(r.URL.Query().Get(noticeParam))] }[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok { if !ok {
+1 -1
View File
@@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
data map[string]any data map[string]any
title string title string
}{ }{
{"login.html", map[string]any{}, "Sign in - Webhooker"}, {"login.html", map[string]any{}, "Login - Webhooker"},
{"profile.html", map[string]any{}, "Profile - Webhooker"}, {"profile.html", map[string]any{}, "Profile - Webhooker"},
{"settings.html", map[string]any{}, "Settings - Webhooker"}, {"settings.html", map[string]any{}, "Settings - Webhooker"},
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"}, {"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
-230
View File
@@ -1,230 +0,0 @@
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)
}
+2 -7
View File
@@ -233,13 +233,8 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "1 configured") assert.Contains(t, body, "1 configured")
assert.NotContains(t, body, "sekrit") assert.NotContains(t, body, "sekrit")
// The database type is called an archive: on its badge, in the assert.Contains(t, body, "Archive Expiry")
// add target form's type list and in its settings. assert.Contains(t, body, "30 days")
list := targetList(t, body)
assert.Contains(t, list, "t-database archive Active")
assert.Contains(t, list, "Archive expiry: 30 days")
assert.Contains(t, list, "Archive rotation: none")
assert.Contains(t, body, `<option value="database">Archive</option>`)
// An unknown type gets the neutral placeholder, never the // An unknown type gets the neutral placeholder, never the
// stored blob. // stored blob.
-147
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -187,152 +186,6 @@ 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
@@ -1,384 +0,0 @@
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
@@ -1,31 +0,0 @@
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)
}
+1 -25
View File
@@ -418,30 +418,6 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
assert.Contains(t, page, "original-name") assert.Contains(t, page, "original-name")
} }
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
// edit page of a database target gives its type and its settings to
// the ones its badge and the add target form use.
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
w := serveTarget(
env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil,
)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, "Type: archive.")
assert.Contains(t, page, `class="label">Archive expiry</label>`)
assert.Contains(t, page, `class="label">Archive rotation</label>`)
}
// TestHandleTargetEditSubmit_Rejects covers every submission that // TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage. // must not reach storage.
// //
@@ -612,7 +588,7 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
{ {
database.TargetTypeSlack, database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25", "name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid delivery attempts", "Invalid max retries",
}, },
{ {
database.TargetTypeDatabase, database.TargetTypeDatabase,
+4 -4
View File
@@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
path := delivery.ArchivePath(dbMgr, wh, archive) path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive file:")) assert.Equal(t, 1, strings.Count(body, "Archive File:"))
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive size:") assert.NotContains(t, body, "Archive Size:")
seedArchive(t, path, 1, 100) seedArchive(t, path, 1, 100)
@@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet") assert.NotContains(t, body, "not created yet")
assert.Regexp(t, assert.Regexp(t,
`Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body, `Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
) )
assert.Contains(t, body, assert.Contains(t, body,
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`, `title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
@@ -69,7 +69,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
body = renderSourceDetailPage(t, h, sess, wh.ID) body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive size:") assert.NotContains(t, body, "Archive Size:")
} }
// targetList returns the text of the targets section in a rendered // targetList returns the text of the targets section in a rendered
+5 -6
View File
@@ -81,18 +81,17 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+ assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
"Deliveries paused: after repeated failures, until "+cooldownEnds+ "Deliveries Paused: after repeated failures, until "+cooldownEnds+
", then one waiting delivery is sent to test the target while "+ ", then one waiting delivery is sent to test the target while "+
"the others wait at least one more cooldown", list) "the others wait at least one more cooldown", list)
assert.Equal(t, 1, strings.Count(list, "Deliveries paused")) assert.Equal(t, 1, strings.Count(list, "Paused"))
log := renderSourceLogsPage(t, h, sess, wh.ID) log := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Equal(t, 2, strings.Count(log, "t-http: waiting")) assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
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)
// No delivery shows as retrying; the Pending link's title says it. assert.NotContains(t, log, "retrying")
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)
@@ -106,7 +105,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+ assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
"Deliveries paused: held while one delivery tests whether the "+ "Deliveries Paused: held while one delivery tests whether the "+
"target has recovered") "target has recovered")
// Not the whole list: the add target form above the rows says UTC. // Not the whole list: the add target form above the rows says UTC.
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC") assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
@@ -116,7 +115,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
breakers.Set(target.ID, delivery.CircuitClosed, 0) breakers.Set(target.ID, delivery.CircuitClosed, 0)
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.NotContains(t, list, "Deliveries paused") assert.NotContains(t, list, "Paused")
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff) assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
} }
+3 -3
View File
@@ -25,14 +25,14 @@ var (
// errRetriesInvalid signals a max_retries form value that is not // errRetriesInvalid signals a max_retries form value that is not
// a non-negative whole number. // a non-negative whole number.
errRetriesInvalid = errors.New( errRetriesInvalid = errors.New(
"must be a whole number", "retries must be a whole number of attempts",
) )
// errRetriesTooLarge signals a max_retries form value that is a // errRetriesTooLarge signals a max_retries form value that is a
// whole number but above maxTargetRetries. It is distinguished // whole number but above maxTargetRetries. It is distinguished
// from errRetriesInvalid so the message can name the ceiling // from errRetriesInvalid so the message can name the ceiling
// instead of implying the input was not a number. // instead of implying the input was not a number.
errRetriesTooLarge = errors.New("out of range") errRetriesTooLarge = errors.New("retries out of range")
) )
// parseMaxRetries interprets a max_retries form value. // parseMaxRetries interprets a max_retries form value.
@@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string {
if errors.Is(err, errRetriesTooLarge) { if errors.Is(err, errRetriesTooLarge) {
return errRetriesTooLarge.Error() + return errRetriesTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetRetries) + ": at most " + strconv.Itoa(maxTargetRetries) +
" attempts" " retries"
} }
return errRetriesInvalid.Error() + return errRetriesInvalid.Error() +
-35
View File
@@ -1,35 +0,0 @@
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,
)
}
+7 -32
View File
@@ -354,14 +354,15 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
// and target_http gives up once the attempt number reaches // and target_http gives up once the attempt number reaches
// max_retries), and 0 is special-cased to a single fire-and-forget // max_retries), and 0 is special-cased to a single fire-and-forget
// attempt with no circuit breaker. // attempt with no circuit breaker.
const maxRetriesHelp = "How many times each delivery is attempted in " + const maxRetriesHelp = "This is the total number of delivery attempts, " +
"all, the first attempt included. 0 means a single attempt with no " + "not retries on top of the first: a value of 3 makes three attempts " +
"retries and no circuit breaker." "in all. 0 means a single attempt with no retries and no circuit " +
"breaker."
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries // TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
// label, "Delivery attempts", and help text on both the create form // help text on both the create form (the add-target form on the webhook
// (the add-target form on the webhook detail page) and the edit form, // detail page) and the edit form, so the copy cannot drift back to
// so the copy cannot drift back to calling the number a retry count. // calling the number a retry count.
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
t.Parallel() t.Parallel()
@@ -392,7 +393,6 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
}, },
) )
assert.Contains(t, createBody, "Delivery attempts:</label>")
assert.Contains( assert.Contains(
t, createBody, maxRetriesHelp, t, createBody, maxRetriesHelp,
"the add-target form must explain max_retries as total attempts", "the add-target form must explain max_retries as total attempts",
@@ -419,33 +419,8 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
}, },
) )
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
assert.Contains( assert.Contains(
t, editBody, maxRetriesHelp, t, editBody, maxRetriesHelp,
"the target edit form must explain max_retries as total attempts", "the target edit form must explain max_retries as total attempts",
) )
} }
// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new
// webhook page gives the database target its Archive checkbox creates,
// and that target's settings, to the ones the target forms use.
func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
dataKeyError: "",
})
assert.Contains(t, body, "created with an archive target")
assert.Contains(t, body, `class="label">Archive expiry</label>`)
assert.Contains(t, body, `class="label">Archive rotation</label>`)
}
-263
View File
@@ -1,263 +0,0 @@
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
@@ -1,170 +0,0 @@
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
@@ -1,133 +0,0 @@
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
@@ -1,245 +0,0 @@
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
@@ -1,178 +0,0 @@
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
}
+4 -6
View File
@@ -131,9 +131,8 @@ 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" (internal/handlers/webhook_create.go) and the target // created" and the target host on "target URL blocked by SSRF
// host on "target URL blocked by SSRF protection" // protection" (both internal/handlers/source_management.go),
// (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.
@@ -280,9 +279,8 @@ 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, // reads are the login page's `next`, the page to return to, and
// `notice`, which names the line a page shows after an action, and // `notice`, which names the line a page shows after an action. The
// 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
+21 -90
View File
@@ -98,25 +98,20 @@ 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. The webhook's name and the newer // newer event failed once with a 502. It returns the webhook, the older
// event's content type are each too long for one line on a phone. It // and the newer event, and the target.
// 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,
@@ -131,9 +126,6 @@ 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,
@@ -326,35 +318,31 @@ const (
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) { func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
t.Helper() t.Helper()
// Each target type, with the badge its targets are listed with and // Each target type, with the fields its add target form submits, in
// the fields its add target form submits, in page order. Only http // page order. Only http and slack have a url field.
// and slack have a url field.
targetTypes := []struct { targetTypes := []struct {
name string name string
badge string
fields string fields string
values map[string]string values map[string]string
}{ }{
{ {
"http", "http", "http", "csrf_token name type url headers timeout max_retries",
"csrf_token name type url headers timeout max_retries",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
"slack", "slack", "csrf_token name type url max_retries", "slack", "csrf_token name type url max_retries",
map[string]string{"url": publicTargetURL}, map[string]string{"url": publicTargetURL},
}, },
{ {
"database", "archive", "csrf_token name type expiry rotation", "database", "csrf_token name type expiry rotation",
map[string]string{"expiry": "720h", "rotation": "daily"}, map[string]string{"expiry": "720h", "rotation": "daily"},
}, },
{"log", "log", "csrf_token name type", nil}, {"log", "csrf_token name type", nil},
} }
for _, tt := range targetTypes { for _, tt := range targetTypes {
checkAddTarget( checkAddTarget(
ctx, t, url, tt.name, tt.badge, ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values,
strings.Fields(tt.fields), tt.values,
) )
} }
} }
@@ -365,11 +353,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
// type's own fields in place of the choice, and the form then submits // type's own fields in place of the choice, and the form then submits
// exactly fields, so a field another type uses, such as url, is absent; // exactly fields, so a field another type uses, such as url, is absent;
// Cancel closes it again. It then adds a target of the type, filling in // Cancel closes it again. It then adds a target of the type, filling in
// values, and checks that the section lists it with badge. // values, and checks that the section lists it with that type.
func checkAddTarget( func checkAddTarget(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
url, targetType, badge string, url, targetType string,
fields []string, fields []string,
values map[string]string, values map[string]string,
) { ) {
@@ -424,8 +412,8 @@ func checkAddTarget(
click(ctx, t, saveButton) click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+ assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+badge+`"]`), `"]/following-sibling::div/span[text()="`+targetType+`"]`),
"%s: the added target is not listed as %s", targetType, badge) "%s: the added target is not listed with its type", targetType)
} }
// chooseTargetType clicks Add, picks targetType and clicks Next, and // chooseTargetType clicks Add, picks targetType and clicks Next, and
@@ -478,10 +466,10 @@ func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
assert.Equal(t, "none", startRotation, assert.Equal(t, "none", startRotation,
"the add target form's archive rotation does not start on none") "the add target form's archive rotation does not start on none")
assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+ assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+
`/following-sibling::span[text()="30 days"]`), `/following-sibling::span[text()="30 days"]`),
"a database target added with 720h is not listed as 30 days") "a database target added with 720h is not listed as 30 days")
assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+ assert.True(t, shown(ctx, row+`//span[text()="Archive Rotation:"]`+
`/following-sibling::span[text()="daily"]`), `/following-sibling::span[text()="daily"]`),
"a database target added with daily is not listed as daily") "a database target added with daily is not listed as daily")
@@ -955,15 +943,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 Pending link, the last link above the list, reaches the row of // page's Back link reaches the row of the newest event, the first after
// the newest event, the first after it, and that Enter then collapses // it, and that Enter then collapses that event, which starts expanded,
// that event, which starts expanded, and Space expands it again. // 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()
pending := `//a[starts-with(text(), "Pending")]` back := `//a[contains(text(), "Back to")]`
expanded := `form[action$="/` + eventID + `/resubmit"]` expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string var focused string
@@ -971,12 +959,12 @@ func checkEventKeyboard(
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
loadPage(url), loadPage(url),
chromedp.Focus(pending, chromedp.BySearch), chromedp.Focus(back, 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 Pending link does not reach the event's row") "Tab from the Back 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")
@@ -1300,60 +1288,3 @@ 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")
}
+3 -3
View File
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
env.requireNotice( env.requireNotice(
t, env.post(path, csrfForm(token), cookies), t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets", logsPath, "resubmit-no-targets",
"this webhook has no active targets", cookies, "this source has no active targets", cookies,
) )
} }
@@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
env.requireNotice( env.requireNotice(
t, env.post(path, csrfForm(token), cookies), t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets", logsPath, "resubmit-no-targets",
"this webhook has no active targets", cookies, "this source has no active targets", cookies,
) )
} }
@@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
csrfForm(token), cookies, csrfForm(token), cookies,
), ),
intrudersLogs, "resubmit-no-targets", intrudersLogs, "resubmit-no-targets",
"this webhook has no active targets", cookies, "this source has no active targets", cookies,
) )
} }
+1 -21
View File
@@ -914,26 +914,6 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil) env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
} }
// TestSignInAndSignOutWording pins one wording for both: the sign-in
// page's button reads "Sign in" and the navbar's buttons "Sign out", as
// the sign-in page's heading, the error page's link and the notice
// after signing out do.
func TestSignInAndSignOutWording(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
assert.Contains(
t, env.get("/pages/login", nil).Body.String(), ">Sign in</button>",
)
userID, _ := env.seedUser(t, "reader", "somepassword")
page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String()
assert.Equal(t, 2, strings.Count(page, ">Sign out</button>"),
"the desktop and the mobile navbar each say Sign out")
}
// --- /user/{username} group --- // --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -1383,7 +1363,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
) )
env.requireNotice( env.requireNotice(
t, w, logsPath, "resubmit-no-targets", t, w, logsPath, "resubmit-no-targets",
"this webhook has no active targets", cookies, "this source has no active targets", cookies,
) )
webhookDB, err := env.dbMgr.GetDB(wh.ID) webhookDB, err := env.dbMgr.GetDB(wh.ID)
+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`
// `notice` and the event log's `show`, and that Referrer-Policy is set to // and `notice`, 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 {
+2 -4
View File
@@ -1,8 +1,6 @@
{ {
"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"
} }
+4 -5
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, ESLint and prettier are deliberately # make, or go). golangci-lint, node and ESLint are deliberately not
# not installed: they run only in docker, via script/lint and script/fmt. # installed: linting runs only in docker, via script/lint.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -60,10 +60,9 @@ 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, script/fmt and script/css # package-manager bootstrap, but script/lint and script/css need it.
# need it.
if missing docker; then if missing docker; then
echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2 echo "bootstrap: docker not found; script/lint 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 (the # script/cibuild: run the CI build. The Dockerfile runs the checks
# gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown # (make fmt-check, lint, test), so a successful build implies a green
# check, make test), so a successful build implies a green repo. Generic: # repo. Generic: needs no adaptation. The Gitea workflow runs this on
# needs no adaptation. The Gitea workflow runs this on push. # push.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+1 -4
View File
@@ -1,7 +1,5 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes): the Go code with gofmt and # script/fmt: format all files (writes).
# 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)"
@@ -12,7 +10,6 @@ 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,7 +12,6 @@ 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 wrap-anywhere">{{.Target.DisplayName}}</span> <span class="text-sm text-gray-700">{{.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>
+2 -2
View File
@@ -6,9 +6,9 @@
entrypoint; the request it copies did. --> entrypoint; the request it copies did. -->
<div class="space-y-2 text-xs"> <div class="space-y-2 text-xs">
{{if .ResubmittedFrom}} {{if .ResubmittedFrom}}
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p> <p class="text-gray-500">The request it copies arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
{{else}} {{else}}
<p class="text-gray-500">Arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p> <p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
{{end}} {{end}}
{{if .HeadersCut}} {{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> <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>
+2 -2
View File
@@ -1,6 +1,6 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}Sign in - Webhooker{{end}} {{define "title"}}Login - Webhooker{{end}}
{{define "content"}} {{define "content"}}
<div class="min-h-screen flex items-center justify-center py-12 px-4"> <div class="min-h-screen flex items-center justify-center py-12 px-4">
@@ -52,7 +52,7 @@
> >
</div> </div>
<button type="submit" class="btn-primary w-full py-3">Sign in</button> <button type="submit" class="btn-primary w-full py-3">Sign In</button>
</form> </form>
</div> </div>
</div> </div>
+2 -2
View File
@@ -32,7 +32,7 @@
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-secondary">Sign out</button> <button type="submit" class="btn-secondary">Logout</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
@@ -49,7 +49,7 @@
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-secondary w-full">Sign out</button> <button type="submit" class="btn-secondary w-full">Logout</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
+15 -18
View File
@@ -6,18 +6,15 @@
<!-- 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. wrap-anywhere goes only on names and no class this wide. -->
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 wrap-anywhere">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}} {{if .Webhook.Description}}
<p class="text-sm text-gray-500 mt-1 wrap-anywhere">{{.Webhook.Description}}</p> <p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
{{end}} {{end}}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -63,7 +60,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 wrap-anywhere">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span> <span x-show="closed" class="text-sm font-medium text-gray-900">{{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
@@ -162,7 +159,7 @@
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1"> <select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
<option value="http">HTTP</option> <option value="http">HTTP</option>
<option value="slack">Slack</option> <option value="slack">Slack</option>
<option value="database">Archive</option> <option value="database">Database</option>
<option value="log">Log</option> <option value="log">Log</option>
</select> </select>
<button type="button" @click="next" class="btn-primary text-sm">Next</button> <button type="button" @click="next" class="btn-primary text-sm">Next</button>
@@ -185,10 +182,10 @@
</div> </div>
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label> <label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -201,10 +198,10 @@
</div> </div>
<div> <div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Delivery attempts:</label> <label class="text-sm text-gray-700">Max retries:</label>
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24"> <input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
</div> </div>
</template> </template>
@@ -252,9 +249,9 @@
{{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 wrap-anywhere">{{.Name}}</span> <span class="text-sm font-medium text-gray-900">{{.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">{{.Type}}</span>
{{if .Active}} {{if .Active}}
<span class="badge-success">Active</span> <span class="badge-success">Active</span>
{{else}} {{else}}
@@ -278,7 +275,7 @@
</div> </div>
{{with .Paused}} {{with .Paused}}
<div class="text-xs text-yellow-600 mt-1"> <div class="text-xs text-yellow-600 mt-1">
<span class="font-medium">Deliveries paused:</span> <span class="font-medium">Deliveries Paused:</span>
<span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span> <span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span>
</div> </div>
{{end}} {{end}}
@@ -290,17 +287,17 @@
{{end}} {{end}}
{{with .Archive}} {{with .Archive}}
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive file:</span> <span class="font-medium text-gray-700">Archive File:</span>
<span class="break-all">{{.Name}}</span> <span class="break-all">{{.Name}}</span>
{{with .Note}}<span>({{.}})</span>{{end}} {{with .Note}}<span>({{.}})</span>{{end}}
</div> </div>
{{if .Files}} {{if .Files}}
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive size:</span> <span class="font-medium text-gray-700">Archive Size:</span>
<span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span> <span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span>
</div> </div>
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Last written:</span> <span class="font-medium text-gray-700">Last Written:</span>
<span title="{{.WrittenUTC}}">{{.Written}}</span> <span title="{{.WrittenUTC}}">{{.Written}}</span>
</div> </div>
{{end}} {{end}}
+7 -21
View File
@@ -3,24 +3,12 @@
{{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 wrap-anywhere">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex justify-between items-center 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>
<!-- 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}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span>
<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>
@@ -33,8 +21,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 wrap-anywhere">{{.ID}}</span> <span class="text-sm font-mono text-gray-700">{{.ID}}</span>
<span class="text-sm text-gray-500 wrap-anywhere">{{.ContentType}}</span> <span class="text-sm text-gray-500">{{.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}}
@@ -59,7 +47,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 wrap-anywhere">{{.ResubmittedFromID}}</a>.{{end}} {{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.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">
@@ -88,8 +76,6 @@
{{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}}
@@ -106,7 +92,7 @@
</div> </div>
</div> </div>
{{else}} {{else}}
<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> <div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
{{end}} {{end}}
</div> </div>
</div> </div>
+2 -2
View File
@@ -46,9 +46,9 @@
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4"> <input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
Archive Archive
</label> </label>
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with an archive target that keeps a copy of every event.</p> <p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with a database target that keeps a copy of every event.</p>
<div x-show="open" x-cloak class="mt-3"> <div x-show="open" x-cloak class="mt-3">
<label for="archive_expiry" class="label">Archive expiry</label> <label for="archive_expiry" class="label">Archive pruning</label>
<select id="archive_expiry" name="archive_expiry" class="input"> <select id="archive_expiry" name="archive_expiry" class="input">
{{range .ArchiveExpiryChoices}} {{range .ArchiveExpiryChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
+5 -5
View File
@@ -7,7 +7,7 @@
<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">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
<p class="text-sm text-gray-500 mt-1">Type: {{if eq .Target.Type "database"}}archive{{else}}{{.Target.Type}}{{end}}. A target's type cannot be changed; create a new target to deliver a different way.</p> <p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
</div> </div>
<div class="card p-6"> <div class="card p-6">
@@ -59,7 +59,7 @@
{{if eq .Target.Type "database"}} {{if eq .Target.Type "database"}}
<div class="form-group"> <div class="form-group">
<label for="expiry" class="label">Archive expiry</label> <label for="expiry" class="label">Archive Expiry</label>
<select id="expiry" name="expiry" class="input"> <select id="expiry" name="expiry" class="input">
{{range .ArchiveExpiryChoices}} {{range .ArchiveExpiryChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
@@ -69,7 +69,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="rotation" class="label">Archive rotation</label> <label for="rotation" class="label">Archive Rotation</label>
<select id="rotation" name="rotation" class="input"> <select id="rotation" name="rotation" class="input">
{{range .ArchiveRotationChoices}} {{range .ArchiveRotationChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option> <option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
@@ -81,9 +81,9 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}} {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<div class="form-group"> <div class="form-group">
<label for="max_retries" class="label">Delivery attempts</label> <label for="max_retries" class="label">Max retries</label>
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input"> <input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p> <p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
</div> </div>
{{end}} {{end}}
+411 -606
View File
File diff suppressed because it is too large Load Diff