1 Commits
Author SHA1 Message Date
sneak f71d3a01a9 Split source_management.go along its CRUD seams (closes #274)
check / check (push) Successful in 3m29s
Pure code movement. Every declaration of
internal/handlers/source_management.go moves unchanged into one of:
webhook_list.go, webhook_create.go, webhook_detail.go, webhook_edit.go
and webhook_delete.go for the webhook pages; event_log.go for the event
log; entrypoint.go for the entrypoint handlers; target_create.go,
target_delete.go and target_toggle.go for the target handlers; and
shared.go for the helpers several of them use. Only each file's package
line and imports are new. The README and a middleware comment that
named the removed file now name the new ones.

Model: opus-5-5
2026-10-03 04:04:43 +00:00
50 changed files with 3361 additions and 3916 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.
+2484 -2269
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
+1 -3
View File
@@ -30,10 +30,8 @@ type Event struct {
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"` EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data. RawQuery is the receiving request's query string // Request data
// as sent, without the leading "?".
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
RawQuery string `gorm:"type:text" json:"rawQuery"`
Headers string `gorm:"type:text" json:"headers"` // JSON Headers string `gorm:"type:text" json:"headers"` // JSON
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
-3
View File
@@ -110,7 +110,6 @@ type Task struct {
MaxRetries int MaxRetries int
Method string Method string
RawQuery string
Headers string Headers string
ContentType string ContentType string
Body *string Body *string
@@ -1753,7 +1752,6 @@ func buildEventFromTask(task *Task) database.Event {
event := database.Event{ event := database.Event{
EntrypointID: task.EntrypointID, EntrypointID: task.EntrypointID,
Method: task.Method, Method: task.Method,
RawQuery: task.RawQuery,
Headers: task.Headers, Headers: task.Headers,
ContentType: task.ContentType, ContentType: task.ContentType,
} }
@@ -2104,7 +2102,6 @@ func buildRecoveryTask(
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: target.MaxRetries, MaxRetries: target.MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: bodyPtr, Body: bodyPtr,
@@ -673,11 +673,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
t, s.WebhookDB, s.WebhookID, targetID, 3, t, s.WebhookDB, s.WebhookID, targetID, 3,
) )
// A recovered delivery still carries its event's query string.
require.NoError(t, s.WebhookDB.Model(&database.Event{}).
Where("webhook_id = ?", s.WebhookID).
Update("raw_query", eventQuery).Error)
s.Engine.ExportRecoverPendingDeliveries( s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, context.Background(), s.WebhookDB,
s.WebhookID, s.WebhookID,
@@ -692,8 +687,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
database.TargetTypeLog, database.TargetTypeLog,
task.TargetType, task.TargetType,
) )
assert.Equal(t, eventQuery, task.RawQuery)
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatalf("expected task %d", i) t.Fatalf("expected task %d", i)
} }
-6
View File
@@ -11,7 +11,6 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -1991,10 +1990,6 @@ func assertLogLineComplete(
"log line must contain the full request headers", "log line must contain the full request headers",
) )
assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery),
"log line must contain the query string",
)
assert.Contains(t, out, event.EntrypointID, assert.Contains(t, out, event.EntrypointID,
"log line must contain the entrypoint id", "log line must contain the entrypoint id",
) )
@@ -2017,7 +2012,6 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
event := seedEvent( event := seedEvent(
t, db, `{"log-body-marker":"abc123"}`, t, db, `{"log-body-marker":"abc123"}`,
) )
event.RawQuery = eventQuery
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, uuid.New().String(), t, db, event.ID, uuid.New().String(),
+3 -7
View File
@@ -39,9 +39,6 @@ type TargetConfigForm struct {
// Timeout is the HTTP target's per-request timeout in seconds, // Timeout is the HTTP target's per-request timeout in seconds,
// empty when unset. // empty when unset.
Timeout string Timeout string
// ForwardQuery is the HTTP target's setting that passes each
// event's query string on to it.
ForwardQuery bool
// Expiry is the database (archive) target's row expiry. // Expiry is the database (archive) target's row expiry.
Expiry string Expiry string
// Rotation is the database (archive) target's rotation. // Rotation is the database (archive) target's rotation.
@@ -67,10 +64,9 @@ func NewTargetConfigForm(
} }
return TargetConfigForm{ return TargetConfigForm{
URL: cfg.URL, URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers), Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout), Timeout: FormatTargetTimeout(cfg.Timeout),
ForwardQuery: cfg.ForwardQuery,
}, nil }, nil
case database.TargetTypeSlack: case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config) cfg, err := parseSlackConfig(t.Config)
-7
View File
@@ -171,13 +171,6 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
if cfg.ForwardQuery {
fields = append(fields, ConfigField{
Label: "Query string",
Value: "passed on to this target",
})
}
fields = append(fields, maxRetriesField(t)) fields = append(fields, maxRetriesField(t))
return fields return fields
+1 -3
View File
@@ -223,8 +223,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
Type: database.TargetTypeHTTP, Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `",` + Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"},` + `"headers":{"Authorization":"Bearer sekrit"}}`,
`"forwardQuery":true}`,
MaxRetries: 5, MaxRetries: 5,
}) })
@@ -236,7 +235,6 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
"Query string": "passed on to this target",
viewMaxRetries: "5", viewMaxRetries: "5",
}, },
fields, fields,
-1
View File
@@ -184,7 +184,6 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID, EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method, Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers, Headers: d.Event.Headers,
Body: d.Event.Body, Body: d.Event.Body,
ContentType: d.Event.ContentType, ContentType: d.Event.ContentType,
@@ -101,7 +101,6 @@ type archivedEvent struct {
WebhookID string WebhookID string
EntrypointID string EntrypointID string
Method string Method string
RawQuery string
Headers string Headers string
Body string Body string
ContentType string ContentType string
@@ -360,7 +360,6 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error {
"webhook_id": ev.WebhookID, "webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID, "entrypoint_id": ev.EntrypointID,
"method": ev.Method, "method": ev.Method,
"raw_query": ev.RawQuery,
"headers": ev.Headers, "headers": ev.Headers,
"body": ev.Body, "body": ev.Body,
"content_type": ev.ContentType, "content_type": ev.ContentType,
@@ -166,7 +166,6 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
WebhookID: exportWebhookID, WebhookID: exportWebhookID,
EntrypointID: "ep-1", EntrypointID: "ep-1",
Method: "POST", Method: "POST",
RawQuery: eventQuery,
Headers: `{"X-Test":["yes"]}`, Headers: `{"X-Test":["yes"]}`,
Body: body, Body: body,
ContentType: testContentType, ContentType: testContentType,
@@ -216,13 +215,12 @@ func assertExportedRow(
assert.Equal(t, row.WebhookID, ev["webhook_id"]) assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"]) assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"]) assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.RawQuery, ev["raw_query"])
assert.Equal(t, row.Headers, ev["headers"]) assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"]) assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody { if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"]) assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev) assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
return return
} }
@@ -231,7 +229,7 @@ func assertExportedRow(
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, binaryBody, string(body)) assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"]) assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev) assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev)
} }
// TestArchiveExport_Empty proves an archive with nothing in it exports // TestArchiveExport_Empty proves an archive with nothing in it exports
@@ -85,7 +85,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`) event := seedEvent(t, webhookDB, `{"archived":true}`)
event.RawQuery = eventQuery
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
@@ -114,7 +113,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.ID, rows[0].EventID)
assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.WebhookID, rows[0].WebhookID)
assert.Equal(t, event.Method, rows[0].Method) assert.Equal(t, event.Method, rows[0].Method)
assert.Equal(t, eventQuery, rows[0].RawQuery)
assert.JSONEq(t, `{"archived":true}`, rows[0].Body) assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
} }
-23
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"sort" "sort"
"sync" "sync"
"time" "time"
@@ -33,11 +32,6 @@ type HTTPTargetConfig struct {
URL string `json:"url"` URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"` Headers map[string]string `json:"headers,omitempty"`
Timeout int `json:"timeout,omitempty"` Timeout int `json:"timeout,omitempty"`
// ForwardQuery passes each event's query string on to the target,
// appended to URL. Off, the target URL is sent exactly as
// configured.
ForwardQuery bool `json:"forwardQuery,omitempty"`
} }
// httpCore holds the retry, backoff, and circuit-breaker // httpCore holds the retry, backoff, and circuit-breaker
@@ -450,10 +444,6 @@ func (t *httpTarget) doHTTPRequest(
) )
} }
if cfg.ForwardQuery {
appendQuery(req.URL, event.RawQuery)
}
originScoped := applyRequestHeaders( originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(), req, event, cfg, t.eng.userAgent(),
) )
@@ -484,19 +474,6 @@ func (t *httpTarget) doHTTPRequest(
return resp.StatusCode, string(body), dur, nil return resp.StatusCode, string(body), dur, nil
} }
// appendQuery adds an event's query string to a delivery's URL, joined
// with "&" to any query string the target URL already has.
func appendQuery(u *url.URL, rawQuery string) {
switch {
case rawQuery == "":
return
case u.RawQuery == "":
u.RawQuery = rawQuery
default:
u.RawQuery += "&" + rawQuery
}
}
// clientForRequest returns the client for one delivery attempt. // clientForRequest returns the client for one delivery attempt.
// originScoped is the header set applyRequestHeaders built for that // originScoped is the header set applyRequestHeaders built for that
// attempt; a request with neither a per-target timeout nor an // attempt; a request with neither a per-target timeout nor an
-172
View File
@@ -1,172 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// eventQuery is the query string the events in these tests arrived
// with.
const eventQuery = "a=1&b=2"
// httpTargetConfig is the stored configuration of an HTTP target at
// targetURL.
func httpTargetConfig(
t *testing.T, targetURL string, forwardQuery bool,
) string {
t.Helper()
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL, ForwardQuery: forwardQuery,
})
require.NoError(t, err)
return string(cfg)
}
// deliverWithQuery sends one event that arrived with eventQuery to an
// HTTP target configured with cfg, through the path a received event's
// delivery takes, and returns the attempt it recorded.
func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult {
t.Helper()
s := newISetup(t)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}")
d := iSeedDelivery(
t, s.WebhookDB, event.ID, uuid.NewString(),
database.DeliveryStatusPending,
)
task := iTask(
d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body,
)
task.RawQuery = eventQuery
s.Engine.ExportProcessNewTask(context.TODO(), &task)
var result database.DeliveryResult
require.NoError(t, s.WebhookDB.Where(
"delivery_id = ?", d.ID,
).First(&result).Error)
return result
}
// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to:
// with the target's setting off, the target URL exactly as configured;
// with it on, the event's query string appended, joined with "&" to a
// query string the target URL already has.
func TestDeliverHTTP_ForwardQuery(t *testing.T) {
t.Parallel()
// The target URL's path, without and with a query string of its
// own.
const (
plain = "/in"
withQuery = "/in?key=k"
)
tests := map[string]struct {
path string
forwardQuery bool
want string
}{
"off": {
path: plain, want: plain,
},
"off, the target URL has a query string": {
path: withQuery, want: withQuery,
},
"on": {
path: plain, forwardQuery: true, want: plain + "?" + eventQuery,
},
"on, the target URL has a query string": {
path: withQuery, forwardQuery: true,
want: withQuery + "&" + eventQuery,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
received <- r.RequestURI
w.WriteHeader(http.StatusOK)
},
))
t.Cleanup(ts.Close)
result := deliverWithQuery(t, httpTargetConfig(
t, ts.URL+tc.path, tc.forwardQuery,
))
assert.True(t, result.Success)
require.Len(t, received, 1)
assert.Equal(t, tc.want, <-received)
})
}
}
// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the
// credential in a target URL's own query string stays masked once the
// event's query string is appended to it: in a response or error that
// echoes the URL the target was sent, as the event log's Redactor shows
// it, and in the error a failed connection stores.
func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
t.Parallel()
const secret = "s3cr3t"
received := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
received <- r.RequestURI
w.WriteHeader(http.StatusBadRequest)
},
))
t.Cleanup(ts.Close)
target := &database.Target{
Type: database.TargetTypeHTTP,
Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
}
deliverWithQuery(t, target.Config)
require.Len(t, received, 1)
sent := <-received
require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent)
redactor := delivery.NewRedactor(target)
for _, echoed := range []string{sent, ts.URL + sent} {
shown := redactor.Redact("rejected " + echoed)
assert.NotContains(t, shown, secret, echoed)
assert.Contains(t, shown, delivery.RedactionMarker, echoed)
}
// Nothing listens on port 1.
failed := deliverWithQuery(t, httpTargetConfig(
t, "http://127.0.0.1:1/in?token="+secret, true,
))
require.NotEmpty(t, failed.Error)
assert.NotContains(t, failed.Error, secret)
assert.NotContains(t, failed.Error, eventQuery)
}
+3 -4
View File
@@ -9,9 +9,9 @@ import (
) )
// logTarget is a fire-and-forget target that logs the entire // logTarget is a fire-and-forget target that logs the entire
// inbound webhook — the full request body, query string and // inbound webhook — the full request body and headers, plus
// headers, plus the method, content type, and the webhook and // the method, content type, and the webhook and entrypoint
// entrypoint ids — then records a single successful attempt. // ids — then records a single successful attempt.
// //
// This is the one log call in the service that deliberately writes // This is the one log call in the service that deliberately writes
// unbounded client-chosen bytes, so it is the one exception to the // unbounded client-chosen bytes, so it is the one exception to the
@@ -46,7 +46,6 @@ func (t *logTarget) Deliver(
"webhook_id", d.Event.WebhookID, "webhook_id", d.Event.WebhookID,
"entrypoint_id", d.Event.EntrypointID, "entrypoint_id", d.Event.EntrypointID,
"method", d.Event.Method, "method", d.Event.Method,
"raw_query", d.Event.RawQuery,
"content_type", d.Event.ContentType, "content_type", d.Event.ContentType,
"headers", d.Event.Headers, "headers", d.Event.Headers,
"body", d.Event.Body, "body", d.Event.Body,
+16 -19
View File
@@ -162,22 +162,18 @@ func targetSecrets(t *database.Target) []string {
} }
// urlSecrets returns the substrings of a destination URL that // urlSecrets returns the substrings of a destination URL that
// must not survive into a rendered page: the whole URL; its // must not survive into a rendered page: the whole URL, the
// path, unless that is empty or "/"; its query string, and the // parts of it MaskURL elides, and any userinfo.
// request URI that carries it, which a remote echoing the
// request line shows even when the URL has no path; and its
// userinfo and password.
// //
// No length floor is applied to the path, the query string or // No length floor is applied to the path, and none to the
// the userinfo. A short path or a four-byte username is // userinfo. A short path or a four-byte username is treated as
// treated as a credential exactly like a long one, because the // a credential exactly like a long one, because the field takes
// field takes an arbitrary URL and no part of it can be // an arbitrary URL and no part of it can be assumed non-secret —
// assumed non-secret — the same rule MaskURL applies. // the same rule MaskURL applies. headerSecrets does carry a
// headerSecrets does carry a floor, and the difference is // floor, and the difference is deliberate: a header is picked
// deliberate: a header is picked out by a name-shaped guess // out by a name-shaped guess and its value may be ordinary
// and its value may be ordinary text, whereas a URL's path, // text, whereas a URL's path and userinfo are credential
// query string and userinfo are credential material by // material by position.
// position.
func urlSecrets(raw string) []string { func urlSecrets(raw string) []string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -192,11 +188,12 @@ func urlSecrets(raw string) []string {
} }
if parsed.Path != "" && parsed.Path != "/" { if parsed.Path != "" && parsed.Path != "/" {
secrets = append(secrets, parsed.EscapedPath()) requestURI := parsed.RequestURI()
} secrets = append(secrets, requestURI)
if parsed.RawQuery != "" { if escaped := parsed.EscapedPath(); escaped != requestURI {
secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery) secrets = append(secrets, escaped)
}
} }
if parsed.User != nil { if parsed.User != nil {
-42
View File
@@ -202,48 +202,6 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
} }
} }
// TestRedactor_RemovesEchoedQueryOfURLWithoutPath covers an
// HTTP target URL whose credential is all in its query string.
// Written with or without the "/", the request line sends it
// as "/?token=…", and a target passing the event's query string
// on sends that after an "&". The event's part stays visible:
// the event's page shows it anyway.
func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
t.Parallel()
const secret = "s3cr3t"
marker := delivery.RedactionMarker
// An echoed request line, and what the event log shows of it.
echoes := map[string]string{
"POST /?token=" + secret + " HTTP/1.1": "POST " + marker +
" HTTP/1.1",
"POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker +
" HTTP/1.1",
"POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " +
marker + "&a=1&b=2 HTTP/1.1",
"POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" +
marker + "&a=1&b=2 HTTP/1.1",
}
for _, dest := range []string{
"https://example.com/?token=" + secret,
"https://example.com?token=" + secret,
} {
r := delivery.NewRedactor(&database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + dest + `"}`,
})
for echoed, want := range echoes {
assert.Equal(
t, want, r.Redact(echoed), "%s: %s", dest, echoed,
)
}
}
}
// TestRedactor_LeavesUnrelatedTextAlone pins that the // TestRedactor_LeavesUnrelatedTextAlone pins that the
// redactor matches literally: it does not guess at what a // redactor matches literally: it does not guess at what a
// secret looks like, so ordinary response content survives. // secret looks like, so ordinary response content survives.
-1
View File
@@ -310,7 +310,6 @@ func createReplayDelivery(
TargetConfig: target.Config, TargetConfig: target.Config,
MaxRetries: target.MaxRetries, MaxRetries: target.MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: replayBody(event.Body), Body: replayBody(event.Body),
@@ -26,9 +26,6 @@ const paramDeliveryID = "deliveryID"
// dispatches to it: the notifier is recorded, not run. // dispatches to it: the notifier is recorded, not run.
const replayTargetURL = "http://93.184.216.34/hook" const replayTargetURL = "http://93.184.216.34/hook"
// replayEventQuery is the query string a seeded event arrived with.
const replayEventQuery = "a=1&b=2"
// seedFailedDelivery records an event, a terminally failed delivery of // seedFailedDelivery records an event, a terminally failed delivery of
// it to the given target, and the attempt that failed. // it to the given target, and the attempt that failed.
func seedFailedDelivery( func seedFailedDelivery(
@@ -45,7 +42,6 @@ func seedFailedDelivery(
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: "entrypoint-" + webhookID, EntrypointID: "entrypoint-" + webhookID,
Method: http.MethodPost, Method: http.MethodPost,
RawQuery: replayEventQuery,
Headers: `{"X-Test":["yes"]}`, Headers: `{"X-Test":["yes"]}`,
Body: `{"replay":"me"}`, Body: `{"replay":"me"}`,
ContentType: contentTypeJSON, ContentType: contentTypeJSON,
@@ -300,10 +296,6 @@ func assertReplayTask(
"replay must use the target's current configuration", "replay must use the target's current configuration",
) )
assert.Equal(t, event.Method, task.Method) assert.Equal(t, event.Method, task.Method)
assert.Equal(
t, replayEventQuery, task.RawQuery,
"replay re-sends the stored query string",
)
assert.Equal(t, event.Headers, task.Headers) assert.Equal(t, event.Headers, task.Headers)
assert.Equal(t, event.ContentType, task.ContentType) assert.Equal(t, event.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum) assert.Equal(t, 1, task.AttemptNum)
+1 -2
View File
@@ -324,8 +324,7 @@ func loadEventLogRows(
var rows []eventLogRow var rows []eventLogRow
err = eventsWithStatus(webhookDB, webhookID, statuses).Select( err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns, eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents, err return rows, totalEvents, err
+7 -29
View File
@@ -17,23 +17,19 @@ import (
// bytes rather than characters, so the cap bounds the page in // bytes rather than characters, so the cap bounds the page in
// bytes whatever the payload's encoding. Cutting in SQLite // bytes whatever the payload's encoding. Cutting in SQLite
// rather than in Go is the point of the projection — an // rather than in Go is the point of the projection — an
// oversized body, query string or set of request headers never // oversized body or set of request headers never becomes a Go
// becomes a Go string at all. // string at all.
const eventLogColumns = "id, created_at, method, content_type, " + const eventLogColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, " + "resubmitted_from_id, entrypoint_id, " +
"substr(cast(raw_query as blob), 1, ?) AS raw_query, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, " +
"substr(cast(headers as blob), 1, ?) AS headers, " + "substr(cast(headers as blob), 1, ?) AS headers, " +
"length(cast(headers as blob)) AS headers_bytes, " + "length(cast(headers as blob)) AS headers_bytes, " +
"substr(cast(body as blob), 1, ?) AS body, " + "substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which // eventColumns is eventLogColumns for the event's own page, which
// shows the whole body, the whole query string and every request // shows the whole body and every request header.
// header.
const eventColumns = "id, created_at, method, content_type, " + const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, entrypoint_id, raw_query, " + "resubmitted_from_id, entrypoint_id, headers, " +
"length(cast(raw_query as blob)) AS raw_query_bytes, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " + "length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " + "cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes" "length(cast(body as blob)) AS body_bytes"
@@ -61,13 +57,6 @@ type EventLogView struct {
// entrypoint's secret. // entrypoint's secret.
Entrypoint string Entrypoint string
// RawQuery is the query string the event arrived with.
// RawQueryCut reports one left out, RawQuery then empty, because
// it holds more than maxRenderedBodyBytes; only the event log
// leaves it out.
RawQuery string
RawQueryCut bool
// Headers is the event's request headers as text, one // Headers is the event's request headers as text, one
// "Name: value" line per value, sorted by name. HeadersCut // "Name: value" line per value, sorted by name. HeadersCut
// reports headers left out because they hold more than // reports headers left out because they hold more than
@@ -96,9 +85,9 @@ func (v EventLogView) ResubmittedFrom() bool {
} }
// eventLogRow is one row of the event log projection, or of // eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its query string, headers and // eventColumns. In the event log its headers and body columns
// body columns arrive already cut to the cap by SQLite, each with // arrive already cut to the cap by SQLite, each with its true
// its true size beside it. // size beside it.
type eventLogRow struct { type eventLogRow struct {
ID string ID string
CreatedAt time.Time CreatedAt time.Time
@@ -106,8 +95,6 @@ type eventLogRow struct {
ContentType string ContentType string
ResubmittedFromID *string ResubmittedFromID *string
EntrypointID string EntrypointID string
RawQuery string
RawQueryBytes int64
Headers string Headers string
HeadersBytes int64 HeadersBytes int64
Body []byte Body []byte
@@ -127,13 +114,6 @@ func (r *eventLogRow) view(
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes) headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
rawQuery := r.RawQuery
rawQueryCut := r.RawQueryBytes > int64(len(rawQuery))
if rawQueryCut {
rawQuery = ""
}
return EventLogView{ return EventLogView{
ID: r.ID, ID: r.ID,
Method: r.Method, Method: r.Method,
@@ -143,8 +123,6 @@ func (r *eventLogRow) view(
Body: newBodyView( Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
), ),
RawQuery: rawQuery,
RawQueryCut: rawQueryCut,
Headers: strings.Join(headers, "\n"), Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
ResubmittedFromID: from, ResubmittedFromID: from,
+3 -75
View File
@@ -1,16 +1,13 @@
package handlers_test package handlers_test
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/go-chi/chi"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -20,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
@@ -119,7 +115,6 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
t.Helper() t.Helper()
assert.Contains(t, page, arrivedAt("Billing sender")) assert.Contains(t, page, arrivedAt("Billing sender"))
assert.Contains(t, page, "No query string.")
assert.Contains(t, page, headerBox( assert.Contains(t, page, headerBox(
"Accept: */*", "Accept: */*",
"User-Agent: shop/1 build\t7", "User-Agent: shop/1 build\t7",
@@ -335,70 +330,3 @@ func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
}) })
} }
} }
// TestHandleWebhook_StoresAndShowsTheQueryString posts to an
// entrypoint's URL with a query string and proves the event stores it
// as sent, and shows it escaped in the event log and on its own page,
// in a box like the one the request headers show in.
func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := seedEntrypoint(t, f.db, f.webhook.ID)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost,
"/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"),
)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", ep.Path)
req = req.WithContext(context.WithValue(
req.Context(), chi.RouteCtxKey, rctx,
))
w := httptest.NewRecorder()
f.h.HandleWebhook().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var stored database.Event
require.NoError(t, f.webhookDB.First(&stored).Error)
assert.Equal(t, "a=1&b=2", stored.RawQuery)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, headerBox("a=1&amp;b=2"))
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerBox("a=1&amp;b=2"))
}
// TestEventRequest_QueryStringOverTheLimit proves the event log leaves
// out a query string that holds more than it shows of a body, and links
// to the event's own page, which shows it whole.
func TestEventRequest_QueryStringOverTheLimit(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
ep := f.entrypoint(t, "Billing sender")
event := f.eventAt(t, ep, `{}`, time.Now())
query := "q=" + strings.Repeat("x", bodyCap)
require.NoError(t, f.webhookDB.Model(event).Update(
"raw_query", query,
).Error)
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Contains(t, page, `<a href="/hook/`+f.webhook.ID+`/events/`+
event.ID+`" class="btn-small">Show the query string</a>`)
assert.NotContains(t, page, "q=x")
assert.Less(t, len(page), 4*bodyCap)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), headerBox(query))
assert.NotContains(t, w.Body.String(), "Show the query string")
}
+1 -3
View File
@@ -30,7 +30,6 @@ type resubmitSource struct {
ID string ID string
EntrypointID string EntrypointID string
Method string Method string
RawQuery string
Headers string Headers string
ContentType string ContentType string
Body []byte Body []byte
@@ -40,7 +39,7 @@ type resubmitSource struct {
// The cast to blob is what makes the driver hand back the stored bytes // The cast to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, the same reason eventBodyQuery // rather than a string conversion, the same reason eventBodyQuery
// casts. // casts.
const resubmitColumns = "id, entrypoint_id, method, raw_query, headers, " + const resubmitColumns = "id, entrypoint_id, method, headers, " +
"content_type, cast(body as blob) AS body" "content_type, cast(body as blob) AS body"
// HandleEventResubmit re-injects a stored event as a new undelivered // HandleEventResubmit re-injects a stored event as a new undelivered
@@ -194,7 +193,6 @@ func (h *Handlers) queueResubmit(
WebhookID: webhook.ID, WebhookID: webhook.ID,
EntrypointID: src.EntrypointID, EntrypointID: src.EntrypointID,
Method: src.Method, Method: src.Method,
RawQuery: src.RawQuery,
HeadersJSON: src.Headers, HeadersJSON: src.Headers,
ContentType: src.ContentType, ContentType: src.ContentType,
Body: src.Body, Body: src.Body,
+3 -10
View File
@@ -22,13 +22,9 @@ import (
// dispatches to it: the notifier is recorded, not run. // dispatches to it: the notifier is recorded, not run.
const resubmitTargetURL = "http://93.184.216.34/hook" const resubmitTargetURL = "http://93.184.216.34/hook"
// resubmitEventHeaders and resubmitEventQuery are the stored header // resubmitEventHeaders is the stored header JSON a seeded event
// JSON and query string a seeded event carries, so a test can prove the // carries, so a test can prove the copy takes it verbatim.
// copy takes them verbatim. const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
const (
resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}`
resubmitEventQuery = "a=1&b=2"
)
// seedStoredEvent records one event in a webhook's own database with // seedStoredEvent records one event in a webhook's own database with
// no deliveries at all, which is the state a captured event is in when // no deliveries at all, which is the state a captured event is in when
@@ -47,7 +43,6 @@ func seedStoredEvent(
WebhookID: webhookID, WebhookID: webhookID,
EntrypointID: "entrypoint-" + webhookID, EntrypointID: "entrypoint-" + webhookID,
Method: http.MethodPost, Method: http.MethodPost,
RawQuery: resubmitEventQuery,
Headers: resubmitEventHeaders, Headers: resubmitEventHeaders,
Body: body, Body: body,
ContentType: contentTypeJSON, ContentType: contentTypeJSON,
@@ -207,7 +202,6 @@ func assertEventCopy(
t.Helper() t.Helper()
assert.Equal(t, original.Method, fresh.Method) assert.Equal(t, original.Method, fresh.Method)
assert.Equal(t, resubmitEventQuery, fresh.RawQuery)
assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Headers, fresh.Headers)
assert.Equal(t, original.Body, fresh.Body) assert.Equal(t, original.Body, fresh.Body)
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes) assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
@@ -242,7 +236,6 @@ func assertResubmitTask(
assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.ID, task.TargetID)
assert.Equal(t, target.Type, task.TargetType) assert.Equal(t, target.Type, task.TargetType)
assert.Equal(t, fresh.Method, task.Method) assert.Equal(t, fresh.Method, task.Method)
assert.Equal(t, fresh.RawQuery, task.RawQuery)
assert.Equal(t, fresh.Headers, task.Headers) assert.Equal(t, fresh.Headers, task.Headers)
assert.Equal(t, fresh.ContentType, task.ContentType) assert.Equal(t, fresh.ContentType, task.ContentType)
assert.Equal(t, 1, task.AttemptNum) assert.Equal(t, 1, task.AttemptNum)
+13 -18
View File
@@ -173,9 +173,6 @@ type targetFormInput struct {
Headers string Headers string
// Timeout is an HTTP target's per-request timeout in seconds. // Timeout is an HTTP target's per-request timeout in seconds.
Timeout string Timeout string
// ForwardQuery is an HTTP target's checkbox that passes each
// event's query string on to it.
ForwardQuery bool
// MaxRetries is an HTTP or Slack target's max_retries. // MaxRetries is an HTTP or Slack target's max_retries.
MaxRetries string MaxRetries string
// Expiry is a database (archive) target's row expiry. // Expiry is a database (archive) target's row expiry.
@@ -198,15 +195,14 @@ type targetFormInput struct {
// tokens. // tokens.
func targetFormInputFrom(r *http.Request) targetFormInput { func targetFormInputFrom(r *http.Request) targetFormInput {
return targetFormInput{ return targetFormInput{
Name: r.PostFormValue("name"), Name: r.PostFormValue("name"),
Type: database.TargetType(r.PostFormValue("type")), Type: database.TargetType(r.PostFormValue("type")),
URL: r.PostFormValue("url"), URL: r.PostFormValue("url"),
Headers: r.PostFormValue("headers"), Headers: r.PostFormValue("headers"),
Timeout: r.PostFormValue("timeout"), Timeout: r.PostFormValue("timeout"),
ForwardQuery: r.PostFormValue("forward_query") != "", MaxRetries: r.PostFormValue("max_retries"),
MaxRetries: r.PostFormValue("max_retries"), Expiry: r.PostFormValue("expiry"),
Expiry: r.PostFormValue("expiry"), Rotation: r.PostFormValue("rotation"),
Rotation: r.PostFormValue("rotation"),
} }
} }
@@ -236,8 +232,8 @@ func (h *Handlers) buildTargetConfig(
} }
// buildHTTPTargetConfig builds config JSON for an HTTP target: an // buildHTTPTargetConfig builds config JSON for an HTTP target: an
// SSRF-validated destination plus the optional headers, timeout and // SSRF-validated destination plus the optional headers and timeout
// query string setting the delivery path honours. // the delivery path honours.
func (h *Handlers) buildHTTPTargetConfig( func (h *Handlers) buildHTTPTargetConfig(
ctx context.Context, ctx context.Context,
in targetFormInput, in targetFormInput,
@@ -260,10 +256,9 @@ func (h *Handlers) buildHTTPTargetConfig(
} }
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
URL: in.URL, URL: in.URL,
Headers: headers, Headers: headers,
Timeout: timeout, Timeout: timeout,
ForwardQuery: in.ForwardQuery,
}) })
return configJSON, "", err return configJSON, "", err
+7 -8
View File
@@ -77,14 +77,13 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
} }
form := targetFormInput{ form := targetFormInput{
Name: target.Name, Name: target.Name,
URL: cfg.URL, URL: cfg.URL,
Headers: cfg.Headers, Headers: cfg.Headers,
Timeout: cfg.Timeout, Timeout: cfg.Timeout,
ForwardQuery: cfg.ForwardQuery, MaxRetries: strconv.Itoa(target.MaxRetries),
MaxRetries: strconv.Itoa(target.MaxRetries), Expiry: cfg.Expiry,
Expiry: cfg.Expiry, Rotation: cfg.Rotation,
Rotation: cfg.Rotation,
} }
h.renderTargetEdit( h.renderTargetEdit(
-53
View File
@@ -442,59 +442,6 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
assert.Contains(t, page, `class="label">Archive rotation</label>`) assert.Contains(t, page, `class="label">Archive rotation</label>`)
} }
// TestHandleTarget_ForwardQuery covers the HTTP target's setting that
// passes each event's query string on to it: the add target form
// stores it checked, the edit form starts with it checked and turns it
// off when saved unchecked, and both forms come back with it checked
// when refused.
func TestHandleTarget_ForwardQuery(t *testing.T) {
t.Parallel()
const checkbox = `name="forward_query" value="on" checked`
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
targetsPath := "/hook/" + webhook.ID + "/targets"
form := url.Values{}
form.Set("name", "forwarding")
form.Set("type", string(database.TargetTypeHTTP))
form.Set("url", editOriginalURL)
form.Set("forward_query", "on")
w := serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
w = serveTarget(
env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil,
)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
edit := editForm(editOriginalURL, "", "")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery)
edit.Set("url", editBlockedURL)
edit.Set("forward_query", "on")
w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), checkbox)
form.Set("url", editBlockedURL)
w = serveTarget(env, http.MethodPost, targetsPath, form)
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "data-forward-query")
}
// TestHandleTargetEditSubmit_Rejects covers every submission that // TestHandleTargetEditSubmit_Rejects covers every submission that
// must not reach storage. // must not reach storage.
// //
-4
View File
@@ -230,7 +230,6 @@ type eventSource struct {
WebhookID string WebhookID string
EntrypointID string EntrypointID string
Method string Method string
RawQuery string
HeadersJSON string HeadersJSON string
ContentType string ContentType string
Body []byte Body []byte
@@ -246,7 +245,6 @@ func (s eventSource) event() *database.Event {
WebhookID: s.WebhookID, WebhookID: s.WebhookID,
EntrypointID: s.EntrypointID, EntrypointID: s.EntrypointID,
Method: s.Method, Method: s.Method,
RawQuery: s.RawQuery,
Headers: s.HeadersJSON, Headers: s.HeadersJSON,
Body: string(s.Body), Body: string(s.Body),
BodyBytes: int64(len(s.Body)), BodyBytes: int64(len(s.Body)),
@@ -266,7 +264,6 @@ func requestEventSource(
WebhookID: entrypoint.WebhookID, WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID, EntrypointID: entrypoint.ID,
Method: r.Method, Method: r.Method,
RawQuery: r.URL.RawQuery,
HeadersJSON: string(headersJSON), HeadersJSON: string(headersJSON),
ContentType: r.Header.Get("Content-Type"), ContentType: r.Header.Get("Content-Type"),
Body: body, Body: body,
@@ -444,7 +441,6 @@ func buildDeliveryTasks(
TargetConfig: targets[i].Config, TargetConfig: targets[i].Config,
MaxRetries: targets[i].MaxRetries, MaxRetries: targets[i].MaxRetries,
Method: event.Method, Method: event.Method,
RawQuery: event.RawQuery,
Headers: event.Headers, Headers: event.Headers,
ContentType: event.ContentType, ContentType: event.ContentType,
Body: bodyPtr, Body: bodyPtr,
+6 -86
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,
@@ -507,10 +499,9 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
t.Helper() t.Helper()
const ( const (
refusedURL = "http://127.0.0.1/hook" refusedURL = "http://127.0.0.1/hook"
urlField = `form[action$="/targets"] input[name="url"]` urlField = `form[action$="/targets"] input[name="url"]`
forwardQuery = `form[action$="/targets"] input[name="forward_query"]` reason = `//div[@class="alert-error"]`
reason = `//div[@class="alert-error"]`
) )
require.NoError(t, chromedp.Run(ctx, loadPage(url))) require.NoError(t, chromedp.Run(ctx, loadPage(url)))
@@ -520,7 +511,6 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
ctx, ctx,
chromedp.SetValue(targetName, "refused", chromedp.ByQuery), chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery), chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
chromedp.Click(forwardQuery, chromedp.ByQuery),
)) ))
click(ctx, t, saveButton) click(ctx, t, saveButton)
@@ -528,26 +518,18 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
assert.True(t, shown(ctx, reason), assert.True(t, shown(ctx, reason),
"a refused target does not show the reason") "a refused target does not show the reason")
var ( var name, typed string
name, typed string
checked bool
)
require.NoError(t, chromedp.Run( require.NoError(t, chromedp.Run(
ctx, ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.JavascriptAttribute(
forwardQuery, "checked", &checked, chromedp.ByQuery,
),
)) ))
assert.Equal(t, "refused", name, assert.Equal(t, "refused", name,
"a refused target does not keep the name entered") "a refused target does not keep the name entered")
assert.Equal(t, refusedURL, typed, assert.Equal(t, refusedURL, typed,
"a refused target does not keep the url entered") "a refused target does not keep the url entered")
assert.True(t, checked,
"a refused target does not keep the query string setting checked")
assert.True(t, shown(ctx, targetName), assert.True(t, shown(ctx, targetName),
"a refused target does not come back with the form open") "a refused target does not come back with the form open")
assert.True(t, hidden(ctx, typeSelect), assert.True(t, hidden(ctx, typeSelect),
@@ -563,15 +545,10 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
ctx, ctx,
chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(targetName, &name, chromedp.ByQuery),
chromedp.Value(urlField, &typed, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery),
chromedp.JavascriptAttribute(
forwardQuery, "checked", &checked, chromedp.ByQuery,
),
)) ))
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered") assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered") assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
assert.False(t, checked,
"after Cancel, the next Add keeps the query string setting checked")
} }
// checkTargetDeliveries loads a webhook page and checks that the row of // checkTargetDeliveries loads a webhook page and checks that the row of
@@ -1315,60 +1292,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")
}
+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
-4
View File
@@ -138,7 +138,6 @@ document.addEventListener("alpine:init", function () {
url: "", url: "",
headers: "", headers: "",
timeout: "", timeout: "",
forwardQuery: false,
maxRetries: "", maxRetries: "",
expiry: "", expiry: "",
rotation: "", rotation: "",
@@ -151,8 +150,6 @@ document.addEventListener("alpine:init", function () {
this.url = refused.destination; this.url = refused.destination;
this.headers = refused.headers; this.headers = refused.headers;
this.timeout = refused.timeout; this.timeout = refused.timeout;
this.forwardQuery =
this.$root.hasAttribute("data-forward-query");
this.maxRetries = refused.maxRetries; this.maxRetries = refused.maxRetries;
this.expiry = refused.expiry; this.expiry = refused.expiry;
this.rotation = refused.rotation; this.rotation = refused.rotation;
@@ -172,7 +169,6 @@ document.addEventListener("alpine:init", function () {
this.url = ""; this.url = "";
this.headers = ""; this.headers = "";
this.timeout = ""; this.timeout = "";
this.forwardQuery = false;
this.maxRetries = ""; this.maxRetries = "";
this.expiry = ""; this.expiry = "";
this.rotation = ""; this.rotation = "";
+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>
+7 -15
View File
@@ -1,22 +1,14 @@
{{define "event_request"}} {{define "event_request"}}
<!-- The entrypoint an event arrived at, its query string and its <!-- The entrypoint an event arrived at and its request headers, as
request headers, as handlers.EventLogView carries them: the same in handlers.EventLogView carries them: the same in the event log and
the event log and the event's own page. The entrypoint's URL is the event's own page. The entrypoint's URL is never shown. A
never shown. A resubmitted copy, even a copy of a copy, did not resubmitted copy, even a copy of a copy, did not arrive at an
arrive at an 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}}
{{if .RawQueryCut}}
<p class="text-gray-500">The query string is larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the query string</a></p>
{{else if .RawQuery}}
<p class="text-gray-500">Query string</p>
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.RawQuery}}</pre>
{{else}}
<p class="text-gray-500">No query string.</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>
+5 -16
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
@@ -135,7 +132,6 @@
data-destination="{{.TargetForm.URL}}" data-destination="{{.TargetForm.URL}}"
data-headers="{{.TargetForm.Headers}}" data-headers="{{.TargetForm.Headers}}"
data-timeout="{{.TargetForm.Timeout}}" data-timeout="{{.TargetForm.Timeout}}"
{{if .TargetForm.ForwardQuery}}data-forward-query{{end}}
data-max-retries="{{.TargetForm.MaxRetries}}" data-max-retries="{{.TargetForm.MaxRetries}}"
data-expiry="{{.TargetForm.Expiry}}" data-expiry="{{.TargetForm.Expiry}}"
data-rotation="{{.TargetForm.Rotation}}"> data-rotation="{{.TargetForm.Rotation}}">
@@ -184,13 +180,6 @@
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label> <label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24"> <input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
</div> </div>
<div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="forward_query" value="on" :checked="forwardQuery" class="h-4 w-4">
Pass the query string on to this target
</label>
<p class="text-xs text-gray-500 mt-1">Appends the query string each event arrived with to the URL above, after any query string the URL already has.</p>
</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">Delivery attempts:</label>
@@ -260,7 +249,7 @@
{{range .Targets}} {{range .Targets}}
<div class="p-4"> <div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900 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">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
{{if .Active}} {{if .Active}}
+5 -10
View File
@@ -3,15 +3,10 @@
{{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. --> <!-- Under a filter, this counts the events the filter lists. -->
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span> <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>
@@ -33,8 +28,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 +54,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">
-8
View File
@@ -47,14 +47,6 @@
<input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input"> <input type="number" id="timeout" name="timeout" value="{{.TargetForm.Timeout}}" min="0" max="{{.MaxTimeout}}" class="input">
<p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p> <p class="text-xs text-gray-500 mt-1">Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.</p>
</div> </div>
<div class="form-group">
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="checkbox" id="forward_query" name="forward_query" value="on"{{if .TargetForm.ForwardQuery}} checked{{end}} class="h-4 w-4">
Pass the query string on to this target
</label>
<p class="text-xs text-gray-500 mt-1">Appends the query string each event arrived with to the destination URL, after any query string the URL already has.</p>
</div>
{{end}} {{end}}
{{if eq .Target.Type "slack"}} {{if eq .Target.Type "slack"}}
+411 -606
View File
File diff suppressed because it is too large Load Diff