Compare commits
21
Commits
984d7589d0
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8fc0c5d32 | ||
|
|
7963188c1e | ||
|
|
16ed356b68 | ||
|
|
fe5e0d4173 | ||
|
|
9ccaa8ce01 | ||
|
|
935e18c6f1 | ||
|
|
af91d8a723 | ||
|
|
7e779f7fce | ||
|
|
9079a3219d | ||
|
|
b9ec91c0f7 | ||
|
|
74a96b2226 | ||
|
|
8b617efa63 | ||
|
|
d8c60c9b67 | ||
|
|
ea8384f4a2 | ||
|
|
17e6c85dd8 | ||
|
|
bcdd4791ec | ||
|
|
f1da5e73dd | ||
|
|
d2ecb83923 | ||
|
|
643077021d | ||
|
|
22fa502638 | ||
|
|
f703b72ce0 |
@@ -15,6 +15,9 @@ 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
|
||||||
|
# them at the `COPY . .` of the stages built on it.
|
||||||
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ jobs:
|
|||||||
# and built cannot report success from cache.
|
# and built cannot report success from cache.
|
||||||
run: git rev-parse HEAD > .ci-fingerprint
|
run: git rev-parse HEAD > .ci-fingerprint
|
||||||
|
|
||||||
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, make test, make build)
|
- name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build)
|
||||||
run: script/cibuild
|
run: script/cibuild
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ bin/
|
|||||||
# Go vendor directory
|
# Go vendor directory
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
# ESLint, prettier and their dependencies, installed from yarn.lock
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# IDE specific files
|
# IDE specific files
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
|
||||||
|
# ESLint and prettier from node_modules/.bin.
|
||||||
|
nodeLinker: node-modules
|
||||||
+51
-9
@@ -4,8 +4,6 @@
|
|||||||
# compile on Alpine musl (off64_t is a glibc type).
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy go mod files first for better layer caching
|
# Copy go mod files first for better layer caching
|
||||||
@@ -19,12 +17,14 @@ RUN go mod download
|
|||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter. golangci-lint is invoked directly rather
|
# Run the Go formatting check and the linter. gofmt and golangci-lint are
|
||||||
# than through `make lint`: this stage is already the pinned linter image, and
|
# invoked directly rather than through `make fmt-check` and `make lint`: this
|
||||||
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here
|
# stage is already the pinned linter image, and both scripts build docker
|
||||||
# would need a docker daemon inside the build. Keep these steps in step with
|
# stages, so calling them here would need a docker daemon inside the build.
|
||||||
# Dockerfile.lint, including --network=none (see its header for why).
|
# The Markdown half of `make fmt-check` is the markdown-check stage below.
|
||||||
RUN make fmt-check
|
# Keep the golangci-lint steps in step with Dockerfile.lint, including
|
||||||
|
# --network=none (see its header for why).
|
||||||
|
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
|
||||||
RUN script/assets
|
RUN script/assets
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
@@ -65,15 +65,57 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# prettier for the Markdown stages below, and stays cached until package.json,
|
||||||
|
# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run,
|
||||||
|
# and the build stage below runs it too. COPY . . brings in the CI cache
|
||||||
|
# barrier described in the lint stage above.
|
||||||
|
#
|
||||||
|
# The image's own corepack runs the yarn that package.json's packageManager
|
||||||
|
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
|
||||||
|
# hash there. The image also ships yarn 1, which `corepack enable yarn`
|
||||||
|
# replaces.
|
||||||
|
# node:24.21.0-alpine (LTS), 2026-09-18
|
||||||
|
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
|
RUN corepack enable yarn && yarn install --immutable --mode=skip-build
|
||||||
|
|
||||||
|
FROM js-deps AS js-lint
|
||||||
|
COPY . .
|
||||||
|
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 and stylesheet check stages passing
|
# Depend on the lint, stylesheet check, JavaScript lint and Markdown check
|
||||||
|
# stages passing
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
COPY --from=css-check /out/tailwind.css /dev/null
|
COPY --from=css-check /out/tailwind.css /dev/null
|
||||||
|
COPY --from=js-lint /src/yarn.lock /dev/null
|
||||||
|
COPY --from=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.
|
||||||
|
|||||||
@@ -2,403 +2,367 @@
|
|||||||
|
|
||||||
One issue per unit of work, one branch and one PR per issue:
|
One issue per unit of work, one branch and one PR per issue:
|
||||||
|
|
||||||
* ensure a tracked issue exists with a definition of done
|
- ensure a tracked issue exists with a definition of done
|
||||||
* branch from `next` (never from `main`)
|
- branch from `next` (never from `main`)
|
||||||
* do the work; open a PR based on `next` (never on `main`)
|
- do the work; open a PR based on `next` (never on `main`)
|
||||||
* pass an independent review, then the manager squash-merges into `next`
|
- pass an independent review, then the manager squash-merges into `next`
|
||||||
* push; nothing stays local-only
|
- push; nothing stays local-only
|
||||||
|
|
||||||
`next` is the branch for the next milestone and must stay green and
|
`next` is the branch for the next milestone and must stay green and mergeable to
|
||||||
mergeable to `main` without notice. One `next` -> `main` PR accumulates
|
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
||||||
the milestone; releases are cut from `main` separately.
|
releases are cut from `main` separately.
|
||||||
|
|
||||||
Issue branches do NOT touch this file — the manager maintains it on
|
Issue branches do NOT touch this file — the manager maintains it on `next`.
|
||||||
`next`. Every branch editing `TODO.md` conflicts with every other
|
Every branch editing `TODO.md` conflicts with every other (#112).
|
||||||
(#112).
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
||||||
authoritative list, and the only place to read a count or a state of
|
authoritative list, and the only place to read a count or a state of play from.
|
||||||
play from. This file records where the project is, not what is in
|
This file records where the project is, not what is in flight: a sentence whose
|
||||||
flight: a sentence whose truth depends on a branch being unmerged is
|
truth depends on a branch being unmerged is wrong the moment it merges, and this
|
||||||
wrong the moment it merges, and this file has been wrong that way
|
file has been wrong that way before.
|
||||||
before.
|
|
||||||
|
|
||||||
The durability defect that held the tag has landed
|
The durability defect that held the tag has landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
|
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
|
||||||
Every SQLite handle opens with WAL journaling and a busy timeout, a
|
handle opens with WAL journaling and a busy timeout, a bookkeeping write that
|
||||||
bookkeeping write that fails leaves its delivery in a recoverable
|
fails leaves its delivery in a recoverable state rather than a lying one, and
|
||||||
state rather than a lying one, and recovery skips a delivery that
|
recovery skips a delivery that already has a successful result row. Final
|
||||||
already has a successful result row. Final pre-tag verification
|
pre-tag verification exercised it and confirmed it holds. Whatever the milestone
|
||||||
exercised it and confirmed it holds. Whatever the milestone still
|
still shows open is what remains before `v1.0.0`.
|
||||||
shows open is what remains before `v1.0.0`.
|
|
||||||
|
|
||||||
Delivery is at-least-once by design, not by accident: a send whose
|
Delivery is at-least-once by design, not by accident: a send whose result row
|
||||||
result row does not land is attempted again, so a receiver can see a
|
does not land is attempted again, so a receiver can see a duplicate. That is
|
||||||
duplicate. That is deliberate — the alternative is a silent lost
|
deliberate — the alternative is a silent lost delivery — and the README says so
|
||||||
delivery — and the README says so under Rationale. It is not a defect
|
under Rationale. It is not a defect to re-file.
|
||||||
to re-file.
|
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the rest of the open 1.0.0 milestone
|
Clear the rest of the open 1.0.0 milestone
|
||||||
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`.
|
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
|
||||||
Merging `next` into `main` is a separate act from tagging and waits on
|
`next` into `main` is a separate act from tagging and waits on neither of those:
|
||||||
neither of those: `next` is kept mergeable at all times, which is the
|
`next` is kept mergeable at all times, which is the point of the branch.
|
||||||
point of the branch.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via
|
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
|
||||||
`BIND_ADDRESS` defaulting to `127.0.0.1`, and document the
|
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
|
||||||
reverse-proxy deployment. A hostname, an empty value or a value
|
hostname, an empty value or a value carrying a port is a startup error, and
|
||||||
carrying a port is a startup error, and the `Dockerfile` sets
|
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
|
||||||
`0.0.0.0` because a loopback bind inside a container is unreachable
|
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
removed the shutdown race: `httpServer` is built in the constructor rather
|
||||||
removed the shutdown race: `httpServer` is built in the constructor
|
than assigned from the serving goroutine, which orders the write before every
|
||||||
rather than assigned from the serving goroutine, which orders the
|
fx hook and rules out the nil dereference a SIGTERM arriving first would have
|
||||||
write before every fx hook and rules out the nil dereference a
|
caused, and `sentryEnabled` is an `atomic.Bool`
|
||||||
SIGTERM arriving first would have caused, and `sentryEnabled` is an
|
(https://git.eeqj.de/sneak/webhooker/issues/226)
|
||||||
`atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226)
|
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
|
||||||
- 2026-08-24 Remove inbound request signature verification. The
|
is the authentication secret, so the per-entrypoint shared secret, the
|
||||||
entrypoint UUID is the authentication secret, so the per-entrypoint
|
`internal/signature` package, the receiver check, the model fields and the
|
||||||
shared secret, the `internal/signature` package, the receiver check,
|
forms are all gone. This reverses the feature that landed earlier in the same
|
||||||
the model fields and the forms are all gone. This reverses the
|
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
|
||||||
feature that landed earlier in the same milestone
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/67,
|
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/279)
|
https://git.eeqj.de/sneak/webhooker/issues/279)
|
||||||
- 2026-08-24 Stamp the build version into the binary and render it in
|
- 2026-08-24 Stamp the build version into the binary and render it in the UI
|
||||||
the UI footer. `script/version` is the single source — `$VERSION`,
|
footer. `script/version` is the single source — `$VERSION`, else
|
||||||
else `git describe --tags --always --dirty`, else `unknown` — so a
|
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
|
||||||
`make build` binary and a `make docker` image from one checkout
|
binary and a `make docker` image from one checkout report the same thing, and
|
||||||
report the same thing, and nothing in it varies between two builds
|
nothing in it varies between two builds of the same commit, which the release
|
||||||
of the same commit, which the release gate's byte-identical
|
gate's byte-identical assertion would catch
|
||||||
assertion would catch
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
||||||
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the
|
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
|
||||||
request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a
|
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
|
||||||
real TLS proxy with the environment left at its `dev` default, the
|
with the environment left at its `dev` default, the session cookie silently
|
||||||
session cookie silently lost `Secure` while the CSRF cookie on the
|
lost `Secure` while the CSRF cookie on the same response kept it.
|
||||||
same response kept it. `X-Forwarded-Proto` is now matched
|
`X-Forwarded-Proto` is now matched case-insensitively on its first
|
||||||
case-insensitively on its first comma-separated element, so `HTTPS`
|
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
|
||||||
and `https, http` no longer fall to the relaxed CSRF path
|
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/269)
|
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
|
||||||
- 2026-08-24 Roll back a failed webhook deletion instead of committing
|
failing delete committed whatever had already succeeded, hard-deleted the
|
||||||
it. A failing delete committed whatever had already succeeded,
|
per-webhook event database anyway, and redirected as though it had worked —
|
||||||
hard-deleted the per-webhook event database anyway, and redirected as
|
orphaned config plus permanently destroyed history, reported as success. All
|
||||||
though it had worked — orphaned config plus permanently destroyed
|
three delete positions now roll back with the event database intact
|
||||||
history, reported as success. All three delete positions now roll
|
|
||||||
back with the event database intact
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
||||||
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
||||||
`(deleted)`, rather than leaving the event log unable to say where a
|
`(deleted)`, rather than leaving the event log unable to say where a delivery
|
||||||
delivery went. A deleted target's credentials stay masked exactly as
|
went. A deleted target's credentials stay masked exactly as a live one's, and
|
||||||
a live one's, and it cannot become deliverable again through the
|
it cannot become deliverable again through the receiver, resubmit, replay, the
|
||||||
receiver, resubmit, replay, the edit form or the toggle
|
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/211)
|
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
|
||||||
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions,
|
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
|
||||||
so the unauthenticated receiver is no longer a memory-exhaustion
|
carries the chi route pattern, and `method` folds anything chi cannot route
|
||||||
vector: `handler` carries the chi route pattern, and `method` folds
|
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
|
||||||
anything chi cannot route onto a single `(unmatched)` sentinel. Both
|
300 random method tokens took the series count from 106 to 7,631, and path
|
||||||
were reproduced before the fix — 300 random method tokens took the
|
flooding reached 62,532 — and a label audit across a live scrape found no
|
||||||
series count from 106 to 7,631, and path flooding reached 62,532 —
|
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
||||||
and a label audit across a live scrape found no third unbounded
|
|
||||||
dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/261)
|
https://git.eeqj.de/sneak/webhooker/issues/261)
|
||||||
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7`
|
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
|
||||||
and `-5` silently became 0 — fire-and-forget — including on the edit
|
silently became 0 — fire-and-forget — including on the edit path, where it
|
||||||
path, where it destroyed a working value, and `999999999` stored
|
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
|
||||||
verbatim. The ceiling of 20 is the `max` both templates already
|
is the `max` both templates already declared
|
||||||
declared (https://git.eeqj.de/sneak/webhooker/issues/221)
|
(https://git.eeqj.de/sneak/webhooker/issues/221)
|
||||||
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a
|
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
|
||||||
backend under development can be tested against real captured
|
under development can be tested against real captured traffic. Per-delivery
|
||||||
traffic. Per-delivery replay cannot serve that: it re-sends one
|
replay cannot serve that: it re-sends one finished delivery to its own
|
||||||
finished delivery to its own original target, and a target created
|
original target, and a target created for a dev backend has no prior delivery
|
||||||
for a dev backend has no prior delivery to replay. Resubmit
|
to replay. Resubmit re-injects the stored event at the top of the receiver
|
||||||
re-injects the stored event at the top of the receiver path and fans
|
path and fans it out to whatever targets are active now
|
||||||
it out to whatever targets are active now
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
||||||
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two
|
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
|
||||||
instances on one directory cannot both deliver
|
on one directory cannot both deliver
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
||||||
- 2026-08-20 Shut down the app when the HTTP listener fails. The
|
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
|
||||||
`OnStart` hook returned as soon as the serving goroutine was
|
returned as soon as the serving goroutine was spawned, so a failed listen left
|
||||||
spawned, so a failed listen left fx reporting RUNNING and a live
|
fx reporting RUNNING and a live process with nothing bound — invisible to
|
||||||
process with nothing bound — invisible to systemd and Docker restart
|
systemd and Docker restart policies
|
||||||
policies (https://git.eeqj.de/sneak/webhooker/issues/200)
|
(https://git.eeqj.de/sneak/webhooker/issues/200)
|
||||||
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
||||||
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL
|
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
|
||||||
with placeholders rather than bound values
|
placeholders rather than bound values
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on
|
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
|
||||||
half-set metrics auth credentials
|
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/205)
|
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
|
||||||
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps
|
trace recorder in for the logging adapter, and that recorder does not
|
||||||
GORM's own trace recorder in for the logging adapter, and that
|
implement `gorm.ParamsFilter`, so those statements logged their bound values
|
||||||
recorder does not implement `gorm.ParamsFilter`, so those statements
|
interpolated and bypassed the suppression above. The two units gated green
|
||||||
logged their bound values interpolated and bypassed the suppression
|
against a `next` that lacked the other, and `next` went red when both landed
|
||||||
above. The two units gated green against a `next` that lacked the
|
|
||||||
other, and `next` went red when both landed
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
||||||
- 2026-08-20 Render per-attempt delivery detail in the event log
|
- 2026-08-20 Render per-attempt delivery detail in the event log
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
||||||
terminally failed delivery
|
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/203)
|
|
||||||
- 2026-08-20 Expose delivery metrics on `/metrics`
|
- 2026-08-20 Expose delivery metrics on `/metrics`
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the
|
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
|
||||||
backup, restore and upgrade procedures
|
restore and upgrade procedures
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
||||||
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap
|
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
|
||||||
banner. The admin bootstrap password was printed once among roughly
|
admin bootstrap password was printed once among roughly 45 fx lines, and under
|
||||||
45 fx lines, and under `docker run -d` went to container logs subject
|
`docker run -d` went to container logs subject to rotation; there was no reset
|
||||||
to rotation; there was no reset path at all, so recovery meant
|
path at all, so recovery meant hand-deleting the users row, documented
|
||||||
hand-deleting the users row, documented nowhere. The password is read
|
nowhere. The password is read from stdin or generated, never from argv where
|
||||||
from stdin or generated, never from argv where `/proc` would publish
|
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
|
||||||
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch
|
SSRF guard, so a self-hosted proxy can forward into the operator's own
|
||||||
for the SSRF guard, so a self-hosted proxy can forward into the
|
network. The guard's always-blocked set cannot be reopened by configuration
|
||||||
operator's own network. The guard's always-blocked set cannot be
|
|
||||||
reopened by configuration
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
||||||
- 2026-08-20 Harden operator-set target headers, which were carried
|
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
|
||||||
unsafely across a redirect
|
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/233)
|
|
||||||
- 2026-08-20 Add a target edit form with headers and timeout fields
|
- 2026-08-20 Add a target edit form with headers and timeout fields
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
||||||
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s,
|
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
|
||||||
matching the org-wide backstop. `go test` applies `-timeout` per
|
the org-wide backstop. `go test` applies `-timeout` per package, and
|
||||||
package, and `internal/handlers` had grown past the old budget: a
|
`internal/handlers` had grown past the old budget: a cache-defeated build
|
||||||
cache-defeated build failed outright at `GOMAXPROCS=4`, and every run
|
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
|
||||||
under deliberate host load breached 30s. The measurement table lives
|
breached 30s. The measurement table lives in the script (#194)
|
||||||
in the script (#194)
|
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
|
||||||
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy
|
and still mandated a 20s test target with a 30s timeout, which the org
|
||||||
was stale and still mandated a 20s test target with a 30s timeout,
|
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
|
||||||
which the org replaced with a 60s cap and a 90s backstop. A synced
|
reading it as one nearly produced a PR against `prompts` proposing a change
|
||||||
copy is not a source; reading it as one nearly produced a PR against
|
already merged there (#196)
|
||||||
`prompts` proposing a change already merged there (#196)
|
- 2026-08-18 Report handler panics through the logger and answer 500. chi
|
||||||
- 2026-08-18 Report handler panics through the logger and answer 500.
|
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
|
||||||
chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no
|
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
|
||||||
longer emits, then indexes `pkg[-1:]`, so it panicked inside its own
|
writing a byte: the recovery never ran, the client got a dropped connection
|
||||||
stack printer before writing a byte: the recovery never ran, the
|
instead of a 500, and the original panic was lost. A local middleware replaces
|
||||||
client got a dropped connection instead of a 500, and the original
|
it, bounded by `MaxPanicLogLineBytes` (#187)
|
||||||
panic was lost. A local middleware replaces it, bounded by
|
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
|
||||||
`MaxPanicLogLineBytes` (#187)
|
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
|
||||||
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every
|
false, so **every record-not-found printed the fully interpolated SQL to
|
||||||
`gorm.Open` left `logger.Default` in place at `Warn` with
|
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
|
||||||
`IgnoreRecordNotFoundError` false, so **every record-not-found
|
submitted username on the login form, at no level the operator set and outside
|
||||||
printed the fully interpolated SQL to stdout** — including the
|
`internal/logger` entirely. Three call sites, not the two the issue named
|
||||||
client-chosen path on `/webhook/{uuid}` and the submitted username on
|
(#178)
|
||||||
the login form, at no level the operator set and outside
|
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
|
||||||
`internal/logger` entirely. Three call sites, not the two the issue
|
reachable unauthenticated, found by reading every `slog` call in the tree
|
||||||
named (#178)
|
rather than only the one reported; the budget moved to a shared
|
||||||
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight
|
`internal/logfield` so no second truncation exists. `DEBUG` being off by
|
||||||
sites reachable unauthenticated, found by reading every `slog` call in
|
default is not a bound and is not treated as one (#176)
|
||||||
the tree rather than only the one reported; the budget moved to a
|
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
|
||||||
shared `internal/logfield` so no second truncation exists. `DEBUG`
|
non-fatal `assert` on an acquire result was dereferenced on the next line, so
|
||||||
being off by default is not a bound and is not treated as one (#176)
|
one timing miss killed the whole `internal/middleware` binary and reddened CI
|
||||||
- 2026-08-18 Stop a slow host turning a login-guard test into a
|
for unrelated PRs. The fix also removed a real production race — `acquire`
|
||||||
segfault. A non-fatal `assert` on an acquire result was dereferenced
|
could shed a request with a slot standing free, because Go picks uniformly
|
||||||
on the next line, so one timing miss killed the whole
|
among ready `select` cases (#186)
|
||||||
`internal/middleware` binary and reddened CI for unrelated PRs. The
|
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
|
||||||
fix also removed a real production race — `acquire` could shed a
|
The receiver's path carries the entrypoint capability token, so every Sentry
|
||||||
request with a slot standing free, because Go picks uniformly among
|
event from `/webhook/{uuid}` shipped a live credential to a third party.
|
||||||
ready `select` cases (#186)
|
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
|
||||||
- 2026-08-18 Send the chi route pattern to Sentry rather than the
|
reduced to an allowlist (#179)
|
||||||
concrete path. The receiver's path carries the entrypoint capability
|
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
|
||||||
token, so every Sentry event from `/webhook/{uuid}` shipped a live
|
query string, so a login could be driven by URL parameters — putting the
|
||||||
credential to a third party. Request `Data`, `QueryString`, `Cookies`
|
password somewhere that lands in access logs, proxy logs and browser history
|
||||||
and `Env` are dropped and headers reduced to an allowlist (#179)
|
(#160)
|
||||||
- 2026-08-18 Read form fields from the POST body only. `r.FormValue`
|
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
|
||||||
merges the query string, so a login could be driven by URL parameters
|
flood of wrong passwords cannot lock out the account it is guessing at. The
|
||||||
— putting the password somewhere that lands in access logs, proxy
|
manager took this decision rather than stall the queue; it is flagged on the
|
||||||
logs and browser history (#160)
|
issue for reversal (#150)
|
||||||
- 2026-08-18 Verify login credentials before spending rate-limit
|
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
|
||||||
budget, so a flood of wrong passwords cannot lock out the account it
|
wrong in both directions from version skew and shared caches. `script/lint`
|
||||||
is guessing at. The manager took this decision rather than stall the
|
asserts the summary line, because `--no-cache-filter` silently ignores a stage
|
||||||
queue; it is flagged on the issue for reversal (#150)
|
name it does not match — the flag that makes the gate meaningful fails open
|
||||||
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint
|
(#109)
|
||||||
was wrong in both directions from version skew and shared caches.
|
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
|
||||||
`script/lint` asserts the summary line, because `--no-cache-filter`
|
truncates for rendering, and that truncated value was the only way to read a
|
||||||
silently ignores a stage name it does not match — the flag that makes
|
body, so the full payload was unreachable (#157)
|
||||||
the gate meaningful fails open (#109)
|
|
||||||
- 2026-08-18 Serve an event's full stored body over HTTP. The list
|
|
||||||
query truncates for rendering, and that truncated value was the only
|
|
||||||
way to read a body, so the full payload was unreachable (#157)
|
|
||||||
- 2026-08-18 Bound the access log line against client-chosen text.
|
- 2026-08-18 Bound the access log line against client-chosen text.
|
||||||
`internal/logfield` budgets by *encoded* bytes, not runes, so a
|
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
|
||||||
handler's JSON escaping cannot multiply a field past its allowance
|
escaping cannot multiply a field past its allowance (#146)
|
||||||
(#146)
|
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
|
||||||
- 2026-08-18 Mark superseded CI commits `failure` rather than
|
skipped run rolls up green, so a commit that was never tested reported success
|
||||||
`skipped`. A skipped run rolls up green, so a commit that was never
|
(#152)
|
||||||
tested reported success (#152)
|
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
|
||||||
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so
|
hooks are bounded by a deadline the orchestrator will actually honour rather
|
||||||
shutdown hooks are bounded by a deadline the orchestrator will
|
than being killed mid-flush (#134)
|
||||||
actually honour rather than being killed mid-flush (#134)
|
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
|
||||||
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation
|
2^64 addresses, so per-address keying let one client mint unlimited buckets.
|
||||||
hands out 2^64 addresses, so per-address keying let one client mint
|
Manager decision, recorded on the issue (#125)
|
||||||
unlimited buckets. Manager decision, recorded on the issue (#125)
|
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
|
||||||
- 2026-08-17 Correct release-blocking README and startup-warning
|
including claims about behaviour the code does not have (#151)
|
||||||
inaccuracies, including claims about behaviour the code does not have
|
|
||||||
(#151)
|
|
||||||
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
||||||
`static/vendor.sha256` instead of committing the minified blob, so
|
`static/vendor.sha256` instead of committing the minified blob, so the
|
||||||
the dependency is pinned by hash rather than by trust (#145)
|
dependency is pinned by hash rather than by trust (#145)
|
||||||
- 2026-08-17 Bound the event log's rendered bodies in the query itself,
|
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
|
||||||
so a large stored payload cannot be read into memory just to be
|
large stored payload cannot be read into memory just to be truncated for
|
||||||
truncated for display (#135)
|
display (#135)
|
||||||
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can
|
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
|
||||||
carry a bearer credential in its path or query, and was rendered
|
bearer credential in its path or query, and was rendered verbatim. Manager
|
||||||
verbatim. Manager decision to mask unconditionally (#115)
|
decision to mask unconditionally (#115)
|
||||||
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that
|
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
|
||||||
hangs cannot hold the process past its grace period (#102)
|
cannot hold the process past its grace period (#102)
|
||||||
- 2026-08-14 Render templates via a buffer rather than the
|
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
|
||||||
`ResponseWriter`, so a template error part-way through cannot commit
|
a template error part-way through cannot commit a 200 and then fail — the
|
||||||
a 200 and then fail — the response is written only once it is whole
|
response is written only once it is whole (#123)
|
||||||
(#123)
|
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
|
||||||
- 2026-08-14 Align the session codec's max-age with the 7-day absolute
|
codec accepted cookies the session layer considered expired, so the cap was
|
||||||
cap. The codec accepted cookies the session layer considered expired,
|
enforced in one place and not the other (#108)
|
||||||
so the cap was enforced in one place and not the other (#108)
|
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
|
||||||
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where
|
default silently discards forwarded headers and every client rate-limits as
|
||||||
the safe default silently discards forwarded headers and every client
|
the proxy's address (#149)
|
||||||
rate-limits as the proxy's address (#149)
|
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
|
||||||
- 2026-08-12 Bound the receiver rate limit per client IP across the
|
`/webhook/*` route. The existing limiter keyed on the request path and
|
||||||
whole `/webhook/*` route. The existing limiter keyed on the request
|
`/webhook/{uuid}` matches any single segment, so a client that invented a
|
||||||
path and `/webhook/{uuid}` matches any single segment, so a client
|
fresh path per request minted a fresh bucket per request: the limit on the
|
||||||
that invented a fresh path per request minted a fresh bucket per
|
only unauthenticated endpoint bounded nothing in aggregate, and every request
|
||||||
request: the limit on the only unauthenticated endpoint bounded
|
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
|
||||||
nothing in aggregate, and every request still cost an entrypoint
|
client address alone now bounds that, chained in front of the unchanged
|
||||||
lookup before it 404ed. An outer limiter keyed on the client address
|
|
||||||
alone now bounds that, chained in front of the unchanged
|
|
||||||
per-entrypoint limiter (#139)
|
per-entrypoint limiter (#139)
|
||||||
- 2026-08-12 Correct release-blocking documentation inaccuracies: the
|
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README
|
||||||
README promised manual redelivery in the present tense in three
|
promised manual redelivery in the present tense in three places when nothing
|
||||||
places when nothing implements it (the same false claim also sat in
|
implements it (the same false claim also sat in the doc comment that was its
|
||||||
the doc comment that was its source text), the env table omitted
|
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
|
||||||
`RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
|
itself omitted five landed units (#141)
|
||||||
units (#141)
|
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
|
||||||
- 2026-08-12 Make the CI gate execute the checks it reports on. The
|
writes a build-context fingerprint before calling `script/cibuild`, so a code
|
||||||
workflow now writes a build-context fingerprint before calling
|
commit invalidates the `COPY` layer of the lint and builder stages while a
|
||||||
`script/cibuild`, so a code commit invalidates the `COPY` layer of
|
docs-only commit still replays from cache; a superseding run also rewrites the
|
||||||
the lint and builder stages while a docs-only commit still replays
|
`failure` status Gitea leaves on commits it cancelled and never tested.
|
||||||
from cache; a superseding run also rewrites the `failure` status
|
Verified by pushing a deliberately broken test and watching CI go red (#119)
|
||||||
Gitea leaves on commits it cancelled and never tested. Verified by
|
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
|
||||||
pushing a deliberately broken test and watching CI go red (#119)
|
reached `time.NewTicker` in both the retention reaper and the archive sweeper,
|
||||||
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
|
panicking two goroutines with no recover after startup had already reported
|
||||||
non-positive value reached `time.NewTicker` in both the retention
|
success (#140)
|
||||||
reaper and the archive sweeper, panicking two goroutines with no
|
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
|
||||||
recover after startup had already reported success (#140)
|
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
|
||||||
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
|
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
|
||||||
cap: the reverse walk cuts entries with `strings.LastIndexByte`
|
on the unauthenticated receiver. Semantics proven unchanged by differential
|
||||||
instead of joining and splitting, so a 1 MB header allocates 16 bytes
|
testing against the previous implementation (#133)
|
||||||
rather than 1.6 MB per request on the unauthenticated receiver.
|
|
||||||
Semantics proven unchanged by differential testing against the
|
|
||||||
previous implementation (#133)
|
|
||||||
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
||||||
attacker-supplied chain cannot burn unbounded CPU in the rate-limit
|
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
|
||||||
key function; running off the end falls back to the peer address
|
function; running off the end falls back to the peer address (#124)
|
||||||
(#124)
|
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
|
||||||
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
|
all three rate limiters key on the connection's own address unless the direct
|
||||||
list: all three rate limiters key on the connection's own address
|
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
|
||||||
unless the direct peer is a configured proxy, in which case
|
left for the first non-proxy hop. Default trusts nothing, and a
|
||||||
`X-Forwarded-For` is walked right to left for the first non-proxy hop.
|
set-but-unparseable value aborts startup. Before this, any client could mint a
|
||||||
Default trusts nothing, and a set-but-unparseable value aborts
|
fresh bucket or drain another's by rotating a spoofed header (#88)
|
||||||
startup. Before this, any client could mint a fresh bucket or drain
|
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
|
||||||
another's by rotating a spoofed header (#88)
|
settings placeholder removed, a progressive-enhancement copy button for the
|
||||||
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
|
entrypoint URL, and retention form copy that states the actual policy
|
||||||
Profile settings placeholder removed, a progressive-enhancement copy
|
(deletion by the reaper, 0 retains forever) (#57)
|
||||||
button for the entrypoint URL, and retention form copy that states the
|
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
|
||||||
actual policy (deletion by the reaper, 0 retains forever) (#57)
|
the request URL in `*url.Error`, so every transport failure persisted the full
|
||||||
- 2026-08-11 Mask the webhook credential in delivery errors and logs:
|
Slack webhook URL into the per-webhook event database via
|
||||||
Go embeds the request URL in `*url.Error`, so every transport failure
|
`DeliveryResult.Error`, a field a future REST API would have served.
|
||||||
persisted the full Slack webhook URL into the per-webhook event
|
`maskURLError` drops path, query and userinfo while preserving the wrapped
|
||||||
database via `DeliveryResult.Error`, a field a future REST API would
|
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
|
||||||
have served. `maskURLError` drops path, query and userinfo while
|
failures still read differently (#118)
|
||||||
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
|
|
||||||
still work and DNS, TLS and timeout failures still read differently
|
|
||||||
(#118)
|
|
||||||
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
||||||
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
|
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
|
||||||
entrypoint path so one entrypoint cannot exhaust another's budget;
|
path so one entrypoint cannot exhaust another's budget; over-limit requests
|
||||||
over-limit requests get 429 with `Retry-After`. It was the one
|
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
|
||||||
unauthenticated, internet-facing endpoint with no limit at all (#64)
|
endpoint with no limit at all (#64)
|
||||||
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
||||||
`MaxBodySize` is now first in all four form-parsing route groups, so
|
`MaxBodySize` is now first in all four form-parsing route groups, so an
|
||||||
an oversized request is rejected with 413 instead of being read in
|
oversized request is rejected with 413 instead of being read in full by the
|
||||||
full by the CSRF middleware before any cap applied (#90)
|
CSRF middleware before any cap applied (#90)
|
||||||
- 2026-08-11 Mask target config on the source detail page, which
|
- 2026-08-11 Mask target config on the source detail page, which rendered the
|
||||||
rendered the stored blob verbatim and so exposed the Slack
|
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
|
||||||
incoming-webhook URL — a bearer credential that cannot be revoked
|
credential that cannot be revoked per-holder. Config reaches the template only
|
||||||
per-holder. Config reaches the template only as a `TargetView` of
|
as a `TargetView` of labelled fields, and header values are rendered as a
|
||||||
labelled fields, and header values are rendered as a count (#113)
|
count (#113)
|
||||||
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
|
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
|
||||||
sentinel written in `BeforeSave` so the GORM column default cannot
|
written in `BeforeSave` so the GORM column default cannot win the race. Also
|
||||||
win the race. Also bounds the reaper's cutoff arithmetic: day counts
|
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
|
||||||
above 106751 overflowed `time.Duration` and wrapped the cutoff into
|
`time.Duration` and wrapped the cutoff into the future, where every row
|
||||||
the future, where every row matched and the sweep deleted everything
|
matched and the sweep deleted everything (#79)
|
||||||
(#79)
|
|
||||||
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||||
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
|
||||||
requests, with the 7-day absolute cap kept as an independent
|
with the 7-day absolute cap kept as an independent backstop that activity
|
||||||
backstop that activity never extends (#66)
|
never extends (#66)
|
||||||
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
||||||
orphaned `retrying` delivery whose target type no longer supports
|
orphaned `retrying` delivery whose target type no longer supports retries,
|
||||||
retries, recording a `DeliveryResult` with the reason instead of
|
recording a `DeliveryResult` with the reason instead of leaving the delivery
|
||||||
leaving the delivery stuck forever (#82)
|
stuck forever (#82)
|
||||||
- 2026-08-09 Root the delivery engine's worker pool and the retention
|
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
|
||||||
reaper's sweep loop at `context.Background()` rather than the fx
|
sweep loop at `context.Background()` rather than the fx `OnStart` hook context
|
||||||
`OnStart` hook context (#97), which carries fx's 15s start timeout and
|
(#97), which carries fx's 15s start timeout and killed both roughly fifteen
|
||||||
killed both roughly fifteen seconds after boot: the proxy silently
|
seconds after boot: the proxy silently stopped delivering webhooks entirely,
|
||||||
stopped delivering webhooks entirely, and the reaper never ran a
|
and the reaper never ran a single sweep under its default one-hour interval
|
||||||
single sweep under its default one-hour interval
|
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
|
||||||
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
|
`database` target) evicts the cached archive writer and closes its handle
|
||||||
last `database` target) evicts the cached archive writer and closes
|
while deliberately leaving `archive-{webhookID}.db` on disk, and a new
|
||||||
its handle while deliberately leaving `archive-{webhookID}.db` on
|
`ArchiveSweeper` prunes idle archives on the existing
|
||||||
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
|
|
||||||
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
||||||
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||||
environment values: `envInt` removed in favour of `envPositiveInt`
|
environment values: `envInt` removed in favour of `envPositiveInt` plus a
|
||||||
plus a `PORT` range check, `envBool` now parses with
|
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
|
||||||
`strconv.ParseBool`, and defaults apply only to unset variables (#80)
|
defaults apply only to unset variables (#80)
|
||||||
- 2026-08-07 Automatic event retention cleanup based on
|
- 2026-08-07 Automatic event retention cleanup based on `retention_days`,
|
||||||
`retention_days`, deleting expired events, deliveries, and delivery
|
deleting expired events, deliveries, and delivery results from each
|
||||||
results from each per-webhook event database (#63)
|
per-webhook event database (#63)
|
||||||
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||||
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
|
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
|
||||||
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
|
canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||||
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
|
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
|
||||||
all newly surfaced lint findings
|
surfaced lint findings
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
Makefile shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||||
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
|
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
|
||||||
plain HTTP and behind reverse proxies (#54)
|
HTTP and behind reverse proxies (#54)
|
||||||
- 2026-03-17 root path redirects based on auth state (#52)
|
- 2026-03-17 root path redirects based on auth state (#52)
|
||||||
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
|
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
|
||||||
with DNS rebinding defense, and per-IP login rate limiting (#42)
|
rebinding defense, and per-IP login rate limiting (#42)
|
||||||
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||||
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
|
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
|
||||||
absolute dev DATA_DIR default and clarified env docs (#46)
|
DATA_DIR default and clarified env docs (#46)
|
||||||
- 2026-03-05 security headers middleware, session regeneration on
|
- 2026-03-05 security headers middleware, session regeneration on login, request
|
||||||
login, request body size limits (#41)
|
body size limits (#41)
|
||||||
- 2026-03-04 tests for delivery, middleware, and session packages
|
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
|
||||||
(#32); removed the build-architecture global (#31)
|
the build-architecture global (#31)
|
||||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
|
||||||
delivery engine with bounded worker pool and circuit breaker,
|
engine with bounded worker pool and circuit breaker, parallel fan-out,
|
||||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
per-webhook event databases, management UI (#16)
|
||||||
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
|
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
|
||||||
into README (#6)
|
(#6)
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Delivery status and retry management UI. Replay of a terminally
|
- Delivery status and retry management UI. Replay of a terminally failed
|
||||||
failed delivery and per-attempt detail already landed
|
delivery and per-attempt detail already landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/202)
|
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
- Per-webhook rate limiting in the receiver handler (per-webhook config plus
|
||||||
plus handler enforcement; global limits must not apply to receiver
|
handler enforcement; global limits must not apply to receiver endpoints)
|
||||||
endpoints)
|
- API key authentication for programmatic access (APIKey model exists; Bearer
|
||||||
- API key authentication for programmatic access (APIKey model exists;
|
token middleware does not)
|
||||||
Bearer token middleware does not)
|
|
||||||
- REST API v1
|
- REST API v1
|
||||||
- CRUD for webhooks, entrypoints, targets
|
- CRUD for webhooks, entrypoints, targets
|
||||||
- event viewing and filtering endpoints
|
- event viewing and filtering endpoints
|
||||||
@@ -406,9 +370,9 @@ point of the branch.
|
|||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics dashboard: success rates, response times, volume
|
- Analytics dashboard: success rates, response times, volume
|
||||||
- A remember-me option at login
|
- A remember-me option at login
|
||||||
- Password reset flow for a forgotten password over the web. The
|
- Password reset flow for a forgotten password over the web. The authenticated
|
||||||
authenticated password *change* flow already landed, and a lost
|
password _change_ flow already landed, and a lost password is recoverable from
|
||||||
password is recoverable from the console with `webhooker resetpw`
|
the console with `webhooker resetpw`
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ func newApp() *fx.App {
|
|||||||
// or renaming a webhook or target reaches its archive
|
// or renaming a webhook or target reaches its archive
|
||||||
// files.
|
// files.
|
||||||
func(e *delivery.Engine) delivery.Archives { return e },
|
func(e *delivery.Engine) delivery.Archives { return e },
|
||||||
|
// Wire *delivery.Engine as delivery.CircuitBreakers so
|
||||||
|
// the pages can show a target whose deliveries are
|
||||||
|
// paused.
|
||||||
|
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
|
||||||
server.New,
|
server.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(
|
fx.Invoke(
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// ESLint configuration for static/js/. script/lint and the image build run
|
||||||
|
// ESLint in the Dockerfile's js-lint stage, never on the host.
|
||||||
|
//
|
||||||
|
// The rules are the ones the JavaScript styleguide linked from
|
||||||
|
// REPO_POLICIES.md states that a linter can check: const for everything,
|
||||||
|
// let only for a variable that is reassigned, never var.
|
||||||
|
export default [
|
||||||
|
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
|
||||||
|
{ ignores: ["static/js/alpine.min.js"] },
|
||||||
|
{
|
||||||
|
// The pages load static/js/app.js as a classic script, not a module.
|
||||||
|
languageOptions: { sourceType: "script" },
|
||||||
|
rules: {
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -149,6 +149,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
|
||||||
|
// Failed and Pending lists, of the newest events with a delivery in
|
||||||
|
// given statuses, and for their counts (eventsWithStatus and
|
||||||
|
// countEventsWithStatus in the handlers). The lists must also reach
|
||||||
|
// the events table only by ID: from the matching deliveries, then from
|
||||||
|
// the newest of those events.
|
||||||
|
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dry := db.Session(&gorm.Session{DryRun: true})
|
||||||
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
|
pending := []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rows []struct{ ID string }
|
||||||
|
count int64
|
||||||
|
)
|
||||||
|
|
||||||
|
matching := dry.Model(&database.Delivery{}).
|
||||||
|
Distinct("event_id").Where("status IN ?", pending)
|
||||||
|
newest := dry.Table("(?) AS matching", matching).
|
||||||
|
Joins("CROSS JOIN events ON events.id = matching.event_id").
|
||||||
|
Where(
|
||||||
|
"events.webhook_id = ? AND events.deleted_at IS NULL",
|
||||||
|
webhookID,
|
||||||
|
).
|
||||||
|
Order("events.created_at DESC").Limit(50).
|
||||||
|
Select("events.id AS event_id")
|
||||||
|
|
||||||
|
// Each step of the plan is printed in braces, so these name the
|
||||||
|
// lookup that follows each scan.
|
||||||
|
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
|
||||||
|
Joins("CROSS JOIN events ON events.id = newest.event_id").
|
||||||
|
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
|
||||||
|
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Distinct("event_id").Where("status IN ?", pending).Count(&count),
|
||||||
|
byStatus)
|
||||||
|
}
|
||||||
|
|
||||||
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||||
// page's statistics (readEventStats in the handlers): deliveries in
|
// page's statistics (readEventStats in the handlers): deliveries in
|
||||||
// progress, each target's deliveries finished since a time, which must
|
// progress, each target's deliveries finished since a time, which must
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ type Delivery struct {
|
|||||||
// the index.
|
// the index.
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
|
// Replay is set on a delivery created by the event log's Replay
|
||||||
|
// action, so the pages can tell it from the delivery it repeats.
|
||||||
|
Replay bool `gorm:"not null;default:false" json:"replay"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||||
// JSON could loop.
|
// JSON could loop.
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ type Target struct {
|
|||||||
|
|
||||||
// For HTTP targets (max_retries=0 means fire-and-forget,
|
// For HTTP targets (max_retries=0 means fire-and-forget,
|
||||||
// >0 enables retries with backoff)
|
// >0 enables retries with backoff)
|
||||||
MaxRetries int `json:"maxRetries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// Webhook.Targets leads back here, and the JSON could loop.
|
// Webhook.Targets leads back here, and the JSON could loop.
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := env.archivePath(tgt)
|
path := env.archivePath(tgt)
|
||||||
|
seedArchiveFile(t, path, tgt.WebhookID, archivedAt...)
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedArchiveFile creates the archive file at path and inserts one row
|
||||||
|
// per supplied archived-at timestamp, as seedArchiveRows does.
|
||||||
|
func seedArchiveFile(
|
||||||
|
t *testing.T, path, webhookID string, archivedAt ...time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
sqlDB, err := sql.Open(
|
sqlDB, err := sql.Open(
|
||||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||||
@@ -182,7 +193,7 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
for i, at := range archivedAt {
|
for i, at := range archivedAt {
|
||||||
row := delivery.ExportArchivedEvent{
|
row := delivery.ExportArchivedEvent{
|
||||||
EventID: fmt.Sprintf("ev-%d", i),
|
EventID: fmt.Sprintf("ev-%d", i),
|
||||||
WebhookID: tgt.WebhookID,
|
WebhookID: webhookID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Body: `{"seeded":true}`,
|
Body: `{"seeded":true}`,
|
||||||
ArchivedAt: at,
|
ArchivedAt: at,
|
||||||
@@ -191,8 +202,6 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, sqlDB.Close())
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivedEventIDs returns the event ids currently stored in an
|
// archivedEventIDs returns the event ids currently stored in an
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
|||||||
return remaining
|
return remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateAndCooldown returns the circuit state and, while the circuit is
|
||||||
|
// open, what is left of the cooldown, or zero once that has passed.
|
||||||
|
// Both are read under one lock, so they always agree.
|
||||||
|
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
|
||||||
|
cb.mu.Lock()
|
||||||
|
defer cb.mu.Unlock()
|
||||||
|
|
||||||
|
if cb.state != CircuitOpen {
|
||||||
|
return cb.state, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
|
||||||
|
}
|
||||||
|
|
||||||
// RecordSuccess records a successful delivery and resets
|
// RecordSuccess records a successful delivery and resets
|
||||||
// the circuit breaker to closed state.
|
// the circuit breaker to closed state.
|
||||||
func (cb *CircuitBreaker) RecordSuccess() {
|
func (cb *CircuitBreaker) RecordSuccess() {
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ type Archives interface {
|
|||||||
Rename(targetID, webhookName, targetName string) error
|
Rename(targetID, webhookName, targetName string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CircuitBreakers is how the handlers read a target's circuit
|
||||||
|
// breaker, so the webhook page and the event log can say that
|
||||||
|
// deliveries to the target are paused and until when. Like Archives,
|
||||||
|
// it keeps the handlers free of the engine's internals and is
|
||||||
|
// trivially faked in tests.
|
||||||
|
type CircuitBreakers interface {
|
||||||
|
StateAndCooldown(targetID string) (CircuitState, time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
// EngineParams are the fx dependencies for the delivery
|
// EngineParams are the fx dependencies for the delivery
|
||||||
// engine.
|
// engine.
|
||||||
type EngineParams struct {
|
type EngineParams struct {
|
||||||
@@ -186,9 +195,11 @@ type Engine struct {
|
|||||||
// targets maps each target type to its implementation.
|
// targets maps each target type to its implementation.
|
||||||
targets map[database.TargetType]Target
|
targets map[database.TargetType]Target
|
||||||
|
|
||||||
// httpTarget is retained so tests can reach the HTTP
|
// httpTarget and slackTarget are retained so StateAndCooldown
|
||||||
// target's shared client and circuit breakers.
|
// can read their circuit breakers, and so tests can reach the
|
||||||
httpTarget *httpTarget
|
// HTTP target's shared client.
|
||||||
|
httpTarget *httpTarget
|
||||||
|
slackTarget *slackTarget
|
||||||
|
|
||||||
// dbTarget is retained so the engine can reach the archive
|
// dbTarget is retained so the engine can reach the archive
|
||||||
// writer registry for eviction, renames and the idle sweep.
|
// writer registry for eviction, renames and the idle sweep.
|
||||||
@@ -284,12 +295,13 @@ func (e *Engine) EvictTarget(targetID string) {
|
|||||||
e.dbTarget.evict(targetID)
|
e.dbTarget.evict(targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename implements Archives. It renames a database target's
|
// Rename implements Archives. It renames every one of a database
|
||||||
// archive file to ArchiveFileName(webhookName, targetName,
|
// target's archive files to ArchiveFileName(webhookName, targetName,
|
||||||
// targetID), under the lock the target's archive writes and the
|
// targetID), each keeping the period in its name, under the lock the
|
||||||
// idle sweep take. It never replaces a file: if one already has the
|
// target's archive writes and the idle sweep take. It never replaces
|
||||||
// new name, the error is ErrArchiveNameTaken. The caller renames
|
// a file: if one already has a new name, the error is
|
||||||
// before it saves the new name: see databaseTarget.rename.
|
// ErrArchiveNameTaken. The caller renames before it saves the new
|
||||||
|
// name: see databaseTarget.rename.
|
||||||
func (e *Engine) Rename(
|
func (e *Engine) Rename(
|
||||||
targetID, webhookName, targetName string,
|
targetID, webhookName, targetName string,
|
||||||
) error {
|
) error {
|
||||||
@@ -300,6 +312,28 @@ func (e *Engine) Rename(
|
|||||||
return e.dbTarget.rename(targetID, webhookName, targetName)
|
return e.dbTarget.rename(targetID, webhookName, targetName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateAndCooldown implements CircuitBreakers. It returns the state of
|
||||||
|
// the target's circuit breaker and, while the breaker is open, what is
|
||||||
|
// left of its cooldown; the cooldown is zero once that has passed and
|
||||||
|
// in any other state. A target with no breaker reads as closed with no
|
||||||
|
// cooldown, and reading never creates one.
|
||||||
|
func (e *Engine) StateAndCooldown(
|
||||||
|
targetID string,
|
||||||
|
) (CircuitState, time.Duration) {
|
||||||
|
for _, core := range []*httpCore{
|
||||||
|
e.httpTarget.httpCore, e.slackTarget.httpCore,
|
||||||
|
} {
|
||||||
|
val, ok := core.circuitBreakers.Load(targetID)
|
||||||
|
if ok {
|
||||||
|
cb, _ := val.(*CircuitBreaker)
|
||||||
|
|
||||||
|
return cb.StateAndCooldown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircuitClosed, 0
|
||||||
|
}
|
||||||
|
|
||||||
// ScheduleRetry schedules a task to be re-enqueued onto the
|
// ScheduleRetry schedules a task to be re-enqueued onto the
|
||||||
// retry channel after delay. It implements the Scheduler
|
// retry channel after delay. It implements the Scheduler
|
||||||
// interface the targets use to own their durable retries.
|
// interface the targets use to own their durable retries.
|
||||||
|
|||||||
@@ -1018,6 +1018,62 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
|
||||||
|
// reads the state of an http or a slack target's circuit breaker, with
|
||||||
|
// what is left of its cooldown while it is open, and no cooldown while
|
||||||
|
// it is half-open, once it closes, or for a target with no breaker.
|
||||||
|
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
httpID := uuid.New().String()
|
||||||
|
slackID := uuid.New().String()
|
||||||
|
|
||||||
|
state, cooldown := e.StateAndCooldown(httpID)
|
||||||
|
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
|
||||||
|
assert.Zero(t, cooldown, "no breaker")
|
||||||
|
|
||||||
|
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
|
||||||
|
e.ExportSetCircuitBreaker(httpID, httpCB)
|
||||||
|
|
||||||
|
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
|
||||||
|
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
|
||||||
|
|
||||||
|
httpCB.RecordFailure()
|
||||||
|
slackCB.RecordFailure()
|
||||||
|
|
||||||
|
for _, id := range []string{httpID, slackID} {
|
||||||
|
state, cooldown := e.StateAndCooldown(id)
|
||||||
|
assert.Equal(t, delivery.CircuitOpen, state)
|
||||||
|
assert.Greater(t, cooldown, 59*time.Minute)
|
||||||
|
assert.LessOrEqual(t, cooldown, time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpCB.RecordSuccess()
|
||||||
|
slackCB.RecordSuccess()
|
||||||
|
|
||||||
|
for _, id := range []string{httpID, slackID} {
|
||||||
|
state, cooldown := e.StateAndCooldown(id)
|
||||||
|
assert.Equal(t, delivery.CircuitClosed, state, "closed")
|
||||||
|
assert.Zero(t, cooldown, "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A breaker with no cooldown goes half-open on the first Allow
|
||||||
|
// after it trips, letting that one delivery through to test the
|
||||||
|
// target.
|
||||||
|
halfOpenID := uuid.New().String()
|
||||||
|
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
|
||||||
|
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
|
||||||
|
|
||||||
|
halfOpenCB.RecordFailure()
|
||||||
|
require.True(t, halfOpenCB.Allow())
|
||||||
|
|
||||||
|
state, cooldown = e.StateAndCooldown(halfOpenID)
|
||||||
|
assert.Equal(t, delivery.CircuitHalfOpen, state)
|
||||||
|
assert.Zero(t, cooldown, "half-open")
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseHTTPConfig_Valid(t *testing.T) {
|
func TestParseHTTPConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ func (e *Engine) ExportSetCircuitBreaker(
|
|||||||
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
|
||||||
|
// slack target.
|
||||||
|
func (e *Engine) ExportSetSlackCircuitBreaker(
|
||||||
|
targetID string, cb *CircuitBreaker,
|
||||||
|
) {
|
||||||
|
e.slackTarget.circuitBreakers.Store(targetID, cb)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||||
func (e *Engine) ExportParseHTTPConfig(
|
func (e *Engine) ExportParseHTTPConfig(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
@@ -493,23 +501,32 @@ func NewExportArchiveWriter(
|
|||||||
return &ExportArchiveWriter{w: w}
|
return &ExportArchiveWriter{w: w}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write archives a row through the writer.
|
// Write archives a row through the writer, into the file named
|
||||||
|
// without a period.
|
||||||
func (e *ExportArchiveWriter) Write(
|
func (e *ExportArchiveWriter) Write(
|
||||||
row ExportArchivedEvent, expiry time.Duration,
|
row ExportArchivedEvent, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.write(row, expiry)
|
return e.w.write(row, expiry, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePeriod archives a row through the writer, into the file for
|
||||||
|
// period.
|
||||||
|
func (e *ExportArchiveWriter) WritePeriod(
|
||||||
|
row ExportArchivedEvent, expiry time.Duration, period string,
|
||||||
|
) error {
|
||||||
|
return e.w.write(row, expiry, period)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open opens the archive file, pruning when expiry is positive.
|
// Open opens the archive file, pruning when expiry is positive.
|
||||||
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
||||||
return e.w.open(expiry)
|
return e.w.open(e.w.path, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reopen closes and reopens the archive file.
|
// Reopen closes and reopens the archive file.
|
||||||
func (e *ExportArchiveWriter) Reopen(
|
func (e *ExportArchiveWriter) Reopen(
|
||||||
expiry time.Duration,
|
expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.reopen(expiry)
|
return e.w.reopen(e.w.path, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNow replaces the clock the writer measures its reopen
|
// SetNow replaces the clock the writer measures its reopen
|
||||||
@@ -539,7 +556,7 @@ func (e *ExportArchiveWriter) Path() string {
|
|||||||
func (e *ExportArchiveWriter) OpenExisting(
|
func (e *ExportArchiveWriter) OpenExisting(
|
||||||
expiry time.Duration,
|
expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.openMode(archiveModeExisting, expiry)
|
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SweepExpired runs an idle sweep of the archive.
|
// SweepExpired runs an idle sweep of the archive.
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ func (e *Engine) initTargets(client *http.Client) {
|
|||||||
dbT := &databaseTarget{eng: e}
|
dbT := &databaseTarget{eng: e}
|
||||||
|
|
||||||
e.httpTarget = httpT
|
e.httpTarget = httpT
|
||||||
|
e.slackTarget = slackT
|
||||||
e.dbTarget = dbT
|
e.dbTarget = dbT
|
||||||
|
|
||||||
e.targets = map[database.TargetType]Target{
|
e.targets = map[database.TargetType]Target{
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ type TargetConfigForm struct {
|
|||||||
Timeout string
|
Timeout string
|
||||||
// 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 string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTargetConfigForm parses a target's stored configuration into
|
// NewTargetConfigForm parses a target's stored configuration into
|
||||||
@@ -85,11 +87,13 @@ func NewTargetConfigForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseConfigForm parses an archive target's optional expiry.
|
// databaseConfigForm parses an archive target's optional expiry and
|
||||||
// An absent, empty or never expiry yields an empty expiry, on which
|
// rotation. An absent, empty or never expiry yields an empty expiry,
|
||||||
// the edit form starts at never; saving it unchanged stores never,
|
// on which the edit form starts at never; saving it unchanged stores
|
||||||
// which means the same as an empty expiry. An expiry that is set
|
// never, which means the same as an empty expiry. An absent rotation
|
||||||
// but not a valid duration is an error, not a blank field.
|
// is empty too, and the form starts at none. An expiry that is set
|
||||||
|
// but not a valid duration, or a rotation that is not one of the
|
||||||
|
// four, is an error, not a blank field.
|
||||||
func databaseConfigForm(
|
func databaseConfigForm(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
) (TargetConfigForm, error) {
|
) (TargetConfigForm, error) {
|
||||||
@@ -106,8 +110,15 @@ func databaseConfigForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = ValidateArchiveRotation(cfg.Rotation)
|
||||||
|
if err != nil {
|
||||||
|
return TargetConfigForm{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
form := TargetConfigForm{Rotation: cfg.Rotation}
|
||||||
|
|
||||||
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
||||||
return TargetConfigForm{}, nil
|
return form, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidateArchiveExpiry(cfg.Expiry)
|
err = ValidateArchiveExpiry(cfg.Expiry)
|
||||||
@@ -115,5 +126,7 @@ func databaseConfigForm(
|
|||||||
return TargetConfigForm{}, err
|
return TargetConfigForm{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return TargetConfigForm{Expiry: cfg.Expiry}, nil
|
form.Expiry = cfg.Expiry
|
||||||
|
|
||||||
|
return form, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,34 +173,29 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
|
|
||||||
fields = append(fields, maxRetriesField(t))
|
fields = append(fields, maxRetriesField(t))
|
||||||
|
|
||||||
if t.MaxQueueSize > 0 {
|
|
||||||
fields = append(fields, ConfigField{
|
|
||||||
Label: "Max Queue Size",
|
|
||||||
Value: strconv.Itoa(t.MaxQueueSize),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxRetriesField describes a target's retry count, which lives
|
// maxRetriesField describes a target's retry count, which lives
|
||||||
// on the target row rather than in its configuration blob.
|
// on the target row rather than in its configuration blob. A
|
||||||
|
// stored 0 makes a single attempt, so it is shown as 1.
|
||||||
func maxRetriesField(t *database.Target) ConfigField {
|
func maxRetriesField(t *database.Target) ConfigField {
|
||||||
retries := strconv.Itoa(t.MaxRetries)
|
attempts := strconv.Itoa(t.MaxRetries)
|
||||||
if t.MaxRetries == 0 {
|
if t.MaxRetries == 0 {
|
||||||
retries += " (fire-and-forget)"
|
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
|
||||||
}
|
}
|
||||||
|
|
||||||
return ConfigField{
|
return ConfigField{
|
||||||
Label: "Max Retries",
|
Label: "Delivery attempts",
|
||||||
Value: retries,
|
Value: attempts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseConfigFields describes an archive target by its
|
// databaseConfigFields describes an archive target by its
|
||||||
// expiry in plain units, such as "30 days", or "never" when
|
// expiry in plain units, such as "30 days", or "never" when
|
||||||
// the archive is kept forever. An expiry that is set but not
|
// the archive is kept forever, and by its rotation. An expiry
|
||||||
// a valid duration is reported as unavailable rather than
|
// that is set but not a valid duration, or a rotation that is
|
||||||
|
// not one of the four, is reported as unavailable rather than
|
||||||
// echoed back.
|
// echoed back.
|
||||||
func databaseConfigFields(configJSON string) []ConfigField {
|
func databaseConfigFields(configJSON string) []ConfigField {
|
||||||
expiry, err := parseArchiveExpiry(configJSON)
|
expiry, err := parseArchiveExpiry(configJSON)
|
||||||
@@ -208,14 +203,22 @@ func databaseConfigFields(configJSON string) []ConfigField {
|
|||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rotation, err := parseArchiveRotation(configJSON)
|
||||||
|
if err != nil {
|
||||||
|
return unavailableConfigFields()
|
||||||
|
}
|
||||||
|
|
||||||
value := archiveExpiryNever
|
value := archiveExpiryNever
|
||||||
if expiry > 0 {
|
if expiry > 0 {
|
||||||
value = plainDuration(expiry)
|
value = plainDuration(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Archive Expiry",
|
Label: "Archive expiry",
|
||||||
Value: value,
|
Value: value,
|
||||||
|
}, {
|
||||||
|
Label: "Archive rotation",
|
||||||
|
Value: rotation,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
viewMaxRetries = "Max Retries"
|
viewMaxRetries = "Delivery attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -190,21 +190,19 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Webhook URL": slackMaskedURL,
|
"Webhook URL": slackMaskedURL,
|
||||||
viewMaxRetries: "0 (fire-and-forget)",
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
||||||
// its retry count the same way an HTTP target does, and no
|
// its retry count the same way an HTTP target does.
|
||||||
// queue size even when one is stored: delivery never reads it.
|
|
||||||
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
target := slackTarget()
|
target := slackTarget()
|
||||||
target.MaxRetries = 2
|
target.MaxRetries = 2
|
||||||
target.MaxQueueSize = 100
|
|
||||||
|
|
||||||
view := viewFor(t, target)
|
view := viewFor(t, target)
|
||||||
|
|
||||||
@@ -226,8 +224,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
Config: `{"url":"` + viewExampleHook + `",` +
|
Config: `{"url":"` + viewExampleHook + `",` +
|
||||||
`"timeout":30,` +
|
`"timeout":30,` +
|
||||||
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
||||||
MaxRetries: 5,
|
MaxRetries: 5,
|
||||||
MaxQueueSize: 100,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
fields := fieldMap(view.Config)
|
||||||
@@ -239,7 +236,6 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
viewMaxRetries: "5",
|
viewMaxRetries: "5",
|
||||||
"Max Queue Size": "100",
|
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
@@ -262,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
viewMaxRetries: "0 (fire-and-forget)",
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -332,13 +328,49 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{"Archive Expiry": tc.want},
|
map[string]string{
|
||||||
|
"Archive expiry": tc.want,
|
||||||
|
"Archive rotation": rotationNone,
|
||||||
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewTargetViews_DatabaseRotation proves the target list shows a
|
||||||
|
// database target's rotation, none when it has none stored.
|
||||||
|
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each stored config, and the rotation the list shows for it.
|
||||||
|
tests := map[string]string{
|
||||||
|
"": rotationNone,
|
||||||
|
`{"rotation":""}`: rotationNone,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rotation := range []string{
|
||||||
|
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
||||||
|
} {
|
||||||
|
tests[`{"rotation":"`+rotation+`"}`] = rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
for config, want := range tests {
|
||||||
|
t.Run(config, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
view := viewFor(t, database.Target{
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Config: config,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, want, fieldMap(view.Config)["Archive rotation"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewTargetViews_Log(t *testing.T) {
|
func TestNewTargetViews_Log(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -386,6 +418,10 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"a fortnight"}`,
|
Config: `{"expiry":"a fortnight"}`,
|
||||||
},
|
},
|
||||||
|
"invalid archive rotation": {
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Config: weeklyConfig,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, target := range tests {
|
for name, target := range tests {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const archiveNameMaxLen = 40
|
|||||||
// from the per-webhook event database. The event is already
|
// from the per-webhook event database. The event is already
|
||||||
// persisted in the per-webhook event DB by the time delivery runs;
|
// persisted in the per-webhook event DB by the time delivery runs;
|
||||||
// the database target additionally writes a durable long-term copy
|
// the database target additionally writes a durable long-term copy
|
||||||
// into the file ArchiveFileName names and then records a single
|
// into the file ArchiveFileName names, with a period added when the
|
||||||
|
// target rotates (see archivePeriodPath), and then records a single
|
||||||
// attempt whose outcome reflects whether the archive write
|
// attempt whose outcome reflects whether the archive write
|
||||||
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
||||||
// and expiry semantics.
|
// and expiry semantics.
|
||||||
@@ -146,8 +147,9 @@ func (t *databaseTarget) Deliver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// archive writes the full event as a row into the target's
|
// archive writes the full event as a row into the target's
|
||||||
// archive database, honouring the optional per-target expiry
|
// archive database, honouring the optional per-target expiry and
|
||||||
// parsed from the target config JSON.
|
// rotation parsed from the target config JSON. With rotation, the
|
||||||
|
// event goes to the file for the period of its receive time.
|
||||||
func (t *databaseTarget) archive(d *database.Delivery) error {
|
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||||
webhookID := d.Event.WebhookID
|
webhookID := d.Event.WebhookID
|
||||||
if webhookID == "" {
|
if webhookID == "" {
|
||||||
@@ -159,6 +161,19 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rotation, err := parseArchiveRotation(d.Target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// An event whose stored row was gone before its delivery ran has
|
||||||
|
// no receive time (see Engine.hydrateEvent), and goes to the
|
||||||
|
// file for now.
|
||||||
|
receivedAt := d.Event.CreatedAt
|
||||||
|
if receivedAt.IsZero() {
|
||||||
|
receivedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
w, err := t.writerFor(d.TargetID)
|
w, err := t.writerFor(d.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -174,7 +189,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
ContentType: d.Event.ContentType,
|
ContentType: d.Event.ContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.write(row, expiry)
|
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
// writerFor returns the archive writer for a database target,
|
// writerFor returns the archive writer for a database target,
|
||||||
@@ -275,10 +290,10 @@ func (t *databaseTarget) releaseSweepWriter(
|
|||||||
delete(t.writers, targetID)
|
delete(t.writers, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWriter builds the writer for a database target's archive. The
|
// newWriter builds the writer for a database target's archive. Its
|
||||||
// file is the one ArchivePath gives for the webhook and the target as
|
// path is the one ArchivePath gives for the webhook and the target as
|
||||||
// the main database names them now; from then on only rename changes
|
// the main database names them now; from then on only rename changes
|
||||||
// the name the writer uses. It does not touch the archive file.
|
// the name the writer uses. It does not touch the archive files.
|
||||||
func (t *databaseTarget) newWriter(
|
func (t *databaseTarget) newWriter(
|
||||||
targetID string,
|
targetID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
@@ -306,10 +321,10 @@ func (t *databaseTarget) newWriter(
|
|||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename moves a database target's archive file to the name for
|
// rename moves every one of a database target's archive files to the
|
||||||
// webhookName and targetName. It goes through the target's writer,
|
// name for webhookName and targetName. It goes through the target's
|
||||||
// so the move holds the lock that writes and the idle sweep take,
|
// writer, so the move holds the lock that writes and the idle sweep
|
||||||
// and later writes use the new name.
|
// take, and later writes use the new name.
|
||||||
//
|
//
|
||||||
// The writer is created if there is none, and it stays cached. The
|
// The writer is created if there is none, and it stays cached. The
|
||||||
// handlers rename before they save the new name, so until the save
|
// handlers rename before they save the new name, so until the save
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ type databaseTargetConfig struct {
|
|||||||
// archived rows are pruned, or "never" (the default) to
|
// archived rows are pruned, or "never" (the default) to
|
||||||
// keep them forever.
|
// keep them forever.
|
||||||
Expiry string `json:"expiry"`
|
Expiry string `json:"expiry"`
|
||||||
|
|
||||||
|
// Rotation is none (the default), monthly, daily or hourly: see
|
||||||
|
// archivePeriod.
|
||||||
|
Rotation string `json:"rotation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivedEvent is one fully captured webhook event stored in a
|
// archivedEvent is one fully captured webhook event stored in a
|
||||||
@@ -178,7 +182,7 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveWriter owns one database target's archive SQLite file.
|
// archiveWriter owns one database target's archive SQLite files.
|
||||||
// It serialises writes, and after each write closes and reopens
|
// It serialises writes, and after each write closes and reopens
|
||||||
// the file (debounced to at most once per debounce window) so
|
// the file (debounced to at most once per debounce window) so
|
||||||
// an operator can move the file away for offline archiving. The
|
// an operator can move the file away for offline archiving. The
|
||||||
@@ -186,8 +190,16 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
// file is opened create-if-missing and its schema is migrated
|
// file is opened create-if-missing and its schema is migrated
|
||||||
// on every open.
|
// on every open.
|
||||||
type archiveWriter struct {
|
type archiveWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
path string
|
|
||||||
|
// path is the target's archive file as ArchivePath names it. A
|
||||||
|
// target that rotates writes to the files archivePeriodPath names
|
||||||
|
// for path and a period instead.
|
||||||
|
path string
|
||||||
|
|
||||||
|
// current is the file db is open on.
|
||||||
|
current string
|
||||||
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
debounce time.Duration
|
debounce time.Duration
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -236,12 +248,14 @@ func newArchiveWriter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write appends the event as a row, then applies the debounced
|
// write appends the event as a row to the archive file for period
|
||||||
// close/reopen. It recreates the archive file if it was moved
|
// (see archivePeriodPath), then applies the debounced close/reopen.
|
||||||
// or removed since the last open. A positive expiry prunes rows
|
// When period names a different file from the one open, the open one
|
||||||
// older than it on each (re)open.
|
// is closed first. It recreates the archive file if it was moved or
|
||||||
|
// removed since the last open. A positive expiry prunes rows older
|
||||||
|
// than it on each (re)open.
|
||||||
func (w *archiveWriter) write(
|
func (w *archiveWriter) write(
|
||||||
row archivedEvent, expiry time.Duration,
|
row archivedEvent, expiry time.Duration, period string,
|
||||||
) error {
|
) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -252,8 +266,10 @@ func (w *archiveWriter) write(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.db == nil || !fileExists(w.path) {
|
file := archivePeriodPath(w.path, period)
|
||||||
err := w.reopen(expiry)
|
|
||||||
|
if w.db == nil || w.current != file || !fileExists(file) {
|
||||||
|
err := w.reopen(file, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -264,41 +280,41 @@ func (w *archiveWriter) write(
|
|||||||
err := w.db.Create(&row).Error
|
err := w.db.Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"archiving event to %s: %w", w.path, err,
|
"archiving event to %s: %w", file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.now().Sub(w.lastReopen) >= w.debounce {
|
if w.now().Sub(w.lastReopen) >= w.debounce {
|
||||||
return w.reopen(expiry)
|
return w.reopen(file, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// open opens (creating if missing) the archive file, migrates
|
// open opens (creating if missing) an archive file, migrates
|
||||||
// its schema, records the reopen time, and prunes expired rows
|
// its schema, records the reopen time, and prunes expired rows
|
||||||
// when expiry is positive.
|
// when expiry is positive.
|
||||||
func (w *archiveWriter) open(expiry time.Duration) error {
|
func (w *archiveWriter) open(file string, expiry time.Duration) error {
|
||||||
return w.openMode(archiveModeCreate, expiry)
|
return w.openMode(file, archiveModeCreate, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openMode opens the archive file with the given SQLite URI
|
// openMode opens an archive file with the given SQLite URI
|
||||||
// mode, migrates its schema, records the reopen time, and
|
// mode, migrates its schema, records the reopen time, and
|
||||||
// prunes expired rows when expiry is positive. The write path
|
// prunes expired rows when expiry is positive. The write path
|
||||||
// passes archiveModeCreate so a missing file is recreated; the
|
// passes archiveModeCreate so a missing file is recreated; the
|
||||||
// idle sweep passes archiveModeExisting so a missing file is an
|
// idle sweep passes archiveModeExisting so a missing file is an
|
||||||
// error rather than a newly conjured empty archive.
|
// error rather than a newly conjured empty archive.
|
||||||
func (w *archiveWriter) openMode(
|
func (w *archiveWriter) openMode(
|
||||||
mode string, expiry time.Duration,
|
file, mode string, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
// Opened through database.OpenSQLite so an archive file carries
|
// Opened through database.OpenSQLite so an archive file carries
|
||||||
// the same WAL journaling, busy timeout, immediate-transaction
|
// the same WAL journaling, busy timeout, immediate-transaction
|
||||||
// locking, and pool bounds as every other database file. See
|
// locking, and pool bounds as every other database file. See
|
||||||
// internal/database/sqlite_open.go.
|
// internal/database/sqlite_open.go.
|
||||||
sqlDB, err := database.OpenSQLite(w.path, mode)
|
sqlDB, err := database.OpenSQLite(file, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"opening archive database %s: %w", w.path, err,
|
"opening archive database %s: %w", file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +330,7 @@ func (w *archiveWriter) openMode(
|
|||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"connecting to archive database %s: %w",
|
"connecting to archive database %s: %w",
|
||||||
w.path, err,
|
file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,11 +339,12 @@ func (w *archiveWriter) openMode(
|
|||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"migrating archive database %s: %w", w.path, err,
|
"migrating archive database %s: %w", file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.db = gdb
|
w.db = gdb
|
||||||
|
w.current = file
|
||||||
w.lastReopen = w.now()
|
w.lastReopen = w.now()
|
||||||
w.reopens++
|
w.reopens++
|
||||||
|
|
||||||
@@ -338,12 +355,12 @@ func (w *archiveWriter) openMode(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reopen closes any open handle and opens the file afresh. The
|
// reopen closes any open handle and opens file afresh. The
|
||||||
// fresh open recreates the file if it was moved away.
|
// fresh open recreates the file if it was moved away.
|
||||||
func (w *archiveWriter) reopen(expiry time.Duration) error {
|
func (w *archiveWriter) reopen(file string, expiry time.Duration) error {
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
return w.open(expiry)
|
return w.open(file, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// close closes the underlying handle, if any.
|
// close closes the underlying handle, if any.
|
||||||
@@ -360,22 +377,56 @@ func (w *archiveWriter) close() {
|
|||||||
w.db = nil
|
w.db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepExpired prunes an archive that may have gone idle, with
|
// sweepExpired prunes the target's archive files, which may have
|
||||||
// no write to trigger the usual on-reopen prune. It takes the
|
// gone idle, with no write to trigger the usual on-reopen prune. It
|
||||||
// writer's own mutex for the whole operation, so a sweep is
|
// lists the files under the writer's own mutex, then takes the mutex
|
||||||
// ordered against concurrent writes rather than reaching around
|
// again for one file at a time, so a write waits for at most one
|
||||||
// them to the file.
|
// file's prune, and each prune is ordered against concurrent writes
|
||||||
|
// rather than reaching around them to the file.
|
||||||
//
|
//
|
||||||
// It never creates the archive file: a missing file is skipped,
|
// It never creates an archive file: it prunes only the files
|
||||||
// and the reopen uses archiveModeExisting so SQLite itself
|
// archiveFiles lists, skips one that is gone by the time it is
|
||||||
// refuses to create one if the file disappears between the
|
// reached (moved away, or renamed since the listing), and opens each
|
||||||
// check and the open.
|
// with archiveModeExisting so SQLite itself refuses to create one if
|
||||||
|
// the file disappears between the check and the open. A file named
|
||||||
|
// for a period that the prune leaves empty is deleted.
|
||||||
//
|
//
|
||||||
// The archive is left CLOSED afterwards. An idle archive holding
|
// The archive is left CLOSED afterwards. An idle archive holding
|
||||||
// no handle is what keeps the operator's move-the-file-away
|
// no handle is what keeps the operator's move-the-file-away
|
||||||
// workflow working; the next write reopens (and recreates) the
|
// workflow working; the next write reopens (and recreates) the
|
||||||
// file as it always has.
|
// file as it always has.
|
||||||
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
files, err := archiveFiles(w.path)
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
err = w.sweepFile(file, expiry)
|
||||||
|
if errors.Is(err, errArchiveWriterEvicted) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepFile prunes one of the target's archive files for sweepExpired,
|
||||||
|
// holding w.mu while it does. It skips a file that is gone, and deletes
|
||||||
|
// the file, with its -wal and -shm, when it is named for a period and
|
||||||
|
// the prune leaves it empty.
|
||||||
|
func (w *archiveWriter) sweepFile(
|
||||||
|
file archiveFile, expiry time.Duration,
|
||||||
|
) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -385,7 +436,7 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileExists(w.path) {
|
if !fileExists(file.path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,26 +444,56 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
// freshly opened file, matching the write path's semantics.
|
// freshly opened file, matching the write path's semantics.
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
err := w.openMode(archiveModeExisting, expiry)
|
err := w.openMode(file.path, archiveModeExisting, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if file.period == "" {
|
||||||
|
w.close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows int64
|
||||||
|
|
||||||
|
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
|
||||||
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"counting rows in archive %s: %w", file.path, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
err = os.Remove(file.path + suffix)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("deleting empty archive file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.log.Info("deleted empty archive file", "path", file.path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename gives the archive file a new name in the same directory,
|
// rename gives every one of the target's archive files the new
|
||||||
// and the writer uses the file under that name from now on. The
|
// name, keeping the period in the name of each (see
|
||||||
// handle is closed first, which folds the -wal into the .db; any
|
// archivePeriodPath), and the writer uses the files under that name
|
||||||
// -wal or -shm still beside the file (left by a crash) is moved with
|
// from now on. The handle is closed first, which folds the -wal into
|
||||||
// it, because SQLite finds them by name. A missing file is not an
|
// the .db; any -wal or -shm still beside a file (left by a crash) is
|
||||||
// error: the operator may have moved it away, and the next write
|
// moved with it, because SQLite finds them by name. A target with no
|
||||||
// creates it under the new name.
|
// files is not an error: the operator may have moved them away, and
|
||||||
|
// the next write creates its file under the new name.
|
||||||
//
|
//
|
||||||
// If a file already has the new name, nothing is moved and the
|
// If a file already has one of the new names, nothing is moved and
|
||||||
// error is ErrArchiveNameTaken. If one file fails to move, those
|
// the error is ErrArchiveNameTaken. If one file fails to move, those
|
||||||
// already moved are moved back before the error is returned, so the
|
// already moved are moved back before the error is returned, so the
|
||||||
// archive is never split across two names.
|
// archive is never split across two names.
|
||||||
func (w *archiveWriter) rename(name string) error {
|
func (w *archiveWriter) rename(name string) error {
|
||||||
@@ -430,38 +511,53 @@ func (w *archiveWriter) rename(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
suffixes := []string{"", "-wal", "-shm"}
|
files, err := archiveFiles(w.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, suffix := range suffixes {
|
// from[i] moves to to[i].
|
||||||
if fileExists(path + suffix) {
|
var from, to []string
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
renamed := archivePeriodPath(path, file.period)
|
||||||
|
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
from = append(from, file.path+suffix)
|
||||||
|
to = append(to, renamed+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, taken := range to {
|
||||||
|
if fileExists(taken) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
for i, suffix := range suffixes {
|
for i := range from {
|
||||||
err := os.Rename(w.path+suffix, path+suffix)
|
err = os.Rename(from[i], to[i])
|
||||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, moved := range suffixes[:i] {
|
for j := range i {
|
||||||
backErr := os.Rename(path+moved, w.path+moved)
|
backErr := os.Rename(to[j], from[j])
|
||||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
||||||
w.log.Error(
|
w.log.Error(
|
||||||
"failed to move archive file back",
|
"failed to move archive file back",
|
||||||
"from", path+moved,
|
"from", to[j],
|
||||||
"to", w.path+moved,
|
"to", from[j],
|
||||||
"error", backErr,
|
"error", backErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
|
"renaming archive %s to %s: %w", from[i], to[i], err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +595,7 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
w.log.Error(
|
w.log.Error(
|
||||||
"failed to prune expired archive rows",
|
"failed to prune expired archive rows",
|
||||||
"path", w.path,
|
"path", w.current,
|
||||||
"error", res.Error,
|
"error", res.Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -509,48 +605,76 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
if res.RowsAffected > 0 {
|
if res.RowsAffected > 0 {
|
||||||
w.log.Info(
|
w.log.Info(
|
||||||
"pruned expired archive rows",
|
"pruned expired archive rows",
|
||||||
"path", w.path,
|
"path", w.current,
|
||||||
"rows_deleted", res.RowsAffected,
|
"rows_deleted", res.RowsAffected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||||
// file says about it.
|
// files says about them.
|
||||||
type ArchiveFileInfo struct {
|
type ArchiveFileInfo struct {
|
||||||
// Size is the bytes on disk of the file and its -wal together.
|
// Files counts the files.
|
||||||
|
Files int
|
||||||
|
|
||||||
|
// Size is the bytes on disk of the files and their -wal together.
|
||||||
Size int64
|
Size int64
|
||||||
|
|
||||||
// Written is when the file or its -wal was last modified, whichever
|
// Written is when a file or a -wal was last modified, whichever is
|
||||||
// is later: a write lands in the -wal first.
|
// latest: a write lands in the -wal first.
|
||||||
Written time.Time
|
Written time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatArchive reads the metadata of the archive file at path and of
|
// StatArchive reads the metadata of a database target's archive
|
||||||
// its -wal, without opening the archive. With no file at path, which is
|
// files, given the path ArchivePath gives it (see archiveFiles), and
|
||||||
// so before the first write and after the operator moved it away, the
|
// of their -wal, without opening them. With no files, which is so
|
||||||
|
// before the first write and after the operator moved them away, the
|
||||||
// error wraps fs.ErrNotExist.
|
// error wraps fs.ErrNotExist.
|
||||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||||
file, err := os.Stat(path)
|
files, err := archiveFiles(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ArchiveFileInfo{}, err
|
return ArchiveFileInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
var info ArchiveFileInfo
|
||||||
|
|
||||||
wal, err := os.Stat(path + "-wal")
|
for _, file := range files {
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
db, err := os.Stat(file.path)
|
||||||
return info, nil
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Files++
|
||||||
|
info.Size += db.Size()
|
||||||
|
|
||||||
|
if db.ModTime().After(info.Written) {
|
||||||
|
info.Written = db.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
wal, err := os.Stat(file.path + "-wal")
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Size += wal.Size()
|
||||||
|
|
||||||
|
if wal.ModTime().After(info.Written) {
|
||||||
|
info.Written = wal.ModTime()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if info.Files == 0 {
|
||||||
return ArchiveFileInfo{}, err
|
return ArchiveFileInfo{}, fmt.Errorf(
|
||||||
}
|
"no archive file for %s: %w", path, fs.ErrNotExist,
|
||||||
|
)
|
||||||
info.Size += wal.Size()
|
|
||||||
|
|
||||||
if wal.ModTime().After(info.Written) {
|
|
||||||
info.Written = wal.ModTime()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -50,21 +54,46 @@ func ArchiveExportFileName(
|
|||||||
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveExport is a database target's archive opened for download.
|
// ArchiveExport is a database target's archive listed for download. It
|
||||||
// It reads the file on its own connection, inside one read-only
|
// opens one of the target's files at a time, only when its rows are
|
||||||
// transaction, so it writes out the archive as it stood when
|
// about to be written out, and closes it before it opens the next, so
|
||||||
// OpenArchiveExport returned.
|
// an export holds at most one file open however many the target has.
|
||||||
//
|
//
|
||||||
// Archives are in WAL mode, where a reader works from a snapshot and
|
// Each file is read on its own connection inside one read-only
|
||||||
// never blocks a writer: archive writes go on while an export is open,
|
// transaction, so its rows are written out as the file stood when it
|
||||||
// and the export does not see them. SQLite cannot checkpoint the -wal
|
// was opened. Archives are in WAL mode, where a reader works from a
|
||||||
// past an open snapshot, so the -wal grows until the export is closed.
|
// snapshot and never blocks a writer: archive writes go on while a file
|
||||||
|
// is open, and the export does not see them. SQLite cannot checkpoint
|
||||||
|
// a -wal past an open snapshot, so the open file's -wal grows until the
|
||||||
|
// export has written that file out.
|
||||||
type ArchiveExport struct {
|
type ArchiveExport struct {
|
||||||
|
// periods are the periods of the target's files when the export
|
||||||
|
// was listed, "" for the file without one, in the order
|
||||||
|
// archiveFiles lists them.
|
||||||
|
periods []string
|
||||||
|
|
||||||
|
// lock is held while currentPath is called and a file is opened,
|
||||||
|
// so that a rename, which holds it too, cannot move the file in
|
||||||
|
// between.
|
||||||
|
lock sync.Locker
|
||||||
|
|
||||||
|
// currentPath returns the path ArchivePath gives the target under
|
||||||
|
// the names stored for it now, which a rename may have changed since
|
||||||
|
// the export was listed.
|
||||||
|
currentPath func() (string, error)
|
||||||
|
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportFile is one archive file opened for an export.
|
||||||
|
type exportFile struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
tx *gorm.DB
|
tx *gorm.DB
|
||||||
|
|
||||||
// empty is true when there is nothing to read: no file, or a file
|
// period is the period in the file's name, "" for none.
|
||||||
// without the archive's table yet.
|
period string
|
||||||
|
|
||||||
|
// empty is true for a file without the archive's table yet.
|
||||||
empty bool
|
empty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,26 +103,46 @@ type exportedName struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenArchiveExport opens the archive file at path for export and
|
// NewArchiveExport lists a database target's archive files for export,
|
||||||
// takes the snapshot the export reads. It never creates the file: with
|
// given the path ArchivePath gives it (see archiveFiles). It opens none
|
||||||
// no file at path, the export has no rows.
|
// of them. Its caller holds lock, which every rename of the target's
|
||||||
|
// files runs under, from reading the names path is made of until it
|
||||||
|
// returns, so the files it lists are the ones those names give.
|
||||||
//
|
//
|
||||||
// Once it has returned, the file is open, so a rename or a move of it
|
// WriteGzipJSON, called without lock held, finds each file again by its
|
||||||
// does not affect the export, which reads the same file under its new
|
// period under the path currentPath gives, holding lock while it does
|
||||||
// name.
|
// and while it opens the file, so a rename during the export loses no
|
||||||
//
|
// file. A file that is gone by then, emptied by the sweep or moved
|
||||||
// The transaction lasts as long as ctx does, so ctx must last for the
|
// away, is skipped. The export never creates a file: with no files, it
|
||||||
// whole export.
|
// has no rows.
|
||||||
func OpenArchiveExport(
|
func NewArchiveExport(
|
||||||
ctx context.Context, path string, log *slog.Logger,
|
path string,
|
||||||
|
lock sync.Locker,
|
||||||
|
currentPath func() (string, error),
|
||||||
|
log *slog.Logger,
|
||||||
) (*ArchiveExport, error) {
|
) (*ArchiveExport, error) {
|
||||||
if !fileExists(path) {
|
files, err := archiveFiles(path)
|
||||||
return &ArchiveExport{empty: true}, nil
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := database.OpenSQLite(path, archiveModeExisting)
|
x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
x.periods = append(x.periods, file.period)
|
||||||
|
}
|
||||||
|
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openExportFile opens one archive file for an export and takes its
|
||||||
|
// snapshot. The transaction lasts as long as ctx does.
|
||||||
|
func openExportFile(
|
||||||
|
ctx context.Context, file archiveFile, log *slog.Logger,
|
||||||
|
) (*exportFile, error) {
|
||||||
|
db, err := database.OpenSQLite(file.path, archiveModeExisting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
@@ -106,7 +155,7 @@ func OpenArchiveExport(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("opening archive %s: %w", path, err)
|
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadOnly makes the driver begin a deferred transaction in place
|
// ReadOnly makes the driver begin a deferred transaction in place
|
||||||
@@ -116,7 +165,9 @@ func OpenArchiveExport(
|
|||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error)
|
return nil, fmt.Errorf(
|
||||||
|
"reading archive %s: %w", file.path, tx.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The transaction's first read is what takes the snapshot.
|
// The transaction's first read is what takes the snapshot.
|
||||||
@@ -127,22 +178,27 @@ func OpenArchiveExport(
|
|||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf("reading archive %s: %w", path, err)
|
return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil
|
return &exportFile{
|
||||||
|
db: db, tx: tx, period: file.period, empty: tables == 0,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
||||||
// webhook and target, each an id and a name; exported_at; and
|
// webhook and target, each an id and a name; exported_at; and
|
||||||
// archived_events, one object per archived row, keyed by column name.
|
// archived_events, one object per archived row, keyed by column name,
|
||||||
// A body that is not valid UTF-8 cannot be a JSON string, so it is
|
// the files in the order archiveFiles lists them. A row from a file
|
||||||
// written in base64, with "body_encoding": "base64" beside it.
|
// named for a period has "period" beside its columns. A body that is
|
||||||
|
// not valid UTF-8 cannot be a JSON string, so it is written in base64,
|
||||||
|
// with "body_encoding": "base64" beside it.
|
||||||
//
|
//
|
||||||
// Each row is written out before the next is read, so neither the
|
// Each row is written out before the next is read, so neither the
|
||||||
// archive nor its JSON is ever held in memory whole. After an error
|
// archive nor its JSON is ever held in memory whole, and each file is
|
||||||
// the gzip stream is left unfinished, so what was written does not
|
// closed once its rows are written, before the next is opened. When it
|
||||||
// decompress as a whole file.
|
// returns, no file is open. After an error the gzip stream is left
|
||||||
|
// unfinished, so what was written does not decompress as a whole file.
|
||||||
func (x *ArchiveExport) WriteGzipJSON(
|
func (x *ArchiveExport) WriteGzipJSON(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
w io.Writer,
|
w io.Writer,
|
||||||
@@ -169,15 +225,35 @@ func (x *ArchiveExport) WriteGzipJSON(
|
|||||||
return zw.Close()
|
return zw.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close ends the export's transaction and closes its connection.
|
// openFile finds the target's archive file for period under the path
|
||||||
func (x *ArchiveExport) Close() error {
|
// currentPath gives now, and opens it for the export, holding x.lock
|
||||||
if x.db == nil {
|
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
|
||||||
return nil
|
func (x *ArchiveExport) openFile(
|
||||||
|
ctx context.Context, period string,
|
||||||
|
) (*exportFile, error) {
|
||||||
|
x.lock.Lock()
|
||||||
|
defer x.lock.Unlock()
|
||||||
|
|
||||||
|
path, err := x.currentPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("finding archive file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = x.tx.Rollback()
|
file := archiveFile{path: archivePeriodPath(path, period), period: period}
|
||||||
|
|
||||||
return x.db.Close()
|
_, err = os.Stat(file.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return openExportFile(ctx, file, x.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// close ends the file's transaction and closes its connection.
|
||||||
|
func (f *exportFile) close() error {
|
||||||
|
_ = f.tx.Rollback()
|
||||||
|
|
||||||
|
return f.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSON writes head with archived_events added as its last key,
|
// writeJSON writes head with archived_events added as its last key,
|
||||||
@@ -207,46 +283,77 @@ func (x *ArchiveExport) writeJSON(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRows writes each archived row to w, oldest first, one per line,
|
// writeRows writes the archived rows of each file to w, one per line,
|
||||||
// separated by commas.
|
// separated by commas, opening each file in turn and closing it once
|
||||||
|
// its rows are written.
|
||||||
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
||||||
if x.empty {
|
sep := "\n"
|
||||||
return nil
|
|
||||||
|
for _, period := range x.periods {
|
||||||
|
f, err := x.openFile(ctx, period)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sep, err = f.writeRows(ctx, w, sep)
|
||||||
|
|
||||||
|
err = errors.Join(err, f.close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := x.tx.WithContext(ctx).
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRows writes the file's archived rows to w, oldest first, the
|
||||||
|
// first after sep and each other after ",\n". It returns what goes
|
||||||
|
// before the next row: sep again when the file had no rows.
|
||||||
|
func (f *exportFile) writeRows(
|
||||||
|
ctx context.Context, w io.Writer, sep string,
|
||||||
|
) (string, error) {
|
||||||
|
if f.empty {
|
||||||
|
return sep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := f.tx.WithContext(ctx).
|
||||||
Model(&archivedEvent{}).Order("id").Rows()
|
Model(&archivedEvent{}).Order("id").Rows()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for sep := "\n"; rows.Next(); sep = ",\n" {
|
for ; rows.Next(); sep = ",\n" {
|
||||||
var ev archivedEvent
|
var ev archivedEvent
|
||||||
|
|
||||||
err = x.tx.ScanRows(rows, &ev)
|
err = f.tx.ScanRows(rows, &ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.WriteString(w, sep)
|
_, err = io.WriteString(w, sep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = writeRow(w, &ev)
|
err = writeRow(w, &ev, f.period)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.Err()
|
return sep, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRow writes an archived row to w as a JSON object keyed by
|
// writeRow writes an archived row to w as a JSON object keyed by
|
||||||
// column name, its body in base64 when it is not valid UTF-8.
|
// column name, its body in base64 when it is not valid UTF-8, with
|
||||||
func writeRow(w io.Writer, ev *archivedEvent) error {
|
// the period of its file beside them unless that is "".
|
||||||
|
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
||||||
row := map[string]any{
|
row := map[string]any{
|
||||||
"id": ev.ID,
|
"id": ev.ID,
|
||||||
"event_id": ev.EventID,
|
"event_id": ev.EventID,
|
||||||
@@ -264,6 +371,10 @@ func writeRow(w io.Writer, ev *archivedEvent) error {
|
|||||||
row["body_encoding"] = "base64"
|
row["body_encoding"] = "base64"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if period != "" {
|
||||||
|
row["period"] = period
|
||||||
|
}
|
||||||
|
|
||||||
line, err := json.Marshal(row)
|
line, err := json.Marshal(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,14 +31,8 @@ const (
|
|||||||
exportTargetName = "Long-term archive"
|
exportTargetName = "Long-term archive"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// binaryBody is a body that is not valid UTF-8.
|
||||||
// binaryBody is a body that is not valid UTF-8.
|
const binaryBody = "\xff\xfe\x00\x01binary\x80"
|
||||||
binaryBody = "\xff\xfe\x00\x01binary\x80"
|
|
||||||
|
|
||||||
// openedEventID is the event the snapshot tests archive before
|
|
||||||
// they open the export.
|
|
||||||
openedEventID = "opened"
|
|
||||||
)
|
|
||||||
|
|
||||||
// writeExportTo writes export to w as the archive of the export tests'
|
// writeExportTo writes export to w as the archive of the export tests'
|
||||||
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
||||||
@@ -59,19 +55,41 @@ func writeExportTo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listExport lists the archive at path for export, as the archive of a
|
||||||
|
// target whose names do not change.
|
||||||
|
func listExport(t *testing.T, path string) *delivery.ArchiveExport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
||||||
|
return path, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newExport lists the archive at path for export, to find each file
|
||||||
|
// again under the path currentPath gives, holding lock while it does.
|
||||||
|
// Nothing else takes lock while it lists, so it does not hold lock.
|
||||||
|
func newExport(
|
||||||
|
t *testing.T,
|
||||||
|
path string,
|
||||||
|
lock sync.Locker,
|
||||||
|
currentPath func() (string, error),
|
||||||
|
) *delivery.ArchiveExport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
export, err := delivery.NewArchiveExport(
|
||||||
|
path, lock, currentPath, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return export
|
||||||
|
}
|
||||||
|
|
||||||
// exportArchive runs a whole export of the archive at path and returns
|
// exportArchive runs a whole export of the archive at path and returns
|
||||||
// its JSON, decompressed and parsed.
|
// its JSON, decompressed and parsed.
|
||||||
func exportArchive(t *testing.T, path string) map[string]any {
|
func exportArchive(t *testing.T, path string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
return writeExport(t, listExport(t, path))
|
||||||
t.Context(), path, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
return writeExport(t, export)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeExport writes an opened export and returns its JSON,
|
// writeExport writes an opened export and returns its JSON,
|
||||||
@@ -241,66 +259,204 @@ func TestArchiveExport_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveExport_ReadsOneSnapshot proves an export writes the
|
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
|
||||||
// archive as it was when it was opened, and holds up no archive
|
// export unlocks its lock right after it opens a file.
|
||||||
// write: a row written while the export is open is stored, and is not
|
type unlockHook struct {
|
||||||
// in the export. A write held up for the whole busy timeout would
|
sync.Mutex
|
||||||
// fail.
|
|
||||||
|
fn func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *unlockHook) Unlock() {
|
||||||
|
u.Mutex.Unlock()
|
||||||
|
u.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_ReadsOneSnapshot proves an export writes a file
|
||||||
|
// out as it was when the export opened it, and holds up no archive
|
||||||
|
// write: a row written after the export was listed but before the file
|
||||||
|
// was opened is in the export, and one written while the file is open
|
||||||
|
// is stored, and is not. A write held up for the whole busy timeout
|
||||||
|
// would fail.
|
||||||
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive.db")
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
opened := &unlockHook{fn: func() {
|
||||||
t.Context(), path, archiveTestLogger(),
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
||||||
)
|
}}
|
||||||
require.NoError(t, err)
|
export := newExport(t, path, opened, func() (string, error) {
|
||||||
|
return path, nil
|
||||||
|
})
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
[]string{"listed", "before-open"},
|
||||||
|
exportedEventIDs(t, writeExport(t, export)),
|
||||||
)
|
)
|
||||||
|
|
||||||
var stored int64
|
var stored int64
|
||||||
|
|
||||||
require.NoError(t, openArchiveDBForRead(t, path).
|
require.NoError(t, openArchiveDBForRead(t, path).
|
||||||
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
||||||
assert.Equal(t, int64(2), stored)
|
assert.Equal(t, int64(3), stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveExport_SurvivesRename proves that renaming the archive
|
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
|
||||||
// while an export of it is open, as renaming its webhook or target
|
// archive after an export has listed it, as renaming its webhook or
|
||||||
// does, leaves the export reading the same file.
|
// target does, loses no file: the export finds each file again by its
|
||||||
func TestArchiveExport_SurvivesRename(t *testing.T) {
|
// period under the new name. A file moved away by then is skipped.
|
||||||
|
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "archive-old.db")
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "archive-old.db")
|
||||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0))
|
for _, period := range []string{"", dayPeriod, hourPeriod} {
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
current := path
|
||||||
t.Context(), path, archiveTestLogger(),
|
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
||||||
)
|
return current, nil
|
||||||
require.NoError(t, err)
|
})
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
require.NoError(t, w.Rename("archive-new.db"))
|
require.NoError(t, w.Rename("archive-new.db"))
|
||||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0))
|
|
||||||
require.NoFileExists(t, path)
|
current = filepath.Join(dir, "archive-new.db")
|
||||||
|
|
||||||
|
removeArchiveFiles(t, periodPath(current, dayPeriod))
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
[]string{"in-", "in-" + hourPeriod},
|
||||||
|
exportedEventIDs(t, writeExport(t, export)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_EveryFileOldestFirst writes a row to a target's
|
||||||
|
// file without a period and to its files for a month, an hour and a
|
||||||
|
// day, and proves the export holds every row: the file without a
|
||||||
|
// period first, then the others oldest period first, each row from a
|
||||||
|
// file named for a period carrying that period. A file made after the
|
||||||
|
// export was listed is not in it.
|
||||||
|
func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
// Written in an order that is not the export's.
|
||||||
|
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export := listExport(t, path)
|
||||||
|
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
|
||||||
|
))
|
||||||
|
|
||||||
|
events := exportedEvents(t, writeExport(t, export))
|
||||||
|
ids := make([]string, 0, len(events))
|
||||||
|
periods := make([]any, 0, len(events))
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
||||||
|
periods = append(periods, ev["period"])
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod},
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
assert.Equal(t,
|
||||||
|
[]any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods,
|
||||||
|
)
|
||||||
|
assert.NotContains(t, events[0], "period",
|
||||||
|
"a row from the file without a period has no period")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFilesPeak is an io.Writer that discards what it is given and
|
||||||
|
// records the most archive files in dir the process had open at any
|
||||||
|
// write, as /proc/self/fd lists the files a process has open.
|
||||||
|
type openFilesPeak struct {
|
||||||
|
dir string
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *openFilesPeak) Write(b []byte) (int, error) {
|
||||||
|
fds, err := os.ReadDir("/proc/self/fd")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
open := map[string]bool{}
|
||||||
|
|
||||||
|
for _, fd := range fds {
|
||||||
|
file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
|
||||||
|
if err == nil && filepath.Dir(file) == p.dir &&
|
||||||
|
strings.HasSuffix(file, ".db") {
|
||||||
|
open[file] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.max = max(p.max, len(open))
|
||||||
|
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_OneFileOpenAtATime exports a target with a file for
|
||||||
|
// each of 24 hours and proves the export never had more than one of
|
||||||
|
// them open, and had one open while it wrote. Each file holds a row of
|
||||||
|
// 48 KiB of random base64, which gzip shrinks little, so the export
|
||||||
|
// writes output while it reads each file.
|
||||||
|
func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("only Linux lists a process's open files in /proc/self/fd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readlink gives each open file's path with no symbolic link in it.
|
||||||
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "archive-wh.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
random := make([]byte, 36<<10)
|
||||||
|
|
||||||
|
for hour := range 24 {
|
||||||
|
_, _ = rand.Read(random)
|
||||||
|
|
||||||
|
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
|
||||||
|
Body: base64.StdEncoding.EncodeToString(random),
|
||||||
|
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The writer's own handle on the last file is not the export's.
|
||||||
|
w.Evict()
|
||||||
|
|
||||||
|
// Through a buffer, the open files are listed once per 8 KiB of
|
||||||
|
// output, a few times for each file, rather than at each of gzip's
|
||||||
|
// small writes, which takes far longer.
|
||||||
|
peak := &openFilesPeak{dir: dir}
|
||||||
|
buffered := bufio.NewWriterSize(peak, 8<<10)
|
||||||
|
|
||||||
|
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
|
||||||
|
require.NoError(t, buffered.Flush())
|
||||||
|
assert.Equal(t, 1, peak.max)
|
||||||
|
}
|
||||||
|
|
||||||
// heapPeak is an io.Writer that discards what it is given and records
|
// heapPeak is an io.Writer that discards what it is given and records
|
||||||
// the largest heap it saw at a write. It collects garbage before each
|
// the largest heap it saw at a write. It collects garbage before each
|
||||||
// reading, so the heap it reads is what is still held.
|
// reading, so the heap it reads is what is still held.
|
||||||
@@ -338,12 +494,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
|||||||
}, 0))
|
}, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
export := listExport(t, path)
|
||||||
t.Context(), path, archiveTestLogger(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, export.Close()) }()
|
|
||||||
|
|
||||||
runtime.GC()
|
runtime.GC()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The archive rotations: how often a database target starts a new
|
||||||
|
// archive file. Every rotation but none puts the period of an event's
|
||||||
|
// receive time, in UTC, in the name of the file the event goes to,
|
||||||
|
// written in the layout beside it.
|
||||||
|
const (
|
||||||
|
archiveRotationNone = "none"
|
||||||
|
archiveRotationMonthly = "monthly"
|
||||||
|
archiveRotationDaily = "daily"
|
||||||
|
archiveRotationHourly = "hourly"
|
||||||
|
|
||||||
|
archiveMonthLayout = "2006-01"
|
||||||
|
archiveDayLayout = "2006-01-02"
|
||||||
|
archiveHourLayout = "2006-01-02-15"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errArchiveRotationUnknown is returned for a rotation that is not one
|
||||||
|
// of the four.
|
||||||
|
var errArchiveRotationUnknown = errors.New(
|
||||||
|
"rotation must be none, monthly, daily or hourly",
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveFile is one of a database target's archive files, and the
|
||||||
|
// period in its name: "" for the file named without one.
|
||||||
|
type archiveFile struct {
|
||||||
|
path string
|
||||||
|
period string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateArchiveRotation checks a user-supplied archive rotation for
|
||||||
|
// a database target: empty or none (both meaning one file), monthly,
|
||||||
|
// daily or hourly.
|
||||||
|
func ValidateArchiveRotation(rotation string) error {
|
||||||
|
switch rotation {
|
||||||
|
case "", archiveRotationNone, archiveRotationMonthly,
|
||||||
|
archiveRotationDaily, archiveRotationHourly:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArchiveRotation reads the rotation from a database target's
|
||||||
|
// config JSON. An empty config or an empty rotation is none.
|
||||||
|
func parseArchiveRotation(configJSON string) (string, error) {
|
||||||
|
if configJSON == "" {
|
||||||
|
return archiveRotationNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg databaseTargetConfig
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parsing database target config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateArchiveRotation(cfg.Rotation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Rotation == "" {
|
||||||
|
return archiveRotationNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg.Rotation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archivePeriod returns the period, in UTC, that a rotation puts an
|
||||||
|
// event received at receivedAt in: "2026-10" for monthly,
|
||||||
|
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
|
||||||
|
func archivePeriod(rotation string, receivedAt time.Time) string {
|
||||||
|
switch rotation {
|
||||||
|
case archiveRotationMonthly:
|
||||||
|
return receivedAt.UTC().Format(archiveMonthLayout)
|
||||||
|
case archiveRotationDaily:
|
||||||
|
return receivedAt.UTC().Format(archiveDayLayout)
|
||||||
|
case archiveRotationHourly:
|
||||||
|
return receivedAt.UTC().Format(archiveHourLayout)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archivePeriodPath returns the path of a database target's archive
|
||||||
|
// file for a period: path, as ArchivePath gives it, with "-" and the
|
||||||
|
// period put before its ".db". The period "" gives path itself.
|
||||||
|
func archivePeriodPath(path, period string) string {
|
||||||
|
if period == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchivePathAt returns the archive file a database target writes an
|
||||||
|
// event received at receivedAt to: ArchivePath's file, with the
|
||||||
|
// period in its name when the target rotates.
|
||||||
|
func ArchivePathAt(
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
target *database.Target,
|
||||||
|
receivedAt time.Time,
|
||||||
|
) (string, error) {
|
||||||
|
rotation, err := parseArchiveRotation(target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return archivePeriodPath(
|
||||||
|
ArchivePath(dbMgr, webhook, target),
|
||||||
|
archivePeriod(rotation, receivedAt),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveFiles lists the database target's archive files that exist,
|
||||||
|
// given the path ArchivePath gives it: the file at path, then each
|
||||||
|
// file archivePeriodPath names for path and a period, oldest period
|
||||||
|
// first. Which rotation wrote a file does not matter, so the files of
|
||||||
|
// an earlier rotation setting are listed too.
|
||||||
|
func archiveFiles(path string) ([]archiveFile, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing archive files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(path), ".db")
|
||||||
|
|
||||||
|
var files []archiveFile
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
period, ok := archiveFilePeriod(stem, entry.Name())
|
||||||
|
if ok {
|
||||||
|
files = append(files, archiveFile{
|
||||||
|
path: filepath.Join(dir, entry.Name()),
|
||||||
|
period: period,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A month sorts before the days and hours in it.
|
||||||
|
slices.SortFunc(files, func(a, b archiveFile) int {
|
||||||
|
return strings.Compare(a.period, b.period)
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveFilePeriod reports whether name is the name of an archive
|
||||||
|
// file of the target whose file name without a period is stem+".db",
|
||||||
|
// and the period in it.
|
||||||
|
func archiveFilePeriod(stem, name string) (string, bool) {
|
||||||
|
rest, ok := strings.CutPrefix(name, stem)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
rest, ok = strings.CutSuffix(rest, ".db")
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest == "" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
period, ok := strings.CutPrefix(rest, "-")
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range []string{
|
||||||
|
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
|
||||||
|
} {
|
||||||
|
_, err := time.Parse(layout, period)
|
||||||
|
if err == nil {
|
||||||
|
return period, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The archive rotations, and the configs of a daily target and of one
|
||||||
|
// whose rotation is not one of the four.
|
||||||
|
const (
|
||||||
|
rotationNone = "none"
|
||||||
|
rotationMonthly = "monthly"
|
||||||
|
rotationDaily = "daily"
|
||||||
|
rotationHourly = "hourly"
|
||||||
|
|
||||||
|
dailyConfig = `{"rotation":"daily"}`
|
||||||
|
weeklyConfig = `{"rotation":"weekly"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// The periods the tests archive into most: two days, and an hour of
|
||||||
|
// the first.
|
||||||
|
const (
|
||||||
|
dayPeriod = "2026-03-04"
|
||||||
|
nextDayPeriod = "2026-03-05"
|
||||||
|
hourPeriod = "2026-03-04-05"
|
||||||
|
)
|
||||||
|
|
||||||
|
// periodPath returns the archive file for a period of the target whose
|
||||||
|
// file without a period is path.
|
||||||
|
func periodPath(path, period string) string {
|
||||||
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverReceivedAt delivers to a database target an event whose
|
||||||
|
// receive time is receivedAt, and returns the event's id. The receive
|
||||||
|
// time is what decides a rotated archive's file, so setting it is how
|
||||||
|
// these tests move the clock across a period boundary.
|
||||||
|
func (env *archiveEnv) deliverReceivedAt(
|
||||||
|
t *testing.T, tgt *database.Target, receivedAt time.Time,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
event.CreatedAt = receivedAt
|
||||||
|
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return event.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
|
||||||
|
// rotation, an event received in the last second of a period and one
|
||||||
|
// received in the first second of the next, and checks each lands in
|
||||||
|
// the file named for its own period, in UTC. Rotation none keeps both
|
||||||
|
// in the one file.
|
||||||
|
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
berlin := time.FixedZone("CEST", 2*60*60)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rotation string
|
||||||
|
before, after time.Time
|
||||||
|
// periods are the periods of before and after.
|
||||||
|
periods [2]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
rotationMonthly, rotationMonthly,
|
||||||
|
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
|
||||||
|
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
[2]string{"2026-01", "2026-02"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotationDaily, rotationDaily,
|
||||||
|
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
|
||||||
|
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
|
||||||
|
[2]string{dayPeriod, nextDayPeriod},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same instants, received in a zone two hours ahead
|
||||||
|
// of UTC, where they fall on 5 March: the period is UTC's.
|
||||||
|
"daily in another zone", rotationDaily,
|
||||||
|
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
|
||||||
|
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
|
||||||
|
[2]string{dayPeriod, nextDayPeriod},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotationHourly, rotationHourly,
|
||||||
|
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
|
||||||
|
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
|
||||||
|
[2]string{hourPeriod, "2026-03-04-06"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
|
first := env.deliverReceivedAt(t, tgt, tc.before)
|
||||||
|
second := env.deliverReceivedAt(t, tgt, tc.after)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{first},
|
||||||
|
archivedEventIDs(t, periodPath(path, tc.periods[0])))
|
||||||
|
assert.Equal(t, []string{second},
|
||||||
|
archivedEventIDs(t, periodPath(path, tc.periods[1])))
|
||||||
|
assert.NoFileExists(t, path,
|
||||||
|
"a rotated target never writes the file without a period")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run(rotationNone, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
|
||||||
|
|
||||||
|
first := env.deliverReceivedAt(t, tgt, cases[0].before)
|
||||||
|
second := env.deliverReceivedAt(t, tgt, cases[0].after)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{first, second},
|
||||||
|
archivedEventIDs(t, env.archivePath(tgt)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
|
||||||
|
// receive time is not known goes to the file for the time it is
|
||||||
|
// archived, rather than to one for the year 1.
|
||||||
|
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
|
before := time.Now().UTC().Format("2006-01")
|
||||||
|
id := env.deliverReceivedAt(t, tgt, time.Time{})
|
||||||
|
|
||||||
|
file := periodPath(path, time.Now().UTC().Format("2006-01"))
|
||||||
|
|
||||||
|
_, err := os.Stat(file)
|
||||||
|
if err != nil {
|
||||||
|
// The month turned during the delivery.
|
||||||
|
file = periodPath(path, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
|
||||||
|
assert.NoFileExists(t, periodPath(path, "0001-01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
|
||||||
|
// rotation from none to daily between two events, and checks the
|
||||||
|
// second goes to the daily file while the first stays where it was.
|
||||||
|
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
first := env.deliverReceivedAt(t, tgt, at)
|
||||||
|
|
||||||
|
tgt.Config = dailyConfig
|
||||||
|
second := env.deliverReceivedAt(t, tgt, at)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
|
||||||
|
assert.Equal(t, []string{second},
|
||||||
|
archivedEventIDs(t, periodPath(path, dayPeriod)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
|
||||||
|
// the file without a period, left from before it rotated, and two
|
||||||
|
// daily files. Each holds a row older than the expiry, and one daily
|
||||||
|
// file also a newer row. The sweep prunes the old row from every file,
|
||||||
|
// deletes the daily file it leaves empty, and keeps the file without a
|
||||||
|
// period although it is empty too.
|
||||||
|
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(
|
||||||
|
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
|
||||||
|
)
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
emptied := periodPath(path, dayPeriod)
|
||||||
|
kept := periodPath(path, nextDayPeriod)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
old := now.Add(-48 * time.Hour)
|
||||||
|
|
||||||
|
seedArchiveFile(t, path, tgt.WebhookID, old)
|
||||||
|
seedArchiveFile(t, emptied, tgt.WebhookID, old)
|
||||||
|
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
env.sweeper.ExportSweep(t.Context())
|
||||||
|
|
||||||
|
assert.Empty(t, archivedEventIDs(t, path))
|
||||||
|
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
assert.NoFileExists(t, emptied+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_MovesEveryFile renames a daily target that also has a
|
||||||
|
// file without a period, and checks every file moves to the new name
|
||||||
|
// with its period, rows and all, and that a later write uses the new
|
||||||
|
// name.
|
||||||
|
func TestRename_MovesEveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
oldPath := env.archivePath(tgt)
|
||||||
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
unrotated := env.deliverReceivedAt(t, tgt, day)
|
||||||
|
|
||||||
|
tgt.Config = dailyConfig
|
||||||
|
first := env.deliverReceivedAt(t, tgt, day)
|
||||||
|
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
||||||
|
|
||||||
|
require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
|
||||||
|
|
||||||
|
newPath := filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, old := range []string{
|
||||||
|
oldPath,
|
||||||
|
periodPath(oldPath, dayPeriod),
|
||||||
|
periodPath(oldPath, nextDayPeriod),
|
||||||
|
} {
|
||||||
|
assert.NoFileExists(t, old)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
|
||||||
|
assert.Equal(t, []string{first},
|
||||||
|
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
|
||||||
|
assert.Equal(t, []string{second},
|
||||||
|
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
|
||||||
|
|
||||||
|
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
|
||||||
|
assert.Equal(t, []string{third},
|
||||||
|
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_NeverReplacesARotatedFile plants a file at the new name
|
||||||
|
// of a target's daily file, and proves the rename is refused and moves
|
||||||
|
// none of the target's files.
|
||||||
|
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, dailyConfig)
|
||||||
|
oldPath := env.archivePath(tgt)
|
||||||
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
env.deliverReceivedAt(t, tgt, day)
|
||||||
|
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
||||||
|
|
||||||
|
newPath := filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||||
|
)
|
||||||
|
planted := periodPath(newPath, nextDayPeriod)
|
||||||
|
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
|
||||||
|
|
||||||
|
require.ErrorIs(
|
||||||
|
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||||
|
delivery.ErrArchiveNameTaken,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.FileExists(t, periodPath(oldPath, dayPeriod))
|
||||||
|
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
|
||||||
|
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
|
||||||
|
// one of a target's files, takes the latest write of any of them, and
|
||||||
|
// leaves out files whose names only look like the target's.
|
||||||
|
func TestStatArchive_EveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "archive-wh.db")
|
||||||
|
files := []string{
|
||||||
|
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
|
||||||
|
}
|
||||||
|
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
|
||||||
|
for i, file := range files {
|
||||||
|
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
|
||||||
|
|
||||||
|
size += int64(100 * (i + 1))
|
||||||
|
at := written.Add(-time.Duration(i) * time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(file, at, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, other := range []string{
|
||||||
|
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
|
||||||
|
"archive-wh-other.db", "archive-wh-2026-03.json",
|
||||||
|
"archive-whx.db",
|
||||||
|
} {
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := delivery.StatArchive(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, len(files), got.Files)
|
||||||
|
assert.Equal(t, size, got.Size)
|
||||||
|
assert.True(t, written.Equal(got.Written), got.Written)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchivePathAt names the file each rotation writes an event to.
|
||||||
|
func TestArchivePathAt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
||||||
|
webhook := &database.Webhook{
|
||||||
|
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
|
||||||
|
}
|
||||||
|
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
`{"rotation":"` + rotationNone + `"}`: "",
|
||||||
|
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
|
||||||
|
dailyConfig: "-2026-10-01",
|
||||||
|
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
|
||||||
|
}
|
||||||
|
|
||||||
|
for config, period := range cases {
|
||||||
|
target := &database.Target{
|
||||||
|
BaseModel: database.BaseModel{ID: "tgt-id"},
|
||||||
|
Name: "Archive",
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
|
||||||
|
require.NoError(t, err, config)
|
||||||
|
assert.Equal(t,
|
||||||
|
filepath.Join(
|
||||||
|
dataDir, "archive-orders-archive-tgt-id"+period+".db",
|
||||||
|
),
|
||||||
|
got, config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
|
||||||
|
Config: weeklyConfig,
|
||||||
|
}, at)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateArchiveRotation accepts the four rotations, and empty,
|
||||||
|
// and refuses anything else.
|
||||||
|
func TestValidateArchiveRotation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, ok := range []string{
|
||||||
|
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
||||||
|
} {
|
||||||
|
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"weekly", "Daily", "hourly "} {
|
||||||
|
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ func TestStatArchive(t *testing.T) {
|
|||||||
|
|
||||||
got, err := delivery.StatArchive(path)
|
got, err := delivery.StatArchive(path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, got.Files)
|
||||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
||||||
assert.True(t, written.Equal(got.Written), got.Written)
|
assert.True(t, written.Equal(got.Written), got.Written)
|
||||||
|
|
||||||
@@ -720,3 +721,41 @@ func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
|||||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||||
assert.Equal(t, oldPath, w.Path())
|
assert.Equal(t, oldPath, w.Path())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
|
||||||
|
// file without a period and a file for a month, and proves that when
|
||||||
|
// the month's file fails to move, the file already moved is moved back:
|
||||||
|
// both files are under the old name with their rows, and nothing is
|
||||||
|
// under the new name. The new name is 251 bytes, so the file without a
|
||||||
|
// period, with its -wal and -shm, can take it, but the month's file,
|
||||||
|
// eight bytes longer, cannot.
|
||||||
|
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
oldPath := filepath.Join(dir, "archive-old.db")
|
||||||
|
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "",
|
||||||
|
))
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
|
||||||
|
))
|
||||||
|
|
||||||
|
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
|
||||||
|
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
|
||||||
|
"%s is not under the old name", entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, oldPath, w.Path())
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,10 +204,11 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
|
|
||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"720h"}`,
|
Config: `{"expiry":"720h","rotation":"daily"}`,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "720h", form.Expiry)
|
assert.Equal(t, "720h", form.Expiry)
|
||||||
|
assert.Equal(t, rotationDaily, form.Rotation)
|
||||||
|
|
||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeLog,
|
Type: database.TargetTypeLog,
|
||||||
@@ -248,6 +249,10 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"soon"}`,
|
Config: `{"expiry":"soon"}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Config: weeklyConfig,
|
||||||
|
},
|
||||||
{Type: database.TargetType("nope")},
|
{Type: database.TargetType("nope")},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ func (c *httpCore) handleRetry(
|
|||||||
database.DeliveryStatusRetrying,
|
database.DeliveryStatusRetrying,
|
||||||
)
|
)
|
||||||
|
|
||||||
backoff := calcBackoff(attemptNum)
|
backoff := Backoff(attemptNum)
|
||||||
|
|
||||||
retryTask := *task
|
retryTask := *task
|
||||||
retryTask.AttemptNum = attemptNum + 1
|
retryTask.AttemptNum = attemptNum + 1
|
||||||
@@ -301,7 +301,7 @@ func (c *httpCore) remainingBackoff(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := calcBackoff(attemptNum)
|
backoff := Backoff(attemptNum)
|
||||||
elapsed := time.Since(lastResult.CreatedAt)
|
elapsed := time.Since(lastResult.CreatedAt)
|
||||||
remaining := backoff - elapsed
|
remaining := backoff - elapsed
|
||||||
|
|
||||||
@@ -326,12 +326,14 @@ func (c *httpCore) backoffElapsed(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := calcBackoff(attemptNum)
|
backoff := Backoff(attemptNum)
|
||||||
|
|
||||||
return time.Since(lastResult.CreatedAt) >= backoff
|
return time.Since(lastResult.CreatedAt) >= backoff
|
||||||
}
|
}
|
||||||
|
|
||||||
func calcBackoff(attemptNum int) time.Duration {
|
// Backoff is how long an http or slack target with retries waits after
|
||||||
|
// a delivery's failed attempt attemptNum before trying it again.
|
||||||
|
func Backoff(attemptNum int) time.Duration {
|
||||||
shift := max(attemptNum-1, 0)
|
shift := max(attemptNum-1, 0)
|
||||||
shift = min(shift, maxBackoffShift)
|
shift = min(shift, maxBackoffShift)
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ const (
|
|||||||
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
|
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// archiveExpiryChoice is one entry of a database target's archive
|
// archiveChoice is one entry of a database target's archive expiry
|
||||||
// expiry select: the expiry stored, the label shown, and whether the
|
// or archive rotation select: the value stored, the label shown, and
|
||||||
// select starts on it.
|
// whether the select starts on it.
|
||||||
type archiveExpiryChoice struct {
|
type archiveChoice struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Label string
|
||||||
Selected bool
|
Selected bool
|
||||||
@@ -21,8 +21,8 @@ type archiveExpiryChoice struct {
|
|||||||
|
|
||||||
// archiveExpiryChoices lists the archive expiries offered by the new
|
// archiveExpiryChoices lists the archive expiries offered by the new
|
||||||
// webhook page, the add target form and the target edit form.
|
// webhook page, the add target form and the target edit form.
|
||||||
func archiveExpiryChoices() []archiveExpiryChoice {
|
func archiveExpiryChoices() []archiveChoice {
|
||||||
return []archiveExpiryChoice{
|
return []archiveChoice{
|
||||||
{Value: archiveExpiryNever, Label: archiveExpiryNever},
|
{Value: archiveExpiryNever, Label: archiveExpiryNever},
|
||||||
{Value: "1h", Label: "1h"},
|
{Value: "1h", Label: "1h"},
|
||||||
{Value: "12h", Label: "12h"},
|
{Value: "12h", Label: "12h"},
|
||||||
@@ -37,7 +37,7 @@ func archiveExpiryChoices() []archiveExpiryChoice {
|
|||||||
// empty expiry selects never. An expiry that is not one of the
|
// empty expiry selects never. An expiry that is not one of the
|
||||||
// choices comes first as its own selected entry, so saving the form
|
// choices comes first as its own selected entry, so saving the form
|
||||||
// unchanged keeps it.
|
// unchanged keeps it.
|
||||||
func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
|
func archiveExpiryOptions(expiry string) []archiveChoice {
|
||||||
if expiry == "" {
|
if expiry == "" {
|
||||||
expiry = archiveExpiryNever
|
expiry = archiveExpiryNever
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true}
|
own := archiveChoice{Value: expiry, Label: expiry, Selected: true}
|
||||||
|
|
||||||
return append([]archiveExpiryChoice{own}, options...)
|
return append([]archiveChoice{own}, options...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -44,26 +45,44 @@ func expiryShown(
|
|||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
return matched(
|
return matched(
|
||||||
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expirySelected returns the target edit page and the expiries its
|
// expirySelected returns the target edit page and the expiries its
|
||||||
// select starts on.
|
// expiry select starts on.
|
||||||
func expirySelected(
|
func expirySelected(
|
||||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||||
) (string, []string) {
|
) (string, []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
page := targetEditPage(t, env, webhookID, targetID)
|
||||||
|
|
||||||
|
return page, selectedIn(page, "expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetEditPage returns a target's edit page.
|
||||||
|
func targetEditPage(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
|
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
page := w.Body.String()
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
return page, matched(`<option value="([^"]*)" selected>`, page)
|
// selectedIn returns the values the select named name on page starts
|
||||||
|
// on.
|
||||||
|
func selectedIn(page, name string) []string {
|
||||||
|
_, rest, _ := strings.Cut(page, `<select id="`+name+`" name="`+name+`"`)
|
||||||
|
options, _, _ := strings.Cut(rest, "</select>")
|
||||||
|
|
||||||
|
return matched(`<option value="([^"]*)" selected>`, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArchiveExpiryChoices adds a database target with each archive
|
// TestArchiveExpiryChoices adds a database target with each archive
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
const (
|
||||||
|
// archiveRotationNone is the archive rotation that keeps a
|
||||||
|
// database target's archive in one file. A stored empty rotation
|
||||||
|
// means the same.
|
||||||
|
archiveRotationNone = "none"
|
||||||
|
|
||||||
|
// tmplKeyArchiveRotationChoices is the template data key for the
|
||||||
|
// entries of a page's archive rotation select.
|
||||||
|
tmplKeyArchiveRotationChoices = "ArchiveRotationChoices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveRotationChoices lists the archive rotations offered by the
|
||||||
|
// new webhook page, the add target form and the target edit form.
|
||||||
|
func archiveRotationChoices() []archiveChoice {
|
||||||
|
return []archiveChoice{
|
||||||
|
{Value: archiveRotationNone, Label: archiveRotationNone},
|
||||||
|
{Value: "monthly", Label: "monthly"},
|
||||||
|
{Value: "daily", Label: "daily"},
|
||||||
|
{Value: "hourly", Label: "hourly"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveRotationOptions returns the choices with rotation selected;
|
||||||
|
// an empty rotation, or one that is not a choice, selects none. A
|
||||||
|
// stored rotation is always a choice: the forms refuse any other.
|
||||||
|
func archiveRotationOptions(rotation string) []archiveChoice {
|
||||||
|
options := archiveRotationChoices()
|
||||||
|
|
||||||
|
for i := range options {
|
||||||
|
if options[i].Value == rotation {
|
||||||
|
options[i].Selected = true
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options[0].Selected = true
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rotationNone is the archive rotation that keeps one file.
|
||||||
|
const rotationNone = "none"
|
||||||
|
|
||||||
|
// rotationShown returns the archive rotations the webhook page's
|
||||||
|
// target list shows.
|
||||||
|
func rotationShown(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID string,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return matched(
|
||||||
|
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
|
||||||
|
renderedPage(t, env, webhookID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveRotationChoices adds a database target with each archive
|
||||||
|
// rotation the forms offer, and checks that it is stored as chosen,
|
||||||
|
// shown in the target list, and that the target edit form starts on
|
||||||
|
// it. It then edits the target to hourly, keeping its expiry.
|
||||||
|
func TestArchiveRotationChoices(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
|
||||||
|
t.Run(rotation, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("type", string(database.TargetTypeDatabase))
|
||||||
|
form.Set("expiry", "720h")
|
||||||
|
form.Set("rotation", rotation)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"expiry":"720h","rotation":"`+rotation+`"}`,
|
||||||
|
targets[0].Config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{rotation}, rotationShown(t, env, webhook.ID))
|
||||||
|
|
||||||
|
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
|
||||||
|
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
|
||||||
|
|
||||||
|
form = url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("expiry", "720h")
|
||||||
|
form.Set("rotation", "hourly")
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"expiry":"720h","rotation":"hourly"}`,
|
||||||
|
storedTarget(t, env, targets[0].ID).Config,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveRotationEditStartsOnNone checks the edit form of a
|
||||||
|
// database target with no rotation stored starts on none.
|
||||||
|
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
target := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
|
||||||
|
)
|
||||||
|
|
||||||
|
page := targetEditPage(t, env, webhook.ID, target.ID)
|
||||||
|
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
|
||||||
|
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveRotationRefused proves a rotation that is not one of the
|
||||||
|
// four is refused on the add target form and the target edit form, and
|
||||||
|
// that nothing is stored.
|
||||||
|
func TestArchiveRotationRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("type", string(database.TargetTypeDatabase))
|
||||||
|
form.Set("rotation", "weekly")
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
|
||||||
|
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
|
||||||
|
|
||||||
|
target := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
||||||
|
`{"rotation":"daily"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
form.Del("type")
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, webhook.ID, target.ID, form)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
|
||||||
|
// page's archive rotation is stored on the archive target it creates.
|
||||||
|
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "rotated")
|
||||||
|
form.Set("archive", "on")
|
||||||
|
form.Set("archive_expiry", "720h")
|
||||||
|
form.Set("archive_rotation", "daily")
|
||||||
|
|
||||||
|
w := submitCreateForm(env, form)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("name = ?", "rotated").First(&webhook).Error)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveFileView_Rotated describes a daily target's archive files
|
||||||
|
// at two times. On a day that has a file, the view names that file;
|
||||||
|
// on the next, before any event, it names the file the next event
|
||||||
|
// will go to, not created yet. Both times the size is of every file
|
||||||
|
// together and the last write the latest of them.
|
||||||
|
func TestArchiveFileView_Rotated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
target := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
||||||
|
`{"rotation":"daily"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
path := delivery.ArchivePath(env.dbMgr, &webhook, target)
|
||||||
|
stem := strings.TrimSuffix(path, ".db")
|
||||||
|
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
for i, day := range []string{"2026-10-01", "2026-10-02"} {
|
||||||
|
file := stem + "-" + day + ".db"
|
||||||
|
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
|
||||||
|
|
||||||
|
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(file, at, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := env.handlers.ArchiveFileViewForTest(
|
||||||
|
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
|
||||||
|
)
|
||||||
|
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
|
||||||
|
assert.Empty(t, view.Note)
|
||||||
|
assert.Equal(t, 2, view.Files)
|
||||||
|
assert.Equal(t, "2.0 kB", view.Size)
|
||||||
|
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
|
||||||
|
|
||||||
|
view = env.handlers.ArchiveFileViewForTest(
|
||||||
|
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
)
|
||||||
|
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
|
||||||
|
assert.Equal(t, "not created yet", view.Note)
|
||||||
|
assert.Equal(t, 2, view.Files)
|
||||||
|
assert.Equal(t, "2.0 kB", view.Size)
|
||||||
|
|
||||||
|
page := targetList(t, renderedPage(t, env, webhook.ID))
|
||||||
|
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderedPage returns the webhook page.
|
||||||
|
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
|
||||||
|
t, "/hook/"+webhookID, env.cookies,
|
||||||
|
map[string]string{sourceIDParam: webhookID},
|
||||||
|
))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
|
|||||||
)))
|
)))
|
||||||
h.renderLoginError(
|
h.renderLoginError(
|
||||||
w, r,
|
w, r,
|
||||||
"Too many failed login attempts. Please try again later.",
|
"Too many failed sign-in attempts. Please try again later.",
|
||||||
http.StatusTooManyRequests,
|
http.StatusTooManyRequests,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
|
||||||
|
// event's page and in the event log, that an http or slack attempt
|
||||||
|
// shows its status as before, while a database or log attempt, which
|
||||||
|
// sends no HTTP request, says what it did and shows no status.
|
||||||
|
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
targetType database.TargetType
|
||||||
|
success bool
|
||||||
|
statusCode int
|
||||||
|
errText string
|
||||||
|
outcome string
|
||||||
|
status string // "" when the attempt must show no status
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
database.TargetTypeHTTP, false, 0, "",
|
||||||
|
"failure", "Status: — (no response)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeSlack, true, http.StatusOK, "",
|
||||||
|
"success", "Status: 200",
|
||||||
|
},
|
||||||
|
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
|
||||||
|
{
|
||||||
|
database.TargetTypeDatabase, false, 0,
|
||||||
|
"opening archive database: disk full", "failure", "",
|
||||||
|
},
|
||||||
|
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
|
||||||
|
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||||
|
dlv := f.delivery(
|
||||||
|
t, event, target.ID, database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
|
||||||
|
&database.DeliveryResult{
|
||||||
|
DeliveryID: dlv.ID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
Success: tc.success,
|
||||||
|
StatusCode: tc.statusCode,
|
||||||
|
Error: tc.errText,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
pages := []string{
|
||||||
|
w.Body.String(),
|
||||||
|
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range pages {
|
||||||
|
assert.Contains(t, page, ">"+tc.outcome+"</span>")
|
||||||
|
|
||||||
|
if tc.errText != "" {
|
||||||
|
assert.Contains(t, page, "Error: "+tc.errText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.status == "" {
|
||||||
|
assert.NotContains(t, page, "Status:")
|
||||||
|
} else {
|
||||||
|
assert.Contains(t, page, tc.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,7 +20,9 @@ const (
|
|||||||
// replayTargetDeleted reports a target that once existed and has
|
// replayTargetDeleted reports a target that once existed and has
|
||||||
// since been deleted. Deletes are soft and deliveries carry no
|
// since been deleted. Deletes are soft and deliveries carry no
|
||||||
// foreign key to the target row, so the history survives its
|
// foreign key to the target row, so the history survives its
|
||||||
// target and this is the ordinary case for an old event.
|
// target and this is the ordinary case for an old event. The
|
||||||
|
// event log shows no Replay button for such a delivery, so only
|
||||||
|
// a page loaded before the delete reaches this.
|
||||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
replayTargetDeleted noticeCode = "replay-target-deleted"
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
@@ -281,6 +282,7 @@ func createReplayDelivery(
|
|||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: target.ID,
|
TargetID: target.ID,
|
||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
|
Replay: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -327,24 +329,25 @@ func replayBody(body string) *string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||||
// log it was triggered from, carrying the outcome as its notice and
|
// log it was triggered from, carrying the outcome as its notice. A
|
||||||
// the page number the form submitted.
|
// Replay form carries the list it was pressed in as show, so a replay
|
||||||
|
// returns to the Failed or Pending list; a Resubmit form carries none,
|
||||||
|
// so a resubmit returns to the full log, where its new event is the
|
||||||
|
// newest.
|
||||||
func redirectToEventLog(
|
func redirectToEventLog(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code noticeCode,
|
||||||
) {
|
) {
|
||||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
location := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
show := r.PostFormValue(showParam)
|
||||||
// this is a POST, and its query string is what logs and Referer
|
if eventLogStatuses(show) != nil {
|
||||||
// headers record.
|
location += "&" + showParam + "=" + show
|
||||||
if page := pageOrFirst(
|
|
||||||
r.PostFormValue("page"),
|
|
||||||
); page > 1 {
|
|
||||||
dest += "&page=" + strconv.Itoa(page)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
|
||||||
|
w, r, location, http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -470,6 +472,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
|
||||||
|
// Replay pressed in the Failed list carries that list in its form and
|
||||||
|
// returns to it, and that a show value the event log does not know
|
||||||
|
// returns to the full log.
|
||||||
|
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, renderSourceLogsPageWithQuery(
|
||||||
|
t, h, sess, wh.ID, "?show=failed",
|
||||||
|
), `name="show" value="failed"`)
|
||||||
|
|
||||||
|
// The second replay is refused, as the first is still queued.
|
||||||
|
for _, tc := range []struct{ show, location string }{
|
||||||
|
{"failed", "/hook/" + wh.ID +
|
||||||
|
"/events?notice=replay-queued&show=failed"},
|
||||||
|
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
|
||||||
|
} {
|
||||||
|
req := postRequest(
|
||||||
|
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
|
||||||
|
authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
),
|
||||||
|
map[string]string{
|
||||||
|
paramSourceID: wh.ID,
|
||||||
|
paramDeliveryID: original.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleDeliveryReplay().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
|
||||||
|
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
||||||
// reaches the page it belongs on: a finished delivery renders a POST
|
// reaches the page it belongs on: a finished delivery renders a POST
|
||||||
// form carrying a CSRF token, and the outcome code a refusal redirects
|
// form carrying a CSRF token, and the outcome code a refusal redirects
|
||||||
@@ -513,7 +575,11 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
assert.Contains(t, refused, "has been deleted")
|
assert.Contains(
|
||||||
|
t, refused,
|
||||||
|
"has been deleted. Use Resubmit to send the event "+
|
||||||
|
"to the webhook",
|
||||||
|
)
|
||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
@@ -524,3 +590,58 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
assert.NotContains(t, unknown, "alert-success")
|
assert.NotContains(t, unknown, "alert-success")
|
||||||
assert.NotContains(t, unknown, "made-up")
|
assert.NotContains(t, unknown, "made-up")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
|
||||||
|
// by Replay is labelled as a replay in the event's summary line in the
|
||||||
|
// event log, and in the list of the event's deliveries there and on
|
||||||
|
// the event's page, while the delivery it repeats is not.
|
||||||
|
func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
w := postReplay(t, h, sess, wh.ID, original.ID)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, eventLog, tgt.Name+": failed")
|
||||||
|
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
|
||||||
|
|
||||||
|
w = serveEventPage(t, h, sess, wh.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// In each delivery list a row names the target, then the label if
|
||||||
|
// it is a replay, then its status: the replay is still pending, the
|
||||||
|
// original failed.
|
||||||
|
replayRow := tgt.Name + `</span> ` +
|
||||||
|
`<span class="text-xs text-gray-500">replay</span> ` +
|
||||||
|
`<span class="text-xs text-gray-400">pending</span>`
|
||||||
|
originalRow := tgt.Name + `</span> ` +
|
||||||
|
`<span class="text-xs text-red-600">failed</span>`
|
||||||
|
|
||||||
|
for _, page := range []string{eventLog, w.Body.String()} {
|
||||||
|
page = strings.Join(strings.Fields(page), " ")
|
||||||
|
|
||||||
|
assert.Contains(t, page, replayRow)
|
||||||
|
assert.Contains(t, page, originalRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,8 +27,8 @@ const maxRenderedResponseBytes = 4096
|
|||||||
// bytes rather than characters, and they make SQLite do the
|
// bytes rather than characters, and they make SQLite do the
|
||||||
// cut, so an oversized stored response never becomes a Go
|
// cut, so an oversized stored response never becomes a Go
|
||||||
// string at all.
|
// string at all.
|
||||||
const deliveryResultColumns = "delivery_id, attempt_num, success, " +
|
const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
||||||
"status_code, error, duration, " +
|
"success, status_code, error, duration, " +
|
||||||
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
||||||
"length(cast(response_body as blob)) AS response_bytes"
|
"length(cast(response_body as blob)) AS response_bytes"
|
||||||
|
|
||||||
@@ -45,6 +48,11 @@ type DeliveryResultView struct {
|
|||||||
AttemptNum int
|
AttemptNum int
|
||||||
Success bool
|
Success bool
|
||||||
|
|
||||||
|
// Ran is how long ago the attempt was recorded, and RanUTC the
|
||||||
|
// full timestamp the page shows on hover.
|
||||||
|
Ran string
|
||||||
|
RanUTC string
|
||||||
|
|
||||||
// StatusCode is 0 when the attempt never got a response,
|
// StatusCode is 0 when the attempt never got a response,
|
||||||
// which is why the page asks HasStatusCode rather than
|
// which is why the page asks HasStatusCode rather than
|
||||||
// printing the number.
|
// printing the number.
|
||||||
@@ -100,6 +108,7 @@ func (v DeliveryResultView) HasStatusCode() bool {
|
|||||||
type deliveryResultRow struct {
|
type deliveryResultRow struct {
|
||||||
DeliveryID string
|
DeliveryID string
|
||||||
AttemptNum int
|
AttemptNum int
|
||||||
|
CreatedAt time.Time
|
||||||
Success bool
|
Success bool
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Error string
|
Error string
|
||||||
@@ -157,6 +166,8 @@ func (r *deliveryResultRow) view(
|
|||||||
return DeliveryResultView{
|
return DeliveryResultView{
|
||||||
AttemptNum: r.AttemptNum,
|
AttemptNum: r.AttemptNum,
|
||||||
Success: r.Success,
|
Success: r.Success,
|
||||||
|
Ran: humanize.Time(r.CreatedAt),
|
||||||
|
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
StatusCode: r.StatusCode,
|
StatusCode: r.StatusCode,
|
||||||
Error: redactor.Redact(r.Error),
|
Error: redactor.Redact(r.Error),
|
||||||
DurationMS: r.Duration,
|
DurationMS: r.Duration,
|
||||||
|
|||||||
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
|
|||||||
}).Error)
|
}).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
|
|
||||||
@@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
|
|||||||
stored := strings.Repeat("A", responseCap*4) + tail
|
stored := strings.Repeat("A", responseCap*4) + tail
|
||||||
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
require.Len(t, views[0].Deliveries[0].Results, 1)
|
require.Len(t, views[0].Deliveries[0].Results, 1)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleEntrypointCreate handles adding a new entrypoint.
|
||||||
|
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := r.PostFormValue("description")
|
||||||
|
|
||||||
|
entrypoint := &database.Entrypoint{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
Description: description,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to create entrypoint", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointEdit handles changing an entrypoint's description.
|
||||||
|
// It writes only the description column, so the entrypoint keeps its
|
||||||
|
// URL, and an activate or deactivate saved since the page was shown
|
||||||
|
// is not undone.
|
||||||
|
func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
entrypointID := chi.URLParam(r, "entrypointID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.DB().Model(&database.Entrypoint{}).Where(
|
||||||
|
"id = ? AND webhook_id = ?", entrypointID, webhook.ID,
|
||||||
|
).Update("description", r.PostFormValue("description"))
|
||||||
|
if result.Error != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to edit entrypoint", result.Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The id came from the URL and may name another webhook's
|
||||||
|
// entrypoint, which this webhook does not have.
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, entrypointSaved),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointDelete handles deleting an entrypoint.
|
||||||
|
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
||||||
|
return h.deleteChildResource(
|
||||||
|
"entrypointID", &database.Entrypoint{},
|
||||||
|
"failed to delete entrypoint",
|
||||||
|
nil,
|
||||||
|
entrypointDeleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointToggle handles toggling an entrypoint's
|
||||||
|
// active state.
|
||||||
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||||
|
return h.toggleChildResource(
|
||||||
|
"entrypointID",
|
||||||
|
func(webhookID, childID string) (bool, error) {
|
||||||
|
var ep database.Entrypoint
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND webhook_id = ?",
|
||||||
|
childID, webhookID,
|
||||||
|
).First(&ep).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the active column: saving the whole row would
|
||||||
|
// write back the description read above over an edit
|
||||||
|
// saved since.
|
||||||
|
active := !ep.Active
|
||||||
|
|
||||||
|
return active, h.db.DB().Model(&ep).
|
||||||
|
Update("active", active).Error
|
||||||
|
},
|
||||||
|
"failed to toggle entrypoint",
|
||||||
|
entrypointActivated, entrypointDeactivated,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page shows every request header.
|
||||||
views, ok := h.eventLogViews(
|
views, ok := h.eventLogViews(
|
||||||
w, r, webhookDB, webhook.ID, rows, targets,
|
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeliveryView is the display-safe projection of a delivery
|
||||||
|
// for the event log page. Its target is a TargetView, so the
|
||||||
|
// stored configuration blob — which holds the target's
|
||||||
|
// credential — has no path to the template.
|
||||||
|
type DeliveryView struct {
|
||||||
|
ID string
|
||||||
|
Status database.DeliveryStatus
|
||||||
|
Target delivery.TargetView
|
||||||
|
|
||||||
|
// Replay is set on a delivery the Replay action created.
|
||||||
|
Replay bool
|
||||||
|
|
||||||
|
// Created is how long ago the delivery was created, and
|
||||||
|
// CreatedUTC the full timestamp the page shows on hover.
|
||||||
|
Created string
|
||||||
|
CreatedUTC string
|
||||||
|
|
||||||
|
// Results is this delivery's attempts in attempt order,
|
||||||
|
// bounded by maxRenderedAttempts. Without them a failure
|
||||||
|
// renders as the status word alone and says nothing about
|
||||||
|
// why.
|
||||||
|
Results []DeliveryResultView
|
||||||
|
|
||||||
|
// AttemptCount is how many attempts were recorded, which
|
||||||
|
// is more than len(Results) once the middle was dropped.
|
||||||
|
AttemptCount int
|
||||||
|
|
||||||
|
// AttemptsOmitted is how many attempts were dropped from
|
||||||
|
// the middle of Results. The page must show it, or the
|
||||||
|
// bound would hide history rather than fold it.
|
||||||
|
AttemptsOmitted int
|
||||||
|
|
||||||
|
// Paused is set while the delivery is retrying and its
|
||||||
|
// target's circuit breaker is open, and nil otherwise.
|
||||||
|
Paused *PausedView
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogTarget is what the event log needs to know about
|
||||||
|
// one target: the display-safe view its template renders, and
|
||||||
|
// the redactor that keeps that target's own credential out of
|
||||||
|
// the text its remote peer chose. The two are kept together
|
||||||
|
// so a caller cannot pick up one without the other, and apart
|
||||||
|
// from TargetView so the secrets never reach a template.
|
||||||
|
type eventLogTarget struct {
|
||||||
|
View delivery.TargetView
|
||||||
|
Redactor delivery.Redactor
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event log's show query parameter and its two values: the events
|
||||||
|
// with a failed delivery, and those with a delivery still pending or
|
||||||
|
// retrying.
|
||||||
|
const (
|
||||||
|
showParam = "show"
|
||||||
|
showFailed = "failed"
|
||||||
|
showPending = "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSourceLogs shows the request/response logs for a
|
||||||
|
// webhook.
|
||||||
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := h.loadTargetMap(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
// Without the map every delivery renders through a
|
||||||
|
// zero redactor, so failing the page is the only
|
||||||
|
// safe answer.
|
||||||
|
h.serverError(w, r, "failed to load targets", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other value of show lists every event, as no value
|
||||||
|
// does.
|
||||||
|
show := r.URL.Query().Get(showParam)
|
||||||
|
|
||||||
|
statuses := eventLogStatuses(show)
|
||||||
|
if statuses == nil {
|
||||||
|
show = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
|
w, r, webhook, targets, statuses,
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, pending, err := h.countFailedAndPendingEvents(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to count events", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
tmplKeyWebhook: &webhook,
|
||||||
|
"Events": evts,
|
||||||
|
"TotalEvents": total,
|
||||||
|
"Show": show,
|
||||||
|
"FailedEvents": failed,
|
||||||
|
"PendingEvents": pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTargetMap loads targets into a map of display-safe
|
||||||
|
// views keyed by target ID, each paired with its redactor.
|
||||||
|
// The projection happens here so that no caller can hand a
|
||||||
|
// raw target, configuration blob and all, to a template: the
|
||||||
|
// raw rows do not leave this function.
|
||||||
|
//
|
||||||
|
// The load is Unscoped because deleting a target only soft
|
||||||
|
// deletes the row while its deliveries survive in the
|
||||||
|
// per-webhook database. Both halves of the map need those rows:
|
||||||
|
// a scoped load leaves an old delivery with a zero redactor,
|
||||||
|
// which renders its response bodies unredacted, and with a zero
|
||||||
|
// view, which renders its target as a blank name.
|
||||||
|
//
|
||||||
|
// This map is historical display only. It is built for the event
|
||||||
|
// log and an event's own page, and reaches nothing but
|
||||||
|
// DeliveryView.Target: the target list on the source detail page,
|
||||||
|
// the edit form and the replay path each resolve targets
|
||||||
|
// themselves, and a deleted row is refused there as before.
|
||||||
|
func (h *Handlers) loadTargetMap(
|
||||||
|
webhookID string,
|
||||||
|
) (map[string]eventLogTarget, error) {
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
err := h.db.DB().Unscoped().Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
).Find(&targets).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetMap := make(
|
||||||
|
map[string]eventLogTarget, len(targets),
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
targetMap[targets[i].ID] = eventLogTarget{
|
||||||
|
Redactor: delivery.NewRedactor(&targets[i]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The views come from NewTargetViews rather than being
|
||||||
|
// rebuilt here, so the masking rules stay in one place and a
|
||||||
|
// deleted target's configuration is masked by the same code
|
||||||
|
// that masks a live one's.
|
||||||
|
for _, v := range delivery.NewTargetViews(targets) {
|
||||||
|
entry := targetMap[v.ID]
|
||||||
|
entry.View = v
|
||||||
|
targetMap[v.ID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEventsWithDeliveries loads the recentEventLimit newest events
|
||||||
|
// and their deliveries from the per-webhook database, and the total
|
||||||
|
// number of events stored. Given delivery statuses, both cover only
|
||||||
|
// the events with a delivery in one of them. Events come back as
|
||||||
|
// capped projections rather than database.Event rows: see
|
||||||
|
// eventLogColumns for why the cut happens in SQL.
|
||||||
|
//
|
||||||
|
// The bool reports whether the load succeeded. It is false
|
||||||
|
// once this has answered the request with an error, and the
|
||||||
|
// caller must then render nothing further.
|
||||||
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
targetMap map[string]eventLogTarget,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) ([]EventLogView, int64, bool) {
|
||||||
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
return nil, 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to get webhook database", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, totalEvents, err := loadEventLogRows(
|
||||||
|
webhookDB, webhook.ID, statuses,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load events", err)
|
||||||
|
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := h.eventLogViews(
|
||||||
|
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||||
|
maxRenderedBodyBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result, totalEvents, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogViews projects loaded events for rendering, each with
|
||||||
|
// its deliveries, how many times it has been resubmitted and the
|
||||||
|
// entrypoint it arrived at (for a resubmitted copy, the one the
|
||||||
|
// request it copies arrived at), and with its request headers only
|
||||||
|
// when their text holds at most maxHeaderBytes. Like
|
||||||
|
// loadEventsWithDeliveries, it reports false once it has answered
|
||||||
|
// the request with an error.
|
||||||
|
func (h *Handlers) eventLogViews(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
rows []eventLogRow,
|
||||||
|
targetMap map[string]eventLogTarget,
|
||||||
|
maxHeaderBytes int,
|
||||||
|
) ([]EventLogView, bool) {
|
||||||
|
result := make([]EventLogView, len(rows))
|
||||||
|
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||||
|
|
||||||
|
var deliveryIDs []string
|
||||||
|
|
||||||
|
eventIDs := make([]string, len(rows))
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
result[i] = rows[i].view(webhookID, maxHeaderBytes)
|
||||||
|
eventIDs[i] = rows[i].ID
|
||||||
|
|
||||||
|
webhookDB.Where(
|
||||||
|
"event_id = ?", rows[i].ID,
|
||||||
|
).Find(&eventDeliveries[i])
|
||||||
|
|
||||||
|
for j := range eventDeliveries[i] {
|
||||||
|
deliveryIDs = append(
|
||||||
|
deliveryIDs, eventDeliveries[i][j].ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := h.loadDeliveryResults(
|
||||||
|
webhookDB, deliveryIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to load delivery attempts", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to count event resubmissions", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
entrypoints, err := h.entrypointNames(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load entrypoints", err)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
result[i].Deliveries = h.newDeliveryViews(
|
||||||
|
eventDeliveries[i], targetMap, attempts,
|
||||||
|
)
|
||||||
|
result[i].ResubmitCount = resubmits[rows[i].ID]
|
||||||
|
|
||||||
|
name, ok := entrypoints[rows[i].EntrypointID]
|
||||||
|
if !ok {
|
||||||
|
name = "deleted entrypoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i].Entrypoint = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEventLogRows reads the event log projection of the
|
||||||
|
// recentEventLimit newest events, newest first, and the total number
|
||||||
|
// of events stored, both narrowed by statuses as eventsWithStatus
|
||||||
|
// narrows them.
|
||||||
|
func loadEventLogRows(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) ([]eventLogRow, int64, error) {
|
||||||
|
totalEvents, err := countEventsWithStatus(
|
||||||
|
webhookDB, webhookID, statuses,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []eventLogRow
|
||||||
|
|
||||||
|
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
|
||||||
|
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
|
||||||
|
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
|
||||||
|
|
||||||
|
return rows, totalEvents, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogStatuses returns the delivery statuses the event log's show
|
||||||
|
// value lists events by, or nil for one that lists every event.
|
||||||
|
func eventLogStatuses(show string) []database.DeliveryStatus {
|
||||||
|
switch show {
|
||||||
|
case showFailed:
|
||||||
|
return []database.DeliveryStatus{database.DeliveryStatusFailed}
|
||||||
|
case showPending:
|
||||||
|
return []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsWithStatus selects the webhook's events, or, given statuses,
|
||||||
|
// the recentEventLimit newest of those with at least one delivery in
|
||||||
|
// one of them.
|
||||||
|
//
|
||||||
|
// Given statuses, its cost follows the matching deliveries. SQLite
|
||||||
|
// never reorders a CROSS JOIN, so it reads each join's left side
|
||||||
|
// first: the distinct event IDs of the matching deliveries, through
|
||||||
|
// idx_deliveries_status; then each of those events by ID, sorted to
|
||||||
|
// keep the newest; then the rows of only the events kept, so no other
|
||||||
|
// event's body is read. With a plain "id IN (matching deliveries)"
|
||||||
|
// condition instead, SQLite, which keeps no statistics on these
|
||||||
|
// tables, walks every event newest first.
|
||||||
|
func eventsWithStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) *gorm.DB {
|
||||||
|
if statuses == nil {
|
||||||
|
return webhookDB.Model(&database.Event{}).Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
matching := webhookDB.Model(&database.Delivery{}).
|
||||||
|
Distinct("event_id").Where("status IN ?", statuses)
|
||||||
|
|
||||||
|
newest := webhookDB.Table("(?) AS matching", matching).
|
||||||
|
Joins("CROSS JOIN events ON events.id = matching.event_id").
|
||||||
|
Where(
|
||||||
|
"events.webhook_id = ? AND events.deleted_at IS NULL",
|
||||||
|
webhookID,
|
||||||
|
).
|
||||||
|
Order("events.created_at DESC").Limit(recentEventLimit).
|
||||||
|
Select("events.id AS event_id")
|
||||||
|
|
||||||
|
return webhookDB.Table("(?) AS newest", newest).
|
||||||
|
Joins("CROSS JOIN events ON events.id = newest.event_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countEventsWithStatus counts the webhook's events with at least one
|
||||||
|
// delivery in one of the statuses, or every event when statuses is
|
||||||
|
// nil. Given statuses, it counts the distinct events of the matching
|
||||||
|
// deliveries and reads nothing but those deliveries, through
|
||||||
|
// idx_deliveries_status, where counting the events would read every
|
||||||
|
// event row. That is the same number, because retention deletes an
|
||||||
|
// event's deliveries with it.
|
||||||
|
func countEventsWithStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
if statuses == nil {
|
||||||
|
err := webhookDB.Model(&database.Event{}).Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
).Count(&count).Error
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.Delivery{}).Distinct("event_id").
|
||||||
|
Where("status IN ?", statuses).Count(&count).Error
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFailedAndPendingEvents returns how many of the webhook's events
|
||||||
|
// the event log lists when it shows only those with a failed delivery,
|
||||||
|
// and when it shows only those with a delivery pending or retrying.
|
||||||
|
func (h *Handlers) countFailedAndPendingEvents(
|
||||||
|
webhookID string,
|
||||||
|
) (int64, int64, error) {
|
||||||
|
if !h.dbMgr.DBExists(webhookID) {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, err := countEventsWithStatus(
|
||||||
|
webhookDB, webhookID, eventLogStatuses(showFailed),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := countEventsWithStatus(
|
||||||
|
webhookDB, webhookID, eventLogStatuses(showPending),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed, pending, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubmitCounts reports, for each of the page's events, how many
|
||||||
|
// events have been resubmitted from it.
|
||||||
|
//
|
||||||
|
// One grouped query covers the page rather than one query per event.
|
||||||
|
// The page shows at most recentEventLimit events, far below SQLite's
|
||||||
|
// bound parameter ceiling, so it needs no chunking as the delivery
|
||||||
|
// result load does.
|
||||||
|
func resubmitCounts(
|
||||||
|
webhookDB *gorm.DB, eventIDs []string,
|
||||||
|
) (map[string]int, error) {
|
||||||
|
counts := make(map[string]int, len(eventIDs))
|
||||||
|
|
||||||
|
if len(eventIDs) == 0 {
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
ResubmittedFromID string
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.Event{}).
|
||||||
|
Select("resubmitted_from_id, count(*) AS total").
|
||||||
|
Where("resubmitted_from_id IN ?", eventIDs).
|
||||||
|
Group("resubmitted_from_id").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
counts[row.ResubmittedFromID] = row.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliveryIDChunkSize bounds how many delivery IDs go into one
|
||||||
|
// IN clause. SQLite refuses a statement carrying more than
|
||||||
|
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
|
||||||
|
// page holds one delivery per target per event, so a webhook
|
||||||
|
// with enough targets would turn the whole query into an error
|
||||||
|
// and the page into zero attempts.
|
||||||
|
const deliveryIDChunkSize = 500
|
||||||
|
|
||||||
|
// loadDeliveryResults loads the recorded attempts for the
|
||||||
|
// page's deliveries, keyed by delivery ID.
|
||||||
|
//
|
||||||
|
// Each response body is cut by SQLite rather than in Go, for
|
||||||
|
// the reason deliveryResultColumns gives. How many attempts a
|
||||||
|
// delivery has is the target's MaxRetries, which the
|
||||||
|
// authenticated operator sets; how many of them reach the page
|
||||||
|
// is bounded again by maxRenderedAttempts.
|
||||||
|
func (h *Handlers) loadDeliveryResults(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryIDs []string,
|
||||||
|
) (map[string][]deliveryResultRow, error) {
|
||||||
|
byDelivery := make(map[string][]deliveryResultRow)
|
||||||
|
|
||||||
|
for chunk := range slices.Chunk(
|
||||||
|
deliveryIDs, deliveryIDChunkSize,
|
||||||
|
) {
|
||||||
|
var rows []deliveryResultRow
|
||||||
|
|
||||||
|
err := webhookDB.Model(
|
||||||
|
&database.DeliveryResult{},
|
||||||
|
).Select(
|
||||||
|
deliveryResultColumns, maxRenderedResponseBytes,
|
||||||
|
).Where(
|
||||||
|
"delivery_id IN ?", chunk,
|
||||||
|
).Order("attempt_num ASC").Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
// Returning what was loaded so far renders the
|
||||||
|
// deliveries in the failed chunk as never having run,
|
||||||
|
// which is indistinguishable from ones that really
|
||||||
|
// never ran. The page fails instead.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
byDelivery[rows[i].DeliveryID] = append(
|
||||||
|
byDelivery[rows[i].DeliveryID], rows[i],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return byDelivery, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDeliveryViews projects deliveries for rendering,
|
||||||
|
// resolving each one's target to its display-safe view and
|
||||||
|
// each one's attempts through that target's redactor. A
|
||||||
|
// retrying delivery also reads its target's circuit breaker.
|
||||||
|
func (h *Handlers) newDeliveryViews(
|
||||||
|
deliveries []database.Delivery,
|
||||||
|
targetMap map[string]eventLogTarget,
|
||||||
|
attempts map[string][]deliveryResultRow,
|
||||||
|
) []DeliveryView {
|
||||||
|
views := make([]DeliveryView, len(deliveries))
|
||||||
|
|
||||||
|
for i := range deliveries {
|
||||||
|
target := targetMap[deliveries[i].TargetID]
|
||||||
|
rows := attempts[deliveries[i].ID]
|
||||||
|
created := deliveries[i].CreatedAt
|
||||||
|
|
||||||
|
results, omitted := renderedAttempts(
|
||||||
|
rows, target.Redactor,
|
||||||
|
)
|
||||||
|
|
||||||
|
views[i] = DeliveryView{
|
||||||
|
ID: deliveries[i].ID,
|
||||||
|
Status: deliveries[i].Status,
|
||||||
|
Target: target.View,
|
||||||
|
Replay: deliveries[i].Replay,
|
||||||
|
Created: humanize.Time(created),
|
||||||
|
CreatedUTC: created.UTC().Format(time.DateTime) + " UTC",
|
||||||
|
Results: results,
|
||||||
|
AttemptCount: len(rows),
|
||||||
|
AttemptsOmitted: omitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
if deliveries[i].Status == database.DeliveryStatusRetrying {
|
||||||
|
views[i].Paused = h.deliveryPausedView(
|
||||||
|
deliveries[i].TargetID, rows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRenderedAttempts bounds how many of one delivery's
|
||||||
|
// attempts the page renders. Past it the middle is dropped and
|
||||||
|
// counted, keeping the first attempts and the last ones: how
|
||||||
|
// the delivery started failing and how it ended are what a
|
||||||
|
// reader needs, and the count says plainly that the rest was
|
||||||
|
// dropped rather than never recorded.
|
||||||
|
const (
|
||||||
|
renderedAttemptsHead = 10
|
||||||
|
renderedAttemptsTail = 10
|
||||||
|
maxRenderedAttempts = renderedAttemptsHead +
|
||||||
|
renderedAttemptsTail
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderedAttempts projects a delivery's attempts through the
|
||||||
|
// target's redactor, at most maxRenderedAttempts of them, and
|
||||||
|
// reports how many it dropped.
|
||||||
|
func renderedAttempts(
|
||||||
|
rows []deliveryResultRow,
|
||||||
|
redactor delivery.Redactor,
|
||||||
|
) ([]DeliveryResultView, int) {
|
||||||
|
omitted := 0
|
||||||
|
|
||||||
|
if len(rows) > maxRenderedAttempts {
|
||||||
|
omitted = len(rows) - maxRenderedAttempts
|
||||||
|
|
||||||
|
kept := make(
|
||||||
|
[]deliveryResultRow, 0, maxRenderedAttempts,
|
||||||
|
)
|
||||||
|
kept = append(kept, rows[:renderedAttemptsHead]...)
|
||||||
|
kept = append(
|
||||||
|
kept, rows[len(rows)-renderedAttemptsTail:]...,
|
||||||
|
)
|
||||||
|
rows = kept
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]DeliveryResultView, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
views[i] = rows[i].view(redactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return views, omitted
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEventLog_TimesCarryTheirZone proves that the event log shows
|
||||||
|
// when an event arrived, and that it and the event's page show when
|
||||||
|
// each delivery was created and each attempt recorded: each as how
|
||||||
|
// long ago, with the full UTC time on hover.
|
||||||
|
func TestEventLog_TimesCarryTheirZone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
receivedAt := now.Add(-3 * time.Hour)
|
||||||
|
createdAt := now.Add(-90 * time.Minute)
|
||||||
|
ranAt := now.Add(-30 * time.Minute)
|
||||||
|
|
||||||
|
event := f.event(t, contentTypeJSON, "{}", receivedAt)
|
||||||
|
dlv := f.deliveryQueuedAt(
|
||||||
|
t, event, target.ID, database.DeliveryStatusDelivered, createdAt,
|
||||||
|
)
|
||||||
|
f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt))
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
eventPage := w.Body.String()
|
||||||
|
eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
received := receivedAt.Format(time.DateTime)
|
||||||
|
assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago</span>`)
|
||||||
|
assert.NotContains(t, eventLog, received+"</span>",
|
||||||
|
"an event's time must not be written without its zone")
|
||||||
|
assert.Contains(t, eventPage, received+" UTC")
|
||||||
|
|
||||||
|
for _, page := range []string{eventLog, eventPage} {
|
||||||
|
assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+
|
||||||
|
` UTC">created 1 hour ago</span>`)
|
||||||
|
assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+
|
||||||
|
` UTC">30 minutes ago</span>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// eventLogColumns is the event log's projection. The casts to
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
@@ -10,16 +17,20 @@ import (
|
|||||||
// bytes rather than characters, so the cap bounds the page in
|
// bytes rather than characters, so the cap bounds the page in
|
||||||
// bytes whatever the payload's encoding. Cutting in SQLite
|
// bytes whatever the payload's encoding. Cutting in SQLite
|
||||||
// rather than in Go is the point of the projection — an
|
// rather than in Go is the point of the projection — an
|
||||||
// oversized body never becomes a Go string at all.
|
// oversized body or set of request headers never becomes a Go
|
||||||
|
// string at all.
|
||||||
const eventLogColumns = "id, created_at, method, content_type, " +
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, " +
|
"resubmitted_from_id, entrypoint_id, " +
|
||||||
|
"substr(cast(headers as blob), 1, ?) AS headers, " +
|
||||||
|
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
// eventColumns is eventLogColumns for the event's own page, which
|
// eventColumns is eventLogColumns for the event's own page, which
|
||||||
// shows the whole body.
|
// shows the whole body and every request header.
|
||||||
const eventColumns = "id, created_at, method, content_type, " +
|
const eventColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, " +
|
"resubmitted_from_id, entrypoint_id, headers, " +
|
||||||
|
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||||
"cast(body as blob) AS body, " +
|
"cast(body as blob) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
@@ -28,12 +39,32 @@ const eventColumns = "id, created_at, method, content_type, " +
|
|||||||
// DeliveryView and TargetView.
|
// DeliveryView and TargetView.
|
||||||
type EventLogView struct {
|
type EventLogView struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
|
|
||||||
|
// Received is how long ago the event arrived, and ReceivedUTC
|
||||||
|
// the full timestamp.
|
||||||
|
Received string
|
||||||
|
ReceivedUTC string
|
||||||
|
|
||||||
Body BodyView
|
Body BodyView
|
||||||
|
|
||||||
|
// Entrypoint names the entrypoint the event arrived at. A
|
||||||
|
// resubmitted copy, even a copy of a copy, did not arrive; it
|
||||||
|
// names the one the request it copies arrived at. The name is
|
||||||
|
// the entrypoint's description, "Entrypoint" when it has none,
|
||||||
|
// or "deleted entrypoint", never its URL, which is the
|
||||||
|
// entrypoint's secret.
|
||||||
|
Entrypoint string
|
||||||
|
|
||||||
|
// Headers is the event's request headers as text, one
|
||||||
|
// "Name: value" line per value, sorted by name. HeadersCut
|
||||||
|
// reports headers left out because they hold more than
|
||||||
|
// maxRenderedBodyBytes, stored or as text; only the event log
|
||||||
|
// leaves them out.
|
||||||
|
Headers string
|
||||||
|
HeadersCut bool
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// ResubmittedFromID names the event this one was copied
|
||||||
// from, empty for an event that arrived on the receiver.
|
// from, empty for an event that arrived on the receiver.
|
||||||
ResubmittedFromID string
|
ResubmittedFromID string
|
||||||
@@ -54,39 +85,121 @@ func (v EventLogView) ResubmittedFrom() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eventLogRow is one row of the event log projection, or of
|
// eventLogRow is one row of the event log projection, or of
|
||||||
// eventColumns. In the event log its body column arrives
|
// eventColumns. In the event log its headers and body columns
|
||||||
// already cut to the cap by SQLite, with the true size beside
|
// arrive already cut to the cap by SQLite, each with its true
|
||||||
// it.
|
// size beside it.
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
|
EntrypointID string
|
||||||
|
Headers string
|
||||||
|
HeadersBytes int64
|
||||||
Body []byte
|
Body []byte
|
||||||
BodyBytes int64
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row of the webhook's events for
|
// view projects a loaded row of the webhook's events for
|
||||||
// rendering.
|
// rendering. It shows the request headers when the row holds them
|
||||||
func (r *eventLogRow) view(webhookID string) EventLogView {
|
// whole and their text holds at most maxHeaderBytes.
|
||||||
|
func (r *eventLogRow) view(
|
||||||
|
webhookID string, maxHeaderBytes int,
|
||||||
|
) EventLogView {
|
||||||
var from string
|
var from string
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
from = *r.ResubmittedFromID
|
from = *r.ResubmittedFromID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
|
||||||
|
|
||||||
return EventLogView{
|
return EventLogView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
|
Received: humanize.Time(r.CreatedAt),
|
||||||
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
Body: newBodyView(
|
Body: newBodyView(
|
||||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||||
),
|
),
|
||||||
|
Headers: strings.Join(headers, "\n"),
|
||||||
|
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
||||||
ResubmittedFromID: from,
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestHeaderLines turns an event's stored request headers, the
|
||||||
|
// JSON the receiver writes, into one "Name: value" line per value,
|
||||||
|
// sorted by name. Headers that do not parse, as when the event log
|
||||||
|
// has cut them, show as none. It reports false, with no lines, when
|
||||||
|
// the lines, each with the newline that follows it, would hold more
|
||||||
|
// than maxBytes: a header sent many times is stored with its name
|
||||||
|
// once but shown with it on every line.
|
||||||
|
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
||||||
|
var headers http.Header
|
||||||
|
|
||||||
|
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(headers))
|
||||||
|
for name := range headers {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(names)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
size := 0
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
for _, value := range headers[name] {
|
||||||
|
line := name + ": " + value
|
||||||
|
|
||||||
|
size += len(line) + len("\n")
|
||||||
|
if size > maxBytes {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypointNames maps each of the webhook's entrypoints to the name
|
||||||
|
// an event that arrived at it shows: its description, or "Entrypoint"
|
||||||
|
// when it has none, as the webhook page names it. A deleted
|
||||||
|
// entrypoint is left out.
|
||||||
|
func (h *Handlers) entrypointNames(
|
||||||
|
webhookID string,
|
||||||
|
) (map[string]string, error) {
|
||||||
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
).Find(&entrypoints).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]string, len(entrypoints))
|
||||||
|
|
||||||
|
for i := range entrypoints {
|
||||||
|
name := entrypoints[i].Description
|
||||||
|
if name == "" {
|
||||||
|
name = "Entrypoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
names[entrypoints[i].ID] = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
// trimPartialRune drops a trailing UTF-8 sequence that the
|
// trimPartialRune drops a trailing UTF-8 sequence that the
|
||||||
// byte-wise cut left incomplete, so a multi-byte rune severed
|
// byte-wise cut left incomplete, so a multi-byte rune severed
|
||||||
// at the cap does not surface as a mojibake tail.
|
// at the cap does not surface as a mojibake tail.
|
||||||
|
|||||||
@@ -75,9 +75,7 @@ func seedAndProject(
|
|||||||
wh := seedWebhook(t, db)
|
wh := seedWebhook(t, db)
|
||||||
seedEventWithBody(t, dbMgr, wh.ID, body)
|
seedEventWithBody(t, dbMgr, wh.ID, body)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
|
|
||||||
return views[0]
|
return views[0]
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// arrivedAt is how a page names the entrypoint an event arrived at.
|
||||||
|
func arrivedAt(name string) string {
|
||||||
|
return `Arrived at <span class="text-gray-900 wrap-anywhere">` + name +
|
||||||
|
`</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
|
||||||
|
// the entrypoint the request it copies arrived at.
|
||||||
|
func copiedRequestArrivedAt(name string) string {
|
||||||
|
return `The request it copies arrived at ` +
|
||||||
|
`<span class="text-gray-900 wrap-anywhere">` + name + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerBox is how a page shows an event's request header lines: as
|
||||||
|
// one block of text in a single box.
|
||||||
|
func headerBox(lines ...string) string {
|
||||||
|
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
|
||||||
|
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
|
||||||
|
`break-all">` + strings.Join(lines, "\n") + `</pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHeadersLink is the event log's link to an event's own page for
|
||||||
|
// request headers it leaves out.
|
||||||
|
func showHeadersLink(webhookID, eventID string) string {
|
||||||
|
return `<a href="/hook/` + webhookID + `/events/` + eventID +
|
||||||
|
`" class="btn-small">Show the request headers</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypoint records one of the fixture webhook's entrypoints.
|
||||||
|
func (f *recentEventsFixture) entrypoint(
|
||||||
|
t *testing.T, description string,
|
||||||
|
) *database.Entrypoint {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
Path: uuid.NewString(),
|
||||||
|
Description: description,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventAt records an event that arrived at the entrypoint with the
|
||||||
|
// given request headers, stored as JSON as the receiver stores them.
|
||||||
|
func (f *recentEventsFixture) eventAt(
|
||||||
|
t *testing.T,
|
||||||
|
ep *database.Entrypoint,
|
||||||
|
headersJSON string,
|
||||||
|
receivedAt time.Time,
|
||||||
|
) *database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
EntrypointID: ep.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: headersJSON,
|
||||||
|
Body: "{}",
|
||||||
|
BodyBytes: 2,
|
||||||
|
ContentType: contentTypeJSON,
|
||||||
|
}
|
||||||
|
event.CreatedAt = receivedAt
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(event).Error)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
|
||||||
|
// events that arrived at two entrypoints each show their own
|
||||||
|
// entrypoint and request headers, in the event log and on their own
|
||||||
|
// pages, with the headers sorted by name, escaped and keeping their
|
||||||
|
// whitespace, and never the entrypoint's URL.
|
||||||
|
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
billing := f.entrypoint(t, "Billing sender")
|
||||||
|
unnamed := f.entrypoint(t, "")
|
||||||
|
|
||||||
|
// Stored in reverse name order.
|
||||||
|
older := f.eventAt(t, billing,
|
||||||
|
`{"X-Shop-Event":["order.created"],`+
|
||||||
|
`"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
|
||||||
|
time.Now().Add(-time.Minute))
|
||||||
|
newer := f.eventAt(t, unnamed,
|
||||||
|
`{"X-Shop-Event":["order.paid"],"X-Note":["<b>hi</b>"]}`,
|
||||||
|
time.Now())
|
||||||
|
|
||||||
|
olderShows := func(t *testing.T, page string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Contains(t, page, arrivedAt("Billing sender"))
|
||||||
|
assert.Contains(t, page, headerBox(
|
||||||
|
"Accept: */*",
|
||||||
|
"User-Agent: shop/1 build\t7",
|
||||||
|
"X-Shop-Event: order.created",
|
||||||
|
), "headers are sorted by name")
|
||||||
|
assert.NotContains(t, page, "order.paid")
|
||||||
|
assert.NotContains(t, page, billing.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
newerShows := func(t *testing.T, page string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
||||||
|
assert.Contains(t, page, headerBox(
|
||||||
|
"X-Note: <b>hi</b>",
|
||||||
|
"X-Shop-Event: order.paid",
|
||||||
|
))
|
||||||
|
assert.NotContains(t, page, "<b>hi</b>")
|
||||||
|
assert.NotContains(t, page, "order.created")
|
||||||
|
assert.NotContains(t, page, unnamed.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The log lists the newer event first, so everything between
|
||||||
|
// the two events' first mentions belongs to the newer one.
|
||||||
|
_, rest, found := strings.Cut(renderSourceLogsPage(
|
||||||
|
t, f.h, f.sess, f.webhook.ID,
|
||||||
|
), newer.ID)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
newerPart, olderPart, found := strings.Cut(rest, older.ID)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
newerShows(t, newerPart)
|
||||||
|
olderShows(t, olderPart)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
newerShows(t, w.Body.String())
|
||||||
|
|
||||||
|
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
olderShows(t, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
|
||||||
|
// has since been deleted says so in the event log and on its own page.
|
||||||
|
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Retired sender")
|
||||||
|
event := f.eventAt(t, ep, `{}`, time.Now())
|
||||||
|
|
||||||
|
require.NoError(t, f.db.DB().Delete(ep).Error)
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
|
||||||
|
assert.NotContains(t, page, "Retired sender")
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
|
||||||
|
assert.NotContains(t, w.Body.String(), "Retired sender")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
|
||||||
|
// of that copy each say the request they copy arrived at the
|
||||||
|
// entrypoint, in the event log and on their own pages, and never that
|
||||||
|
// they did.
|
||||||
|
func TestEventRequest_ResubmittedCopy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
|
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
|
||||||
|
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
|
||||||
|
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Model(copied).Update(
|
||||||
|
"resubmitted_from_id", original.ID,
|
||||||
|
).Error)
|
||||||
|
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
|
||||||
|
"resubmitted_from_id", copied.ID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
// The log lists the newest event first, and each event's Resubmit
|
||||||
|
// form comes before its entrypoint, so cutting the page at the
|
||||||
|
// copy's and the original's forms leaves each event's entrypoint
|
||||||
|
// in its own part.
|
||||||
|
copyOfCopyPart, rest, found := strings.Cut(
|
||||||
|
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||||
|
"/events/"+copied.ID+"/resubmit",
|
||||||
|
)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
copyPart, originalPart, found := strings.Cut(
|
||||||
|
rest, "/events/"+original.ID+"/resubmit",
|
||||||
|
)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
for _, part := range []string{copyOfCopyPart, copyPart} {
|
||||||
|
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
|
||||||
|
assert.NotContains(t, part, arrivedAt("Billing sender"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
||||||
|
assert.NotContains(t, originalPart,
|
||||||
|
copiedRequestArrivedAt("Billing sender"))
|
||||||
|
|
||||||
|
for _, event := range []*database.Event{copied, copyOfCopy} {
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(),
|
||||||
|
copiedRequestArrivedAt("Billing sender"))
|
||||||
|
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
|
||||||
|
// request headers that hold more than it shows of a body, whether
|
||||||
|
// stored or as lines, and links to the event's own page, which shows
|
||||||
|
// them all.
|
||||||
|
func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The receiver stores each "<" as six bytes of JSON, so this
|
||||||
|
// header is over the limit stored but not as a line.
|
||||||
|
const lessThans = bodyCap/6 + 1
|
||||||
|
|
||||||
|
// A header sent many times is stored with its name once, and
|
||||||
|
// shown with it on every line.
|
||||||
|
repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
headers http.Header
|
||||||
|
line string
|
||||||
|
}{
|
||||||
|
"stored": {
|
||||||
|
headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
|
||||||
|
line: "X-Long: " + strings.Repeat("<", lessThans),
|
||||||
|
},
|
||||||
|
"as lines": {
|
||||||
|
headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
|
||||||
|
line: repeatedName + ": ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
headersJSON, err := json.Marshal(tc.headers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
|
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
|
||||||
|
assert.NotContains(t, page, tc.line)
|
||||||
|
assert.Less(t, len(page), 4*bodyCap)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), tc.line)
|
||||||
|
assert.NotContains(t, w.Body.String(), "Show the request headers")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_ManyShortHeaderLines proves that for many short
|
||||||
|
// request header lines the event log writes no more than its limit,
|
||||||
|
// apart from escaping: lines that fill the limit show as one block of
|
||||||
|
// text, and one line more is left out with a link to the event's own
|
||||||
|
// page.
|
||||||
|
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each "A: " line and the newline after it hold four bytes, so
|
||||||
|
// this many lines fill the limit exactly. Each line in its own
|
||||||
|
// element would make the page many times the limit.
|
||||||
|
const fill = bodyCap / len("A: \n")
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
lines int
|
||||||
|
shown bool
|
||||||
|
}{
|
||||||
|
"filling the limit": {lines: fill, shown: true},
|
||||||
|
"one over the limit": {lines: fill + 1, shown: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
headersJSON, err := json.Marshal(http.Header{
|
||||||
|
"A": slices.Repeat([]string{""}, tc.lines),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
|
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
|
||||||
|
link := showHeadersLink(f.webhook.ID, event.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.shown, strings.Contains(page, box))
|
||||||
|
assert.Equal(t, !tc.shown, strings.Contains(page, link))
|
||||||
|
assert.Less(t, len(page), 4*bodyCap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,12 +51,6 @@ const (
|
|||||||
SidecarLeftMsgForTest = sidecarLeftMsg
|
SidecarLeftMsgForTest = sidecarLeftMsg
|
||||||
)
|
)
|
||||||
|
|
||||||
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
|
||||||
// package.
|
|
||||||
func PageOrFirstForTest(s string) int {
|
|
||||||
return pageOrFirst(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DummyVerificationsForTest reports how many equivalent-cost
|
// DummyVerificationsForTest reports how many equivalent-cost
|
||||||
// verifications were charged for usernames that do not exist. It
|
// verifications were charged for usernames that do not exist. It
|
||||||
// lets a test prove the anti-enumeration path ran without timing
|
// lets a test prove the anti-enumeration path ran without timing
|
||||||
@@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte {
|
|||||||
func (s *Handlers) LoadEventLogViewsForTest(
|
func (s *Handlers) LoadEventLogViewsForTest(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
page int,
|
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, newRequestForTest(), webhook, nil, page,
|
w, newRequestForTest(), webhook, nil, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
@@ -167,7 +160,16 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
|||||||
// buildDatabaseTargetConfig for use in the handlers_test
|
// buildDatabaseTargetConfig for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func BuildDatabaseTargetConfigForTest(
|
func BuildDatabaseTargetConfigForTest(
|
||||||
expiry string,
|
expiry, rotation string,
|
||||||
) (string, string, error) {
|
) (string, string, error) {
|
||||||
return buildDatabaseTargetConfig(expiry)
|
return buildDatabaseTargetConfig(expiry, rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveFileViewForTest exposes archiveFileView, which describes a
|
||||||
|
// database target's archive files as the target list shows them at
|
||||||
|
// now.
|
||||||
|
func (s *Handlers) ArchiveFileViewForTest(
|
||||||
|
webhook *database.Webhook, target *database.Target, now time.Time,
|
||||||
|
) *ArchiveFileView {
|
||||||
|
return s.archiveFileView(webhook, target, now)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||||
maxBodyShift = 20
|
maxBodyShift = 20
|
||||||
// recentEventLimit is the number of recent events to show.
|
// recentEventLimit is the number of most recent events that a
|
||||||
|
// webhook's page and its event log show.
|
||||||
recentEventLimit = 50
|
recentEventLimit = 50
|
||||||
// paginationPerPage is the number of items per page.
|
|
||||||
paginationPerPage = 25
|
|
||||||
|
|
||||||
// tmplKeyError is the template data key for an error message.
|
// tmplKeyError is the template data key for an error message.
|
||||||
tmplKeyError = "Error"
|
tmplKeyError = "Error"
|
||||||
@@ -57,19 +56,20 @@ var errVerificationBusy = errors.New(
|
|||||||
type HandlersParams struct {
|
type HandlersParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Session *session.Session
|
Session *session.Session
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Archives delivery.Archives
|
Archives delivery.Archives
|
||||||
SSRFGuard *delivery.Guard
|
CircuitBreakers delivery.CircuitBreakers
|
||||||
Metrics *metrics.Set
|
SSRFGuard *delivery.Guard
|
||||||
Registry *prometheus.Registry
|
Metrics *metrics.Set
|
||||||
|
Registry *prometheus.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP handler methods for all application
|
// Handlers provides HTTP handler methods for all application
|
||||||
@@ -84,6 +84,7 @@ type Handlers struct {
|
|||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
notifier delivery.Notifier
|
notifier delivery.Notifier
|
||||||
archives delivery.Archives
|
archives delivery.Archives
|
||||||
|
breakers delivery.CircuitBreakers
|
||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
|
|
||||||
@@ -98,7 +99,9 @@ type Handlers struct {
|
|||||||
// Interleaved, one could rename an archive between another's
|
// Interleaved, one could rename an archive between another's
|
||||||
// rename and save, leaving the file named for one edit and the
|
// rename and save, leaving the file named for one edit and the
|
||||||
// stored names from the other. An archive download holds it while
|
// stored names from the other. An archive download holds it while
|
||||||
// it reads the stored names and opens the file they give.
|
// it reads the stored names and lists the files they give, and
|
||||||
|
// again for each file while it finds the file under the names
|
||||||
|
// stored then and opens it.
|
||||||
renameMu sync.Mutex
|
renameMu sync.Mutex
|
||||||
|
|
||||||
// dummyVerifications counts the equivalent-cost verifications
|
// dummyVerifications counts the equivalent-cost verifications
|
||||||
@@ -148,6 +151,7 @@ func New(
|
|||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.archives = params.Archives
|
s.archives = params.Archives
|
||||||
|
s.breakers = params.CircuitBreakers
|
||||||
s.mtr = params.Metrics
|
s.mtr = params.Metrics
|
||||||
s.ssrf = params.SSRFGuard
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
@@ -163,10 +167,12 @@ func New(
|
|||||||
),
|
),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate(
|
"source_logs.html": parsePageTemplate(
|
||||||
"source_logs.html", "event_body.html", "delivery_attempts.html",
|
"source_logs.html", "event_request.html", "event_body.html",
|
||||||
|
"delivery_row.html", "delivery_attempts.html",
|
||||||
),
|
),
|
||||||
"event_detail.html": parsePageTemplate(
|
"event_detail.html": parsePageTemplate(
|
||||||
"event_detail.html", "event_body.html", "delivery_attempts.html",
|
"event_detail.html", "event_request.html", "event_body.html",
|
||||||
|
"delivery_row.html", "delivery_attempts.html",
|
||||||
),
|
),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
"error.html": parsePageTemplate("error.html"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -181,6 +182,40 @@ func (r *recordingArchives) Renames() []archiveRename {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testCircuitBreakers is a delivery.CircuitBreakers that reports, for
|
||||||
|
// each target, the circuit state and cooldown a test gave it with Set,
|
||||||
|
// and a closed breaker for any other target.
|
||||||
|
type testCircuitBreakers struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[string]delivery.CircuitState
|
||||||
|
cooldowns map[string]time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set makes the target's breaker read as state, with cooldown left.
|
||||||
|
func (b *testCircuitBreakers) Set(
|
||||||
|
targetID string, state delivery.CircuitState, cooldown time.Duration,
|
||||||
|
) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if b.states == nil {
|
||||||
|
b.states = map[string]delivery.CircuitState{}
|
||||||
|
b.cooldowns = map[string]time.Duration{}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.states[targetID] = state
|
||||||
|
b.cooldowns[targetID] = cooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *testCircuitBreakers) StateAndCooldown(
|
||||||
|
targetID string,
|
||||||
|
) (delivery.CircuitState, time.Duration) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
return b.states[targetID], b.cooldowns[targetID]
|
||||||
|
}
|
||||||
|
|
||||||
// newTestApp returns an app whose RequireStart fails the test when
|
// newTestApp returns an app whose RequireStart fails the test when
|
||||||
// starting takes longer than fx's default start timeout of 15s. That
|
// starting takes longer than fx's default start timeout of 15s. That
|
||||||
// limit catches a start that hangs, not a busy host: measured with make
|
// limit catches a start that hangs, not a busy host: measured with make
|
||||||
@@ -231,6 +266,12 @@ func newTestAppWithConfig(
|
|||||||
func(r *recordingArchives) delivery.Archives {
|
func(r *recordingArchives) delivery.Archives {
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
|
func() *testCircuitBreakers {
|
||||||
|
return &testCircuitBreakers{}
|
||||||
|
},
|
||||||
|
func(b *testCircuitBreakers) delivery.CircuitBreakers {
|
||||||
|
return b
|
||||||
|
},
|
||||||
metrics.NewRegistry,
|
metrics.NewRegistry,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
@@ -436,23 +477,37 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Empty expiry: the keep-forever default, empty config.
|
// Empty expiry and rotation: the keep-forever, one-file default,
|
||||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
// empty config.
|
||||||
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, errMsg)
|
assert.Empty(t, errMsg)
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
|
|
||||||
// Explicit never is stored as config.
|
// Explicit never is stored as config.
|
||||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, errMsg)
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||||
|
|
||||||
// A positive duration is stored as config.
|
// A positive duration is stored as config.
|
||||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, errMsg)
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||||
|
|
||||||
|
// A rotation is stored as config, with or without an expiry.
|
||||||
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
|
assert.JSONEq(t, `{"rotation":"daily"}`, cfg)
|
||||||
|
|
||||||
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest(
|
||||||
|
"720h", "hourly",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
|
assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
||||||
@@ -461,7 +516,7 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
@@ -471,3 +526,20 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDatabaseTargetConfig_RejectsBadRotation(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, bad := range []string{"weekly", "Daily", " none"} {
|
||||||
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(
|
||||||
|
t, errMsg, "Invalid archive rotation",
|
||||||
|
"rotation %q should be refused", bad,
|
||||||
|
)
|
||||||
|
assert.Empty(t, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
},
|
},
|
||||||
replayTargetDeleted: {
|
replayTargetDeleted: {
|
||||||
Text: "Not replayed: the target this delivery was for " +
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
"has been deleted. Recreate the target, then replay.",
|
"has been deleted. Use Resubmit to send the event " +
|
||||||
|
"to the webhook's currently active targets.",
|
||||||
Failed: true,
|
Failed: true,
|
||||||
},
|
},
|
||||||
replayTargetMissing: {
|
replayTargetMissing: {
|
||||||
@@ -95,7 +96,7 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
},
|
},
|
||||||
resubmitNoTargets: {
|
resubmitNoTargets: {
|
||||||
Text: "Resubmitted: a new event was created, but this " +
|
Text: "Resubmitted: a new event was created, but this " +
|
||||||
"source has no active targets, so nothing was queued.",
|
"webhook has no active targets, so nothing was queued.",
|
||||||
},
|
},
|
||||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
|||||||
data map[string]any
|
data map[string]any
|
||||||
title string
|
title string
|
||||||
}{
|
}{
|
||||||
{"login.html", map[string]any{}, "Login - Webhooker"},
|
{"login.html", map[string]any{}, "Sign in - Webhooker"},
|
||||||
{"profile.html", map[string]any{}, "Profile - Webhooker"},
|
{"profile.html", map[string]any{}, "Profile - Webhooker"},
|
||||||
{"settings.html", map[string]any{}, "Settings - Webhooker"},
|
{"settings.html", map[string]any{}, "Settings - Webhooker"},
|
||||||
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
|
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
|
||||||
@@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"source_logs.html",
|
"source_logs.html",
|
||||||
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
|
map[string]any{
|
||||||
|
dataKeyWebhook: webhook,
|
||||||
|
dataKeyEvents: []handlers.EventLogView{},
|
||||||
|
"TotalEvents": int64(0),
|
||||||
|
},
|
||||||
"Full Event Log - orders - Webhooker",
|
"Full Event Log - orders - Webhooker",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseRetentionDays interprets a retention_days form value. It
|
||||||
|
// returns the number of days, or, for a value it refuses, the message
|
||||||
|
// the create and edit forms show; the message is empty when the value
|
||||||
|
// is accepted.
|
||||||
|
//
|
||||||
|
// An empty value yields fallback, which lets the create path apply the
|
||||||
|
// default and the edit path leave the stored value unchanged. A value
|
||||||
|
// of 0 is returned as 0 and is rewritten to the retain-forever
|
||||||
|
// sentinel by database.Webhook's BeforeSave hook. Anything unparseable
|
||||||
|
// or negative is refused rather than silently given a default.
|
||||||
|
//
|
||||||
|
// The upper bound is not cosmetic. The reaper computes its cutoff as a
|
||||||
|
// time.Duration, an int64 nanosecond count, so a day count above
|
||||||
|
// database.MaxFiniteRetentionDays overflows, puts the cutoff in the
|
||||||
|
// future, and deletes every event the webhook has. A finite value
|
||||||
|
// above that ceiling is therefore refused, and the message names the
|
||||||
|
// ceiling rather than implying the input was not a number.
|
||||||
|
//
|
||||||
|
// A value at or above the retain-forever sentinel is not out of range:
|
||||||
|
// it is what the edit form pre-fills for a retain-forever webhook, so
|
||||||
|
// submitting the form back unchanged has to keep meaning "forever"
|
||||||
|
// rather than being rejected.
|
||||||
|
func parseRetentionDays(raw string, fallback int) (int, string) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v < 0 {
|
||||||
|
return 0, "Retention must be a whole number of days, or 0 to " +
|
||||||
|
"retain events forever."
|
||||||
|
}
|
||||||
|
|
||||||
|
if v >= database.RetentionForeverDays {
|
||||||
|
return database.RetentionForeverDays, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if v > database.MaxFiniteRetentionDays {
|
||||||
|
return 0, "Retention must be at most " +
|
||||||
|
strconv.Itoa(database.MaxFiniteRetentionDays) +
|
||||||
|
" days, or 0 to retain events forever."
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownedWebhook resolves the request's sourceID parameter to a
|
||||||
|
// webhook the session's user owns.
|
||||||
|
//
|
||||||
|
// Ownership and existence are decided by one query, so a
|
||||||
|
// webhook belonging to another user is indistinguishable from
|
||||||
|
// one that does not exist: both are a 404, and neither confirms
|
||||||
|
// the id. Callers that reach further into a webhook's data —
|
||||||
|
// the event log page and the event body download — share this
|
||||||
|
// one check rather than restating it, so the download cannot
|
||||||
|
// come to authorize differently from the page that links to it.
|
||||||
|
//
|
||||||
|
// It reports false once it has written the response, which is a
|
||||||
|
// redirect to the login page for an unauthenticated request and
|
||||||
|
// a 404 otherwise. The caller returns without writing more.
|
||||||
|
func (h *Handlers) ownedWebhook(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) (database.Webhook, bool) {
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return database.Webhook{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return database.Webhook{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhook, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteChildResource returns a handler that deletes a child
|
||||||
|
// resource (entrypoint or target) belonging to a webhook. The
|
||||||
|
// optional afterDelete hook runs with the child's id once the
|
||||||
|
// delete has removed it, before the redirect, which carries done as
|
||||||
|
// its notice.
|
||||||
|
func (h *Handlers) deleteChildResource(
|
||||||
|
idParam string,
|
||||||
|
model any,
|
||||||
|
errMsg string,
|
||||||
|
afterDelete func(childID string),
|
||||||
|
done noticeCode,
|
||||||
|
) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
childID := chi.URLParam(r, idParam)
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.DB().Where(
|
||||||
|
"id = ? AND webhook_id = ?",
|
||||||
|
childID, webhook.ID,
|
||||||
|
).Delete(model)
|
||||||
|
if result.Error != nil {
|
||||||
|
h.serverError(w, r, errMsg, result.Error)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only for a row this webhook really had: the id came from
|
||||||
|
// the URL and may name another webhook's child.
|
||||||
|
if afterDelete != nil && result.RowsAffected > 0 {
|
||||||
|
afterDelete(childID)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r,
|
||||||
|
withNotice("/hook/"+webhook.ID, done),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleChildResource returns a handler that toggles the active
|
||||||
|
// state of a child resource belonging to a webhook. toggleFn returns
|
||||||
|
// the new state, and the redirect carries activated or deactivated as
|
||||||
|
// its notice to match.
|
||||||
|
func (h *Handlers) toggleChildResource(
|
||||||
|
idParam string,
|
||||||
|
toggleFn func(webhookID, childID string) (bool, error),
|
||||||
|
errMsg string,
|
||||||
|
activated, deactivated noticeCode,
|
||||||
|
) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
childID := chi.URLParam(r, idParam)
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := toggleFn(webhook.ID, childID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, errMsg, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done := deactivated
|
||||||
|
if active {
|
||||||
|
done = activated
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r,
|
||||||
|
withNotice("/hook/"+webhook.ID, done),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserID extracts the user ID from the session.
|
||||||
|
func (h *Handlers) getUserID(
|
||||||
|
r *http.Request,
|
||||||
|
) (string, bool) {
|
||||||
|
sess, err := h.session.Get(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.session.IsAuthenticated(sess) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.session.GetUserID(sess)
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
|
|||||||
// retention, each with archive on. Nothing is created, and the form
|
// retention, each with archive on. Nothing is created, and the form
|
||||||
// comes back with the reason and every value entered: name,
|
// comes back with the reason and every value entered: name,
|
||||||
// description, retention, URL, the checked archive box and the pruning
|
// description, retention, URL, the checked archive box and the pruning
|
||||||
// choice.
|
// and rotation choices.
|
||||||
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -152,6 +152,7 @@ func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
|||||||
form.Set("http_url", tc.httpURL)
|
form.Set("http_url", tc.httpURL)
|
||||||
form.Set("archive", "on")
|
form.Set("archive", "on")
|
||||||
form.Set("archive_expiry", "2160h")
|
form.Set("archive_expiry", "2160h")
|
||||||
|
form.Set("archive_rotation", "hourly")
|
||||||
|
|
||||||
w := submitCreateForm(env, form)
|
w := submitCreateForm(env, form)
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
@@ -166,6 +167,7 @@ func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
|||||||
assert.Contains(t, page, `name="archive" value="on" checked`)
|
assert.Contains(t, page, `name="archive" value="on" checked`)
|
||||||
assert.Contains(t, page, `x-data="collapsible" data-open`)
|
assert.Contains(t, page, `x-data="collapsible" data-open`)
|
||||||
assert.Contains(t, page, `<option value="2160h" selected>`)
|
assert.Contains(t, page, `<option value="2160h" selected>`)
|
||||||
|
assert.Contains(t, page, `<option value="hourly" selected>`)
|
||||||
|
|
||||||
assertNothingCreated(t, env.db)
|
assertNothingCreated(t, env.db)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
assert.Contains(t, body, "1 configured")
|
assert.Contains(t, body, "1 configured")
|
||||||
assert.NotContains(t, body, "sekrit")
|
assert.NotContains(t, body, "sekrit")
|
||||||
|
|
||||||
assert.Contains(t, body, "Archive Expiry")
|
// The database type is called an archive: on its badge, in the
|
||||||
assert.Contains(t, body, "30 days")
|
// add target form's type list and in its settings.
|
||||||
|
list := targetList(t, body)
|
||||||
|
assert.Contains(t, list, "t-database archive Active")
|
||||||
|
assert.Contains(t, list, "Archive expiry: 30 days")
|
||||||
|
assert.Contains(t, list, "Archive rotation: none")
|
||||||
|
assert.Contains(t, body, `<option value="database">Archive</option>`)
|
||||||
|
|
||||||
// An unknown type gets the neutral placeholder, never the
|
// An unknown type gets the neutral placeholder, never the
|
||||||
// stored blob.
|
// stored blob.
|
||||||
@@ -275,3 +280,99 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|||||||
`<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">`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
|
||||||
|
// delete prompt on the webhook page names the webhook, entrypoint or
|
||||||
|
// target and says what deleting it loses, that the webhook's gives its
|
||||||
|
// number of stored events (5 received, 2 removed by retention, so 3,
|
||||||
|
// the statistics pane's "Within retention" figure), and that an
|
||||||
|
// entrypoint with no description is named by its URL. The template
|
||||||
|
// writes the slashes after http: as \/, which the browser reads as /.
|
||||||
|
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, database.AddEventTotals(
|
||||||
|
webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
|
||||||
|
))
|
||||||
|
|
||||||
|
unnamed := seedEntrypoint(t, db, wh.ID)
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(
|
||||||
|
&database.Entrypoint{
|
||||||
|
WebhookID: wh.ID,
|
||||||
|
Path: "described-" + wh.ID,
|
||||||
|
Description: "Stripe",
|
||||||
|
Active: true,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete webhook "delete-me"?\n\n`+
|
||||||
|
`This deletes its stored events (3) and their deliveries. `+
|
||||||
|
`Any archive files it wrote are kept.`)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete entrypoint "Stripe"?\n\n`+
|
||||||
|
`Senders using its URL get an error from now on, `+
|
||||||
|
`and the URL cannot be restored.`)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete entrypoint "http:\/\/example.com/h/`+
|
||||||
|
unnamed.Path+`"?`)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`Delete target "t-log"?\n\n`+
|
||||||
|
`Nothing more is delivered to it. `+
|
||||||
|
`Its past deliveries stay in the event log.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
||||||
|
// webhook name with quotes, a backslash, a closing script tag and a
|
||||||
|
// newline reaches its delete prompt escaped for the script, which the
|
||||||
|
// browser reads back as the name typed: each quote and angle bracket
|
||||||
|
// as a \u escape, the slash as \/, the newline as \n and the backslash
|
||||||
|
// doubled. An unescaped newline would break the prompt's script, and
|
||||||
|
// the form would then submit without asking.
|
||||||
|
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID,
|
||||||
|
Name: "Bob's \"best\" \\ hook</script>\nline two",
|
||||||
|
}
|
||||||
|
require.NoError(
|
||||||
|
t, db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, body,
|
||||||
|
"Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
|
||||||
|
"\\u003c\\/script\\u003e\\nline two"?")
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,44 @@ func TestHandleSourceLogs_NamesDeletedTarget(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_OffersNoReplayForDeletedTarget proves a
|
||||||
|
// finished delivery offers Replay while its target lives and not
|
||||||
|
// once the target is deleted. A replay to a deleted target is always
|
||||||
|
// refused, and recreating the target makes a new one that the old
|
||||||
|
// delivery does not name.
|
||||||
|
func TestHandleSourceLogs_OffersNoReplayForDeletedTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
_, failed := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
replayForm := `action="/hook/` + wh.ID + `/deliveries/` +
|
||||||
|
failed.ID + `/replay"`
|
||||||
|
|
||||||
|
before := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
assert.Contains(t, before, replayForm)
|
||||||
|
|
||||||
|
deleteTargetThroughHandler(t, h, sess, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
after := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
assert.NotContains(t, after, replayForm)
|
||||||
|
assert.NotContains(t, after, ">Replay<")
|
||||||
|
assert.Contains(t, after, tgt.Name+deletedMarker)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleSourceLogs_MasksDeletedTargetConfig proves that
|
// TestHandleSourceLogs_MasksDeletedTargetConfig proves that
|
||||||
// naming a deleted target does not widen what the page shows of
|
// naming a deleted target does not widen what the page shows of
|
||||||
// it: its stored configuration stays masked by exactly the rules
|
// it: its stored configuration stays masked by exactly the rules
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package handlers_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -153,3 +157,201 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
|
|||||||
assert.Contains(t, body, tgt.Name)
|
assert.Contains(t, body, tgt.Name)
|
||||||
assert.Contains(t, body, "delivered")
|
assert.Contains(t, body, "delivered")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
|
||||||
|
// holds the 50 newest events, newest first, and not one more, and says
|
||||||
|
// how many events there are in all.
|
||||||
|
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for i := range 51 {
|
||||||
|
f.event(
|
||||||
|
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
|
||||||
|
base.Add(time.Duration(i)*time.Second),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
||||||
|
assert.NotContains(t, body, "application/x-log-00")
|
||||||
|
assert.Contains(t, body, "application/x-log-01")
|
||||||
|
assert.Less(
|
||||||
|
t,
|
||||||
|
strings.Index(body, "application/x-log-50"),
|
||||||
|
strings.Index(body, "application/x-log-49"),
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, "50 most recent of 51 events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
|
||||||
|
// Failed list holds exactly the events with a failed delivery, the
|
||||||
|
// Pending list exactly those with a delivery pending or retrying, each
|
||||||
|
// once, and any other show value every event; that each link, and the
|
||||||
|
// line beside the heading, counts the events its list holds; and that
|
||||||
|
// the shown link is marked.
|
||||||
|
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
const (
|
||||||
|
failed = database.DeliveryStatusFailed
|
||||||
|
delivered = database.DeliveryStatusDelivered
|
||||||
|
pending = database.DeliveryStatusPending
|
||||||
|
retrying = database.DeliveryStatusRetrying
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each event is named by its content type. The first failed and
|
||||||
|
// was then replayed and delivered. The second failed, and so did
|
||||||
|
// its replay, and the fifth has one delivery pending and another
|
||||||
|
// retrying: each must still be listed and counted once.
|
||||||
|
events := []struct {
|
||||||
|
contentType string
|
||||||
|
deliveries []database.DeliveryStatus
|
||||||
|
}{
|
||||||
|
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
|
||||||
|
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
|
||||||
|
{"application/x-pending", []database.DeliveryStatus{pending}},
|
||||||
|
{"application/x-retrying", []database.DeliveryStatus{retrying}},
|
||||||
|
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
|
||||||
|
{"application/x-delivered", []database.DeliveryStatus{delivered}},
|
||||||
|
{"application/x-no-delivery", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
all := make([]string, len(events))
|
||||||
|
|
||||||
|
for i, e := range events {
|
||||||
|
event := f.event(
|
||||||
|
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, status := range e.deliveries {
|
||||||
|
f.delivery(t, event, target.ID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
all[i] = e.contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
query string
|
||||||
|
current string
|
||||||
|
heading string
|
||||||
|
listed []string
|
||||||
|
}{
|
||||||
|
{"", "All", "7 total events", all},
|
||||||
|
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
|
||||||
|
[]string{"application/x-failed", "application/x-failed-twice"}},
|
||||||
|
{"?show=pending", "Pending (3)",
|
||||||
|
"3 events with a delivery pending or retrying", []string{
|
||||||
|
"application/x-pending", "application/x-retrying",
|
||||||
|
"application/x-pending-retrying",
|
||||||
|
}},
|
||||||
|
{"?show=unknown", "All", "7 total events", all},
|
||||||
|
} {
|
||||||
|
body := renderSourceLogsPageWithQuery(
|
||||||
|
t, f.h, f.sess, f.webhook.ID, tc.query,
|
||||||
|
)
|
||||||
|
|
||||||
|
// One row per listed event, so with each listed event shown
|
||||||
|
// no event is listed twice.
|
||||||
|
assert.Equal(t, len(tc.listed),
|
||||||
|
strings.Count(body, `role="button"`), tc.query)
|
||||||
|
|
||||||
|
for _, contentType := range all {
|
||||||
|
assert.Equal(t,
|
||||||
|
slices.Contains(tc.listed, contentType),
|
||||||
|
strings.Contains(body, ">"+contentType+"<"),
|
||||||
|
tc.query+" "+contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(t, body, ">"+tc.heading+"<", tc.query)
|
||||||
|
assert.Contains(t, body, "Failed (2)", tc.query)
|
||||||
|
assert.Contains(t, body, "Pending (3)", tc.query)
|
||||||
|
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
|
||||||
|
assert.Contains(t, body,
|
||||||
|
`aria-current="page">`+tc.current+"</a>", tc.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
|
||||||
|
// list holds the 50 newest matching events, as the full log does,
|
||||||
|
// while its link and heading count every matching event.
|
||||||
|
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||||
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for i := range 51 {
|
||||||
|
event := f.event(
|
||||||
|
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
|
||||||
|
base.Add(time.Duration(i)*time.Second),
|
||||||
|
)
|
||||||
|
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The newest event has no failed delivery.
|
||||||
|
f.event(t, "application/x-no-delivery", "{}", time.Now())
|
||||||
|
|
||||||
|
body := renderSourceLogsPageWithQuery(
|
||||||
|
t, f.h, f.sess, f.webhook.ID, "?show=failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
||||||
|
assert.NotContains(t, body, "application/x-failed-00")
|
||||||
|
assert.NotContains(t, body, "application/x-no-delivery")
|
||||||
|
assert.Contains(t, body, "Failed (51)")
|
||||||
|
assert.Contains(t, body,
|
||||||
|
"50 most recent of 51 events with a failed delivery")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
|
||||||
|
// says that no event matches rather than that none was recorded.
|
||||||
|
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
f.delivery(
|
||||||
|
t, f.event(t, contentTypeJSON, "{}", time.Now()),
|
||||||
|
target.ID, database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Contains(t, renderSourceLogsPageWithQuery(
|
||||||
|
t, f.h, f.sess, f.webhook.ID, "?show=failed",
|
||||||
|
), "No event has a failed delivery.")
|
||||||
|
assert.Contains(t, renderSourceLogsPageWithQuery(
|
||||||
|
t, f.h, f.sess, f.webhook.ID, "?show=pending",
|
||||||
|
), "No event has a delivery pending or retrying.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
|
||||||
|
// events in the log only the newest starts expanded.
|
||||||
|
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
|
||||||
|
f.event(t, "application/x-newer", "{}", now)
|
||||||
|
|
||||||
|
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
||||||
|
|
||||||
|
open := strings.Index(body, " data-open>")
|
||||||
|
newer := strings.Index(body, "application/x-newer")
|
||||||
|
older := strings.Index(body, "application/x-older")
|
||||||
|
|
||||||
|
assert.Less(t, open, newer, "the newest event is not the open one")
|
||||||
|
assert.Less(t, newer, older)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleTargetCreate handles adding a new target to a webhook.
|
||||||
|
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
h.renameMu.Lock()
|
||||||
|
defer h.renameMu.Unlock()
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.processTargetCreate(w, r, webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processTargetCreate validates and creates a new target. A refused
|
||||||
|
// submission shows the webhook page again, with the add target form
|
||||||
|
// open on the chosen type, the values entered, and the reason.
|
||||||
|
func (h *Handlers) processTargetCreate(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
) {
|
||||||
|
in := targetFormInputFrom(r)
|
||||||
|
|
||||||
|
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
h.renderSourceDetail(w, r, webhook, in, errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.db.DB().Create(target).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to create target", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTarget validates a new target for a webhook and returns the row
|
||||||
|
// to create, or, when it refuses the target, the message the form
|
||||||
|
// shows. An error is the server's fault, not a refusal: the accepted
|
||||||
|
// configuration could not be encoded. Every form that creates a
|
||||||
|
// target goes through here, so they all accept and refuse the same
|
||||||
|
// things.
|
||||||
|
func (h *Handlers) newTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookID string,
|
||||||
|
in targetFormInput,
|
||||||
|
) (*database.Target, string, error) {
|
||||||
|
target := &database.Target{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Type: in.Type,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
errMsg, err := h.setTargetFromForm(ctx, target, in)
|
||||||
|
if err != nil || errMsg != "" {
|
||||||
|
return nil, errMsg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return target, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTargetFromForm validates a target form against the target's type
|
||||||
|
// and, when it accepts it, sets the target's name, configuration and
|
||||||
|
// retry count from it. It returns the message the form shows for
|
||||||
|
// anything it refuses, an unknown type among them, and then leaves the
|
||||||
|
// target unchanged; an error is the server's fault, as for newTarget.
|
||||||
|
// The add target form and the target edit form both go through here,
|
||||||
|
// so the two cannot come to disagree about what a target may be.
|
||||||
|
func (h *Handlers) setTargetFromForm(
|
||||||
|
ctx context.Context,
|
||||||
|
target *database.Target,
|
||||||
|
in targetFormInput,
|
||||||
|
) (string, error) {
|
||||||
|
if in.Name == "" {
|
||||||
|
return "Name is required", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
|
||||||
|
if err != nil || errMsg != "" {
|
||||||
|
return errMsg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty max_retries keeps the target's count: the
|
||||||
|
// fire-and-forget default of 0 for a new target, and the stored
|
||||||
|
// count for an edited one, since the forms for target types that
|
||||||
|
// do not retry have no such field. A value that is filled in but
|
||||||
|
// invalid is refused rather than becoming that count, so a typo
|
||||||
|
// cannot destroy the count a target is delivering with.
|
||||||
|
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
|
||||||
|
if err != nil {
|
||||||
|
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target.Name = in.Name
|
||||||
|
target.Config = configJSON
|
||||||
|
target.MaxRetries = maxRetries
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetFormInput carries the raw values of a target form. Both the
|
||||||
|
// create and the edit path fill one and hand it to setTargetFromForm,
|
||||||
|
// so neither can come to validate a target differently from the
|
||||||
|
// other. Both forms are filled from one: the edit form with the
|
||||||
|
// stored values, and a refused form with the values submitted.
|
||||||
|
type targetFormInput struct {
|
||||||
|
// Name is the target's name.
|
||||||
|
Name string
|
||||||
|
// Type is the type chosen on the add target form. The edit form
|
||||||
|
// has none: a target's stored type decides.
|
||||||
|
Type database.TargetType
|
||||||
|
// URL is the destination for an HTTP target and the webhook URL
|
||||||
|
// for a Slack target.
|
||||||
|
URL string
|
||||||
|
// Headers is an HTTP target's headers, one "Name: value" per
|
||||||
|
// line.
|
||||||
|
Headers string
|
||||||
|
// Timeout is an HTTP target's per-request timeout in seconds.
|
||||||
|
Timeout string
|
||||||
|
// MaxRetries is an HTTP or Slack target's max_retries.
|
||||||
|
MaxRetries string
|
||||||
|
// Expiry is a database (archive) target's row expiry.
|
||||||
|
Expiry string
|
||||||
|
// Rotation is a database (archive) target's rotation.
|
||||||
|
Rotation string
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetFormInputFrom reads a target form from a request body. The
|
||||||
|
// body size cap is enforced by the MaxBodySize middleware, which runs
|
||||||
|
// before CSRF parses the form.
|
||||||
|
//
|
||||||
|
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||||
|
// falls back to the query string, which would let
|
||||||
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
|
// configure a target from a value the request line carries — and the
|
||||||
|
// request line, unlike the body, is what logs, proxies, Referer
|
||||||
|
// headers and error trackers record. The headers field is under the
|
||||||
|
// same rule and for the same reason: its values are authorization
|
||||||
|
// tokens.
|
||||||
|
func targetFormInputFrom(r *http.Request) targetFormInput {
|
||||||
|
return targetFormInput{
|
||||||
|
Name: r.PostFormValue("name"),
|
||||||
|
Type: database.TargetType(r.PostFormValue("type")),
|
||||||
|
URL: r.PostFormValue("url"),
|
||||||
|
Headers: r.PostFormValue("headers"),
|
||||||
|
Timeout: r.PostFormValue("timeout"),
|
||||||
|
MaxRetries: r.PostFormValue("max_retries"),
|
||||||
|
Expiry: r.PostFormValue("expiry"),
|
||||||
|
Rotation: r.PostFormValue("rotation"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTargetConfig builds the JSON config string for a target from
|
||||||
|
// the submitted form values, or returns the message the form shows
|
||||||
|
// for a value it refuses. An error is the server's fault, not a
|
||||||
|
// refusal: the accepted configuration could not be encoded. Which
|
||||||
|
// fields of in apply depends on the target type; a type without a URL
|
||||||
|
// ignores any URL submitted.
|
||||||
|
func (h *Handlers) buildTargetConfig(
|
||||||
|
ctx context.Context,
|
||||||
|
targetType database.TargetType,
|
||||||
|
in targetFormInput,
|
||||||
|
) (string, string, error) {
|
||||||
|
switch targetType {
|
||||||
|
case database.TargetTypeHTTP:
|
||||||
|
return h.buildHTTPTargetConfig(ctx, in)
|
||||||
|
case database.TargetTypeSlack:
|
||||||
|
return h.buildSlackTargetConfig(ctx, in.URL)
|
||||||
|
case database.TargetTypeDatabase:
|
||||||
|
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
|
||||||
|
case database.TargetTypeLog:
|
||||||
|
return "", "", nil
|
||||||
|
default:
|
||||||
|
return "", "Invalid target type", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHTTPTargetConfig builds config JSON for an HTTP target: an
|
||||||
|
// SSRF-validated destination plus the optional headers and timeout
|
||||||
|
// the delivery path honours.
|
||||||
|
func (h *Handlers) buildHTTPTargetConfig(
|
||||||
|
ctx context.Context,
|
||||||
|
in targetFormInput,
|
||||||
|
) (string, string, error) {
|
||||||
|
errMsg := h.validateTargetURL(
|
||||||
|
ctx, in.URL, "URL is required for HTTP targets",
|
||||||
|
)
|
||||||
|
if errMsg != "" {
|
||||||
|
return "", errMsg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Sprintf("Invalid headers: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
|
||||||
|
URL: in.URL,
|
||||||
|
Headers: headers,
|
||||||
|
Timeout: timeout,
|
||||||
|
})
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSlackTargetConfig builds config JSON for a Slack target,
|
||||||
|
// whose whole configuration is one SSRF-validated webhook URL.
|
||||||
|
func (h *Handlers) buildSlackTargetConfig(
|
||||||
|
ctx context.Context,
|
||||||
|
targetURL string,
|
||||||
|
) (string, string, error) {
|
||||||
|
errMsg := h.validateTargetURL(
|
||||||
|
ctx, targetURL,
|
||||||
|
"Webhook URL is required for Slack targets",
|
||||||
|
)
|
||||||
|
if errMsg != "" {
|
||||||
|
return "", errMsg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
|
||||||
|
WebhookURL: targetURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTargetURL refuses an empty or SSRF-blocked destination,
|
||||||
|
// returning the message the form shows, or "" when the destination
|
||||||
|
// is accepted. missingMsg is the message for no URL at all.
|
||||||
|
//
|
||||||
|
// It is the single point at which a user-supplied destination enters
|
||||||
|
// the SSRF guard, on create and on edit alike. An edit path that
|
||||||
|
// reached storage without passing through here would reopen the hole
|
||||||
|
// the guard closes.
|
||||||
|
func (h *Handlers) validateTargetURL(
|
||||||
|
ctx context.Context,
|
||||||
|
targetURL, missingMsg string,
|
||||||
|
) string {
|
||||||
|
if targetURL == "" {
|
||||||
|
return missingMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
|
||||||
|
if err != nil {
|
||||||
|
// The submitted URL can be a credential (a Slack
|
||||||
|
// incoming webhook URL is a bearer token), so the log
|
||||||
|
// records only its scheme and host.
|
||||||
|
h.log.Warn(
|
||||||
|
"target URL blocked by SSRF protection",
|
||||||
|
"url", delivery.MaskURL(targetURL),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg := "Invalid target URL: " + err.Error()
|
||||||
|
|
||||||
|
// Only a private or reserved address's refusal says how
|
||||||
|
// to allow it. Other refusals never do: link-local, the
|
||||||
|
// unspecified addresses and the unconditional metadata
|
||||||
|
// addresses cannot be opened, and the default
|
||||||
|
// blocklist's public addresses, which listing does open,
|
||||||
|
// hand out credentials.
|
||||||
|
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||||
|
msg += ". Private and reserved addresses are refused " +
|
||||||
|
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||||
|
"setting allows named networks (see \"Allowing " +
|
||||||
|
"egress to your own network\" in the README)."
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalTargetConfig serialises a target configuration for storage.
|
||||||
|
func marshalTargetConfig(cfg any) (string, error) {
|
||||||
|
configBytes, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(configBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDatabaseTargetConfig builds config JSON for a database
|
||||||
|
// (archive) target. The optional expiry and rotation are validated
|
||||||
|
// here, at creation time, so a bad value is refused instead of
|
||||||
|
// failing every subsequent delivery. Each is stored only when set,
|
||||||
|
// and with neither the config is empty (the keep-forever, one-file
|
||||||
|
// default).
|
||||||
|
func buildDatabaseTargetConfig(
|
||||||
|
expiry, rotation string,
|
||||||
|
) (string, string, error) {
|
||||||
|
expiry = strings.TrimSpace(expiry)
|
||||||
|
|
||||||
|
err := delivery.ValidateArchiveExpiry(expiry)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = delivery.ValidateArchiveRotation(rotation)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := map[string]any{}
|
||||||
|
|
||||||
|
if expiry != "" {
|
||||||
|
cfg["expiry"] = expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
if rotation != "" {
|
||||||
|
cfg["rotation"] = rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg) == 0 {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := marshalTargetConfig(cfg)
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleTargetDelete handles deleting a target. A deleted
|
||||||
|
// database target's archive writer is evicted and its handle
|
||||||
|
// closed; the archive file is left on disk.
|
||||||
|
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
||||||
|
return h.deleteChildResource(
|
||||||
|
"targetID", &database.Target{},
|
||||||
|
"failed to delete target",
|
||||||
|
h.evictTargetArchiveWriter,
|
||||||
|
targetDeleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictTargetArchiveWriter is evictArchiveWriter for one deleted
|
||||||
|
// target, and leaves its archive file on disk for the same reason.
|
||||||
|
// A target that is not a database target has no writer, and
|
||||||
|
// evicting it does nothing.
|
||||||
|
func (h *Handlers) evictTargetArchiveWriter(targetID string) {
|
||||||
|
if h.archives == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.archives.EvictTarget(targetID)
|
||||||
|
}
|
||||||
@@ -27,13 +27,11 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := context.WithoutCancel(r.Context())
|
ctx := context.WithoutCancel(r.Context())
|
||||||
|
|
||||||
webhook, target, export, ok := h.openTargetArchive(ctx, w, r)
|
webhook, target, export, ok := h.listTargetArchive(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = export.Close() }()
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
@@ -81,16 +79,15 @@ func (d downloadWriter) Write(b []byte) (int, error) {
|
|||||||
return d.w.Write(b)
|
return d.w.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openTargetArchive opens the archive of the request's database target
|
// listTargetArchive lists the archive files of the request's database
|
||||||
// for export, with its reads under ctx. It reports false once it has
|
// target for export. It reports false once it has written the response.
|
||||||
// written the response.
|
|
||||||
//
|
//
|
||||||
// It holds renameMu, which every archive rename runs under, while it
|
// It holds renameMu, which every archive rename runs under, while it
|
||||||
// reads the stored names and opens the file, so the file it opens is
|
// reads the stored names and lists the files, so the files it lists
|
||||||
// the one those names give. It lets go before the export is streamed:
|
// are the ones those names give. It lets go before the export is
|
||||||
// once the file is open, a rename does not affect the export.
|
// streamed, which takes renameMu again for each file only while it
|
||||||
func (h *Handlers) openTargetArchive(
|
// finds the file under the names stored then and opens it.
|
||||||
ctx context.Context,
|
func (h *Handlers) listTargetArchive(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
||||||
@@ -108,14 +105,43 @@ func (h *Handlers) openTargetArchive(
|
|||||||
return database.Webhook{}, nil, nil, false
|
return database.Webhook{}, nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
export, err := delivery.OpenArchiveExport(
|
export, err := delivery.NewArchiveExport(
|
||||||
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
|
delivery.ArchivePath(h.dbMgr, &webhook, target),
|
||||||
|
&h.renameMu,
|
||||||
|
func() (string, error) {
|
||||||
|
return h.storedArchivePath(webhook.ID, target.ID)
|
||||||
|
},
|
||||||
|
h.log,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to open archive for export", err)
|
h.serverError(w, r, "failed to list archive for export", err)
|
||||||
|
|
||||||
return database.Webhook{}, nil, nil, false
|
return database.Webhook{}, nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return webhook, target, export, true
|
return webhook, target, export, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storedArchivePath returns the path delivery.ArchivePath gives a
|
||||||
|
// database target under the names stored for it and its webhook now.
|
||||||
|
// Its caller holds renameMu. A webhook or target deleted since is still
|
||||||
|
// found, since deleting one leaves its archive files under their names.
|
||||||
|
func (h *Handlers) storedArchivePath(
|
||||||
|
webhookID, targetID string,
|
||||||
|
) (string, error) {
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Unscoped().First(&webhook, "id = ?", webhookID).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var target database.Target
|
||||||
|
|
||||||
|
err = h.db.DB().Unscoped().First(&target, "id = ?", targetID).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -85,7 +87,7 @@ func TestHandleTargetDownload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetDownload_WaitsForRename proves a download reads the
|
// TestHandleTargetDownload_WaitsForRename proves a download reads the
|
||||||
// target's names and opens its archive under the lock a rename holds:
|
// target's names and lists its archive under the lock a rename holds:
|
||||||
// started while an edit is renaming the archive, it waits, and is
|
// started while an edit is renaming the archive, it waits, and is
|
||||||
// named for the target's new name.
|
// named for the target's new name.
|
||||||
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
||||||
@@ -148,18 +150,17 @@ func (s *stalledWriter) Write(b []byte) (int, error) {
|
|||||||
return s.ResponseRecorder.Write(b)
|
return s.ResponseRecorder.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
// startStalledDownload starts a download of the target and returns once
|
||||||
// lets go of the rename lock once its archive is open: while the
|
// it is stalled at its first write, which comes before it opens any
|
||||||
// download is stalled writing, an edit can still rename the target.
|
// archive file. Closing the writer's resume lets it go on; the returned
|
||||||
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
// channel is closed when it has finished.
|
||||||
t.Parallel()
|
func startStalledDownload(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||||
env := setupSourceTest(t)
|
) (*stalledWriter, <-chan struct{}) {
|
||||||
wh := seedWebhookWithRetention(t, env.db, 7)
|
t.Helper()
|
||||||
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
t.Context(), http.MethodGet, downloadPath(wh.ID, archive.ID), nil,
|
t.Context(), http.MethodGet, downloadPath(webhookID, targetID), nil,
|
||||||
)
|
)
|
||||||
for _, c := range env.cookies {
|
for _, c := range env.cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
@@ -179,6 +180,21 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
|||||||
|
|
||||||
<-sw.writing
|
<-sw.writing
|
||||||
|
|
||||||
|
return sw, downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
||||||
|
// lets go of the rename lock once it has listed its archive: while the
|
||||||
|
// download is stalled writing, an edit can still rename the target.
|
||||||
|
func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
|
||||||
|
|
||||||
edited := make(chan *httptest.ResponseRecorder, 1)
|
edited := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -197,6 +213,62 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, sw.Code)
|
assert.Equal(t, http.StatusOK, sw.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleTargetDownload_FindsFilesAfterRename proves a download
|
||||||
|
// finds each of the target's files again under the names stored when
|
||||||
|
// it reaches the file: the target is renamed while the download is
|
||||||
|
// stalled before it has opened any file, and the rows of both its files
|
||||||
|
// are in the download.
|
||||||
|
func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 7)
|
||||||
|
archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase)
|
||||||
|
oldPath := delivery.ArchivePath(env.dbMgr, &wh, archive)
|
||||||
|
|
||||||
|
month := func(path string) string {
|
||||||
|
return strings.TrimSuffix(path, ".db") + "-2026-10.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
seedArchive(t, oldPath, 1, 16)
|
||||||
|
seedArchive(t, month(oldPath), 1, 16)
|
||||||
|
|
||||||
|
sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID)
|
||||||
|
|
||||||
|
require.Equal(t,
|
||||||
|
http.StatusSeeOther, renameTarget(env, wh.ID, archive.ID).Code,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test's archives record a rename without moving any file, so
|
||||||
|
// the files are moved here, as the delivery engine moves them.
|
||||||
|
var renamed database.Target
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().First(&renamed, "id = ?", archive.ID).Error)
|
||||||
|
|
||||||
|
newPath := delivery.ArchivePath(env.dbMgr, &wh, &renamed)
|
||||||
|
|
||||||
|
require.NoError(t, os.Rename(oldPath, newPath))
|
||||||
|
require.NoError(t, os.Rename(month(oldPath), month(newPath)))
|
||||||
|
|
||||||
|
close(sw.resume)
|
||||||
|
<-downloaded
|
||||||
|
require.Equal(t, http.StatusOK, sw.Code)
|
||||||
|
|
||||||
|
zr, err := gzip.NewReader(sw.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var (
|
||||||
|
got map[string]json.RawMessage
|
||||||
|
events []map[string]any
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, json.NewDecoder(zr).Decode(&got))
|
||||||
|
require.NoError(t, json.Unmarshal(got["archived_events"], &events))
|
||||||
|
require.Len(t, events, 2)
|
||||||
|
assert.NotContains(t, events[0], "period")
|
||||||
|
assert.Equal(t, "2026-10", events[1]["period"])
|
||||||
|
}
|
||||||
|
|
||||||
// seedArchive writes rows to the archive file at path, each with a
|
// seedArchive writes rows to the archive file at path, each with a
|
||||||
// body of bodySize random bytes, which do not compress. Its table has
|
// body of bodySize random bytes, which do not compress. Its table has
|
||||||
// only the columns the test fills; an export writes the others empty.
|
// only the columns the test fills; an export writes the others empty.
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
|||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
MaxRetries: strconv.Itoa(target.MaxRetries),
|
MaxRetries: strconv.Itoa(target.MaxRetries),
|
||||||
Expiry: cfg.Expiry,
|
Expiry: cfg.Expiry,
|
||||||
|
Rotation: cfg.Rotation,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTargetEdit(
|
h.renderTargetEdit(
|
||||||
@@ -242,6 +243,9 @@ func (h *Handlers) renderTargetEdit(
|
|||||||
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
||||||
tmplKeyError: errMsg,
|
tmplKeyError: errMsg,
|
||||||
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
|
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
|
||||||
|
tmplKeyArchiveRotationChoices: archiveRotationOptions(
|
||||||
|
form.Rotation,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
|
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
|
||||||
|
|||||||
@@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
|||||||
assert.Contains(t, page, "original-name")
|
assert.Contains(t, page, "original-name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
|
||||||
|
// edit page of a database target gives its type and its settings to
|
||||||
|
// the ones its badge and the add target form use.
|
||||||
|
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodGet,
|
||||||
|
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
|
||||||
|
assert.Contains(t, page, "Type: archive.")
|
||||||
|
assert.Contains(t, page, `class="label">Archive expiry</label>`)
|
||||||
|
assert.Contains(t, page, `class="label">Archive rotation</label>`)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
||||||
// must not reach storage.
|
// must not reach storage.
|
||||||
//
|
//
|
||||||
@@ -588,10 +612,11 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
|||||||
{
|
{
|
||||||
database.TargetTypeSlack,
|
database.TargetTypeSlack,
|
||||||
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
||||||
"Invalid max retries",
|
"Invalid delivery attempts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
database.TargetTypeDatabase, "name=edited&expiry=7d",
|
database.TargetTypeDatabase,
|
||||||
|
"name=edited&expiry=7d&rotation=daily",
|
||||||
"Invalid archive expiry",
|
"Invalid archive expiry",
|
||||||
},
|
},
|
||||||
{database.TargetTypeLog, "name=", "Name is required"},
|
{database.TargetTypeLog, "name=", "Name is required"},
|
||||||
@@ -613,15 +638,15 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
|||||||
page := w.Body.String()
|
page := w.Body.String()
|
||||||
assert.Contains(t, page, `class="alert-error">`+tc.reason)
|
assert.Contains(t, page, `class="alert-error">`+tc.reason)
|
||||||
|
|
||||||
// headers is the form's one textarea and expiry its one
|
// headers is the form's one textarea, and expiry and
|
||||||
// select; every other field is an input.
|
// rotation its selects; every other field is an input.
|
||||||
for field := range form {
|
for field := range form {
|
||||||
shown := `name="` + field + `" value="` + form.Get(field) + `"`
|
shown := `name="` + field + `" value="` + form.Get(field) + `"`
|
||||||
|
|
||||||
switch field {
|
switch field {
|
||||||
case "headers":
|
case "headers":
|
||||||
shown = ">" + form.Get(field) + "</textarea>"
|
shown = ">" + form.Get(field) + "</textarea>"
|
||||||
case "expiry":
|
case "expiry", "rotation":
|
||||||
shown = `<option value="` + form.Get(field) + `" selected>`
|
shown = `<option value="` + form.Get(field) + `" selected>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,9 +22,86 @@ type TargetRowView struct {
|
|||||||
// and is nil when the webhook's event database could not be read.
|
// and is nil when the webhook's event database could not be read.
|
||||||
Deliveries *TargetDeliveries
|
Deliveries *TargetDeliveries
|
||||||
|
|
||||||
// Archive is a database target's archive file, and nil for a target
|
// Archive is a database target's archive files, and nil for a
|
||||||
// of any other type.
|
// target of any other type.
|
||||||
Archive *ArchiveFileView
|
Archive *ArchiveFileView
|
||||||
|
|
||||||
|
// Paused is set while the target's circuit breaker is turning its
|
||||||
|
// deliveries away, and nil otherwise.
|
||||||
|
Paused *PausedView
|
||||||
|
}
|
||||||
|
|
||||||
|
// PausedView is a target's circuit breaker turning deliveries away.
|
||||||
|
// While the breaker is open, Until is a time in UTC, and Relative how
|
||||||
|
// long that is from now: on the target's row, when the cooldown ends;
|
||||||
|
// on a delivery, the earliest it can be tried next. While it is
|
||||||
|
// half-open both are empty: the cooldown has ended, and the target's
|
||||||
|
// deliveries are held while one delivery tests whether the target has
|
||||||
|
// recovered.
|
||||||
|
type PausedView struct {
|
||||||
|
Until string
|
||||||
|
Relative string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pausedView reads the target's circuit breaker for its row, and
|
||||||
|
// returns nil when the breaker lets the target's deliveries through.
|
||||||
|
func (h *Handlers) pausedView(targetID string) *PausedView {
|
||||||
|
state, cooldown := h.breakers.StateAndCooldown(targetID)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case state == delivery.CircuitHalfOpen:
|
||||||
|
return &PausedView{}
|
||||||
|
case state == delivery.CircuitOpen && cooldown > 0:
|
||||||
|
return newPausedView(time.Now().Add(cooldown))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliveryPausedView reads the circuit breaker of a retrying delivery's
|
||||||
|
// target. While it is open, it says the earliest the delivery can be
|
||||||
|
// tried next: the later of the cooldown's end and the end of the
|
||||||
|
// delivery's own backoff after its last attempt. It is only the
|
||||||
|
// earliest: when the cooldown ends, one of the target's waiting
|
||||||
|
// deliveries is sent to test it while the others wait at least one more
|
||||||
|
// cooldown. Otherwise it returns nil, half-open included, since the
|
||||||
|
// delivery may then be the one being sent to test the target.
|
||||||
|
func (h *Handlers) deliveryPausedView(
|
||||||
|
targetID string, attempts []deliveryResultRow,
|
||||||
|
) *PausedView {
|
||||||
|
state, cooldown := h.breakers.StateAndCooldown(targetID)
|
||||||
|
if state != delivery.CircuitOpen || cooldown <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
next := time.Now().Add(cooldown)
|
||||||
|
|
||||||
|
if len(attempts) > 0 {
|
||||||
|
last := attempts[len(attempts)-1]
|
||||||
|
|
||||||
|
backoffEnd := last.CreatedAt.Add(delivery.Backoff(last.AttemptNum))
|
||||||
|
if backoffEnd.After(next) {
|
||||||
|
next = backoffEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPausedView(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPausedView is a PausedView of deliveries paused until the given
|
||||||
|
// time. A time not on the current UTC day is written with its date.
|
||||||
|
func newPausedView(until time.Time) *PausedView {
|
||||||
|
until = until.UTC()
|
||||||
|
|
||||||
|
layout := time.TimeOnly
|
||||||
|
if until.Format(time.DateOnly) != time.Now().UTC().Format(time.DateOnly) {
|
||||||
|
layout = time.DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PausedView{
|
||||||
|
Until: until.Format(layout) + " UTC",
|
||||||
|
Relative: humanize.Time(until),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TargetDeliveries is how many of a target's deliveries became
|
// TargetDeliveries is how many of a target's deliveries became
|
||||||
@@ -39,17 +117,23 @@ type TargetDeliveries struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveFileView is what a database target's row shows about its
|
// ArchiveFileView is what a database target's row shows about its
|
||||||
// archive file.
|
// archive files.
|
||||||
type ArchiveFileView struct {
|
type ArchiveFileView struct {
|
||||||
|
// Name is the name of the file an event received now goes to.
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
// Note stands in for the size and the last write when there are
|
// Note says that file does not exist yet, or that the files could
|
||||||
// none to show, and is empty when there are.
|
// not be read, and is empty otherwise.
|
||||||
Note string
|
Note string
|
||||||
|
|
||||||
// Size is the size on disk. Written is how long ago the file was
|
// Files counts the target's archive files, and is 0 when there are
|
||||||
// last written, and WrittenUTC the full time the page shows on
|
// none, or they could not be read, and so no size or last write to
|
||||||
// hover.
|
// show.
|
||||||
|
Files int
|
||||||
|
|
||||||
|
// Size is the size on disk of all the files together. Written is
|
||||||
|
// how long ago the latest of them was last written, and WrittenUTC
|
||||||
|
// the full time the page shows on hover.
|
||||||
Size string
|
Size string
|
||||||
Written string
|
Written string
|
||||||
WrittenUTC string
|
WrittenUTC string
|
||||||
@@ -62,6 +146,7 @@ func (h *Handlers) targetRows(
|
|||||||
) []TargetRowView {
|
) []TargetRowView {
|
||||||
views := delivery.NewTargetViews(targets)
|
views := delivery.NewTargetViews(targets)
|
||||||
rows := make([]TargetRowView, len(views))
|
rows := make([]TargetRowView, len(views))
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
deliveries, err := h.loadTargetDeliveries(webhook.ID)
|
deliveries, err := h.loadTargetDeliveries(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,8 +167,10 @@ func (h *Handlers) targetRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if targets[i].Type == database.TargetTypeDatabase {
|
if targets[i].Type == database.TargetTypeDatabase {
|
||||||
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
|
rows[i].Archive = h.archiveFileView(webhook, &targets[i], now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rows[i].Paused = h.pausedView(targets[i].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
@@ -147,34 +234,57 @@ func readTargetDeliveries(
|
|||||||
return byTarget, nil
|
return byTarget, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveFileView describes a database target's archive file from the
|
// archiveFileView describes a database target's archive files from
|
||||||
// file's metadata alone; the archive is never opened. The file is found
|
// their metadata alone; the archive is never opened. It names the file
|
||||||
// by the name the archive writer uses, so it follows a rename of the
|
// an event received at now goes to. The files are found by the name
|
||||||
// webhook or the target.
|
// the archive writer uses, so they follow a rename of the webhook or
|
||||||
|
// the target.
|
||||||
func (h *Handlers) archiveFileView(
|
func (h *Handlers) archiveFileView(
|
||||||
webhook *database.Webhook, target *database.Target,
|
webhook *database.Webhook, target *database.Target, now time.Time,
|
||||||
) *ArchiveFileView {
|
) *ArchiveFileView {
|
||||||
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now)
|
||||||
view := &ArchiveFileView{Name: filepath.Base(path)}
|
if err != nil {
|
||||||
|
return h.archiveUnreadable(&ArchiveFileView{}, target, err)
|
||||||
|
}
|
||||||
|
|
||||||
file, err := delivery.StatArchive(path)
|
view := &ArchiveFileView{Name: filepath.Base(current)}
|
||||||
|
|
||||||
|
_, statErr := os.Stat(current)
|
||||||
|
if errors.Is(statErr, fs.ErrNotExist) {
|
||||||
|
view.Note = "not created yet"
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := delivery.StatArchive(
|
||||||
|
delivery.ArchivePath(h.dbMgr, webhook, target),
|
||||||
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
view.Note = "not created yet"
|
// No files: no size or last write to show.
|
||||||
case err != nil:
|
case err != nil:
|
||||||
h.log.Error(
|
return h.archiveUnreadable(view, target, err)
|
||||||
"failed to read archive file metadata",
|
|
||||||
"target_id", target.ID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
view.Note = "could not be read"
|
|
||||||
default:
|
default:
|
||||||
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
|
view.Files = files.Files
|
||||||
view.Written = humanize.Time(file.Written)
|
view.Size = humanize.Bytes(uint64(files.Size)) //nolint:gosec // never negative
|
||||||
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
|
view.Written = humanize.Time(files.Written)
|
||||||
|
view.WrittenUTC = files.Written.UTC().Format(time.DateTime) + " UTC"
|
||||||
}
|
}
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// archiveUnreadable logs why a database target's archive files could
|
||||||
|
// not be described, and returns view saying so.
|
||||||
|
func (h *Handlers) archiveUnreadable(
|
||||||
|
view *ArchiveFileView, target *database.Target, err error,
|
||||||
|
) *ArchiveFileView {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to read archive file metadata",
|
||||||
|
"target_id", target.ID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
view.Note = "could not be read"
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
path := delivery.ArchivePath(dbMgr, wh, archive)
|
path := delivery.ArchivePath(dbMgr, wh, archive)
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
|
assert.Equal(t, 1, strings.Count(body, "Archive file:"))
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
assert.Contains(t, body, filepath.Base(path))
|
||||||
assert.Contains(t, body, "not created yet")
|
assert.Contains(t, body, "not created yet")
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
assert.NotContains(t, body, "Archive size:")
|
||||||
|
|
||||||
seedArchive(t, path, 1, 100)
|
seedArchive(t, path, 1, 100)
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
assert.Contains(t, body, filepath.Base(path))
|
assert.Contains(t, body, filepath.Base(path))
|
||||||
assert.NotContains(t, body, "not created yet")
|
assert.NotContains(t, body, "not created yet")
|
||||||
assert.Regexp(t,
|
assert.Regexp(t,
|
||||||
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
`Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
|
||||||
)
|
)
|
||||||
assert.Contains(t, body,
|
assert.Contains(t, body,
|
||||||
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
|
||||||
@@ -69,7 +69,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
|
|||||||
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
body = renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
assert.Contains(t, body, filepath.Base(path))
|
assert.Contains(t, body, filepath.Base(path))
|
||||||
assert.Contains(t, body, "not created yet")
|
assert.Contains(t, body, "not created yet")
|
||||||
assert.NotContains(t, body, "Archive Size:")
|
assert.NotContains(t, body, "Archive size:")
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetList returns the text of the targets section in a rendered
|
// targetList returns the text of the targets section in a rendered
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cooldownEnds is how the pages write the end of a paused target's
|
||||||
|
// breaker's cooldown: the time in UTC, with its date when that falls on
|
||||||
|
// another UTC day, then how long that is from now.
|
||||||
|
const cooldownEnds = `(\d{4}-\d\d-\d\d )?\d\d:\d\d:\d\d UTC ` +
|
||||||
|
`\(\d+ seconds from now\)`
|
||||||
|
|
||||||
|
// TestPausedTarget_ShownUntilBreakerCloses takes an http target's
|
||||||
|
// circuit breaker from open through half-open to closed.
|
||||||
|
//
|
||||||
|
// Open, the target's row on the webhook page says its deliveries are
|
||||||
|
// paused and until when, and each retrying delivery says it is waiting
|
||||||
|
// and why in the event log and on the event's page, with the earliest
|
||||||
|
// it can be tried next: the later of the cooldown's end and the end of
|
||||||
|
// its own backoff, with the date when that is another UTC day.
|
||||||
|
// Half-open, the row says deliveries are held while one delivery tests
|
||||||
|
// the target, with no time, and no delivery says it is waiting. Closed,
|
||||||
|
// the pages say neither. The delivered delivery and the log target are
|
||||||
|
// shown as before throughout.
|
||||||
|
func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
breakers *testCircuitBreakers
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &breakers)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
target := seedTarget(t, db, wh.ID, database.TargetTypeHTTP)
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
retrying := seedStoredEvent(t, dbMgr, wh.ID, `{"n":1}`)
|
||||||
|
addDelivery(t, dbMgr, wh.ID, retrying.ID, target.ID,
|
||||||
|
database.DeliveryStatusRetrying)
|
||||||
|
|
||||||
|
delivered := seedStoredEvent(t, dbMgr, wh.ID, `{"n":2}`)
|
||||||
|
addDelivery(t, dbMgr, wh.ID, delivered.ID, target.ID,
|
||||||
|
database.DeliveryStatusDelivered)
|
||||||
|
|
||||||
|
// This delivery's 18th attempt failed a minute ago, so its own
|
||||||
|
// backoff ends over a day from now: long after the cooldown, and on
|
||||||
|
// another UTC day, so the page shows the date.
|
||||||
|
backedOff := seedStoredEvent(t, dbMgr, wh.ID, `{"n":3}`)
|
||||||
|
backedOffID := addDelivery(t, dbMgr, wh.ID, backedOff.ID, target.ID,
|
||||||
|
database.DeliveryStatusRetrying)
|
||||||
|
|
||||||
|
failedAt := time.Now().Add(-time.Minute).Truncate(time.Second)
|
||||||
|
addFailedAttempt(t, dbMgr, wh.ID, backedOffID, 18, failedAt)
|
||||||
|
|
||||||
|
backoffEnds := failedAt.Add(delivery.Backoff(18)).UTC().
|
||||||
|
Format("2006-01-02 15:04:05") + " UTC (1 day from now)"
|
||||||
|
|
||||||
|
const waiting = "waiting: target paused after repeated failures, " +
|
||||||
|
"next try no earlier than "
|
||||||
|
|
||||||
|
breakers.Set(target.ID, delivery.CircuitOpen, 30*time.Second)
|
||||||
|
|
||||||
|
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
|
||||||
|
"Deliveries paused: after repeated failures, until "+cooldownEnds+
|
||||||
|
", then one waiting delivery is sent to test the target while "+
|
||||||
|
"the others wait at least one more cooldown", list)
|
||||||
|
assert.Equal(t, 1, strings.Count(list, "Deliveries paused"))
|
||||||
|
|
||||||
|
log := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
|
||||||
|
assert.Contains(t, log, "t-http: delivered")
|
||||||
|
assert.Regexp(t, waiting+cooldownEnds, log)
|
||||||
|
assert.Contains(t, log, waiting+backoffEnds)
|
||||||
|
// No delivery shows as retrying; the Pending link's title says it.
|
||||||
|
assert.NotRegexp(t, `t-http: retrying|>retrying<`, log)
|
||||||
|
|
||||||
|
page := eventPage(t, h, sess, wh.ID, retrying.ID)
|
||||||
|
assert.Regexp(t, waiting+cooldownEnds, page)
|
||||||
|
assert.NotContains(t, page, "retrying")
|
||||||
|
|
||||||
|
page = eventPage(t, h, sess, wh.ID, backedOff.ID)
|
||||||
|
assert.Contains(t, page, waiting+backoffEnds)
|
||||||
|
assert.NotContains(t, page, "seconds from now")
|
||||||
|
|
||||||
|
breakers.Set(target.ID, delivery.CircuitHalfOpen, 0)
|
||||||
|
|
||||||
|
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
|
||||||
|
"Deliveries paused: held while one delivery tests whether the "+
|
||||||
|
"target has recovered")
|
||||||
|
// Not the whole list: the add target form above the rows says UTC.
|
||||||
|
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
|
||||||
|
|
||||||
|
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
|
||||||
|
|
||||||
|
breakers.Set(target.ID, delivery.CircuitClosed, 0)
|
||||||
|
|
||||||
|
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.NotContains(t, list, "Deliveries paused")
|
||||||
|
|
||||||
|
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetRow returns the row of the target named name in a targetList:
|
||||||
|
// from its name to the name of the target listed after it, next.
|
||||||
|
func targetRow(list, name, next string) string {
|
||||||
|
_, row, _ := strings.Cut(list, name+" ")
|
||||||
|
row, _, _ = strings.Cut(row, next+" ")
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertRetryingNotWaiting checks that the event log and each event's
|
||||||
|
// page show the http target's delivery of the event as retrying, and
|
||||||
|
// none of them as waiting.
|
||||||
|
func assertRetryingNotWaiting(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID string,
|
||||||
|
events ...*database.Event,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
log := renderSourceLogsPage(t, h, sess, webhookID)
|
||||||
|
assert.Equal(t, len(events), strings.Count(log, "t-http: retrying"))
|
||||||
|
assert.NotContains(t, log, "waiting")
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
page := eventPage(t, h, sess, webhookID, event.ID)
|
||||||
|
assert.Contains(t, page, ">retrying</span>")
|
||||||
|
assert.NotContains(t, page, "waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDelivery records a delivery of the event to the target, with the
|
||||||
|
// given status, in the webhook's own database, and returns its ID.
|
||||||
|
func addDelivery(
|
||||||
|
t *testing.T,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhookID, eventID, targetID string,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dlv := &database.Delivery{
|
||||||
|
EventID: eventID,
|
||||||
|
TargetID: targetID,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
||||||
|
dlv,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
return dlv.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFailedAttempt records the delivery's failed attempt attemptNum,
|
||||||
|
// made at the given time.
|
||||||
|
func addFailedAttempt(
|
||||||
|
t *testing.T,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhookID, deliveryID string,
|
||||||
|
attemptNum int,
|
||||||
|
at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
||||||
|
&database.DeliveryResult{
|
||||||
|
BaseModel: database.BaseModel{CreatedAt: at},
|
||||||
|
DeliveryID: deliveryID,
|
||||||
|
AttemptNum: attemptNum,
|
||||||
|
Error: "connection refused",
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventPage runs the real event page handler and returns the
|
||||||
|
// rendered HTML.
|
||||||
|
func eventPage(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID, eventID string,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := serveEventPage(t, h, sess, webhookID, eventID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ var (
|
|||||||
// errRetriesInvalid signals a max_retries form value that is not
|
// errRetriesInvalid signals a max_retries form value that is not
|
||||||
// a non-negative whole number.
|
// a non-negative whole number.
|
||||||
errRetriesInvalid = errors.New(
|
errRetriesInvalid = errors.New(
|
||||||
"retries must be a whole number of attempts",
|
"must be a whole number",
|
||||||
)
|
)
|
||||||
|
|
||||||
// errRetriesTooLarge signals a max_retries form value that is a
|
// errRetriesTooLarge signals a max_retries form value that is a
|
||||||
// whole number but above maxTargetRetries. It is distinguished
|
// whole number but above maxTargetRetries. It is distinguished
|
||||||
// from errRetriesInvalid so the message can name the ceiling
|
// from errRetriesInvalid so the message can name the ceiling
|
||||||
// instead of implying the input was not a number.
|
// instead of implying the input was not a number.
|
||||||
errRetriesTooLarge = errors.New("retries out of range")
|
errRetriesTooLarge = errors.New("out of range")
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseMaxRetries interprets a max_retries form value.
|
// parseMaxRetries interprets a max_retries form value.
|
||||||
@@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string {
|
|||||||
if errors.Is(err, errRetriesTooLarge) {
|
if errors.Is(err, errRetriesTooLarge) {
|
||||||
return errRetriesTooLarge.Error() +
|
return errRetriesTooLarge.Error() +
|
||||||
": at most " + strconv.Itoa(maxTargetRetries) +
|
": at most " + strconv.Itoa(maxTargetRetries) +
|
||||||
" retries"
|
" attempts"
|
||||||
}
|
}
|
||||||
|
|
||||||
return errRetriesInvalid.Error() +
|
return errRetriesInvalid.Error() +
|
||||||
|
|||||||
@@ -383,20 +383,3 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
|
|
||||||
// non-numeric form value legitimately falls back. A page number says
|
|
||||||
// where to send the browser after an action that has already
|
|
||||||
// happened, so it is not configuration and rejecting it would report
|
|
||||||
// a failure that did not occur.
|
|
||||||
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
|
|
||||||
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
|
|
||||||
"%q should fall back to the first page", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
|
|
||||||
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleTargetToggle handles toggling a target's active state.
|
||||||
|
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
||||||
|
return h.toggleChildResource(
|
||||||
|
"targetID",
|
||||||
|
func(webhookID, childID string) (bool, error) {
|
||||||
|
var tgt database.Target
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND webhook_id = ?",
|
||||||
|
childID, webhookID,
|
||||||
|
).First(&tgt).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the active column: saving the whole row would
|
||||||
|
// write back the name and settings read above over an
|
||||||
|
// edit saved since.
|
||||||
|
active := !tgt.Active
|
||||||
|
|
||||||
|
return active, h.db.DB().Model(&tgt).
|
||||||
|
Update("active", active).Error
|
||||||
|
},
|
||||||
|
"failed to toggle target",
|
||||||
|
targetActivated, targetDeactivated,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
dataKeyWebhook = "Webhook"
|
dataKeyWebhook = "Webhook"
|
||||||
dataKeyError = "Error"
|
dataKeyError = "Error"
|
||||||
|
dataKeyEvents = "Events"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testWebhookID is the identifier given to the webhook under test on
|
// testWebhookID is the identifier given to the webhook under test on
|
||||||
@@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
// A pointer, as in the handlers: source_detail.html calls
|
// A pointer, as in the handlers: source_detail.html calls
|
||||||
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
// Webhook.RetentionLabel, a pointer method. The webhook page only
|
||||||
// over their lists, and a list left out renders as empty, so the
|
// ranges over its lists, and a list left out renders as empty, so
|
||||||
// lists are left out.
|
// its lists are left out. The event log also counts its events, so
|
||||||
|
// it gets an empty list.
|
||||||
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||||
webhook.ID = testWebhookID
|
webhook.ID = testWebhookID
|
||||||
|
|
||||||
@@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
|||||||
|
|
||||||
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||||
dataKeyWebhook: webhook,
|
dataKeyWebhook: webhook,
|
||||||
|
dataKeyEvents: []handlers.EventLogView{},
|
||||||
"TotalEvents": int64(0),
|
"TotalEvents": int64(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -317,9 +320,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
"Entrypoints": handlers.NewEntrypointViews(
|
"Entrypoints": handlers.NewEntrypointViews(
|
||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
"Events": []database.Event{},
|
dataKeyEvents: []database.Event{},
|
||||||
"BaseURL": "https://hooks.example.com",
|
"BaseURL": "https://hooks.example.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
@@ -351,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
// and target_http gives up once the attempt number reaches
|
// and target_http gives up once the attempt number reaches
|
||||||
// max_retries), and 0 is special-cased to a single fire-and-forget
|
// max_retries), and 0 is special-cased to a single fire-and-forget
|
||||||
// attempt with no circuit breaker.
|
// attempt with no circuit breaker.
|
||||||
const maxRetriesHelp = "This is the total number of delivery attempts, " +
|
const maxRetriesHelp = "How many times each delivery is attempted in " +
|
||||||
"not retries on top of the first: a value of 3 makes three attempts " +
|
"all, the first attempt included. 0 means a single attempt with no " +
|
||||||
"in all. 0 means a single attempt with no retries and no circuit " +
|
"retries and no circuit breaker."
|
||||||
"breaker."
|
|
||||||
|
|
||||||
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
||||||
// help text on both the create form (the add-target form on the webhook
|
// label, "Delivery attempts", and help text on both the create form
|
||||||
// detail page) and the edit form, so the copy cannot drift back to
|
// (the add-target form on the webhook detail page) and the edit form,
|
||||||
// calling the number a retry count.
|
// so the copy cannot drift back to calling the number a retry count.
|
||||||
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -384,12 +386,13 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|||||||
"Entrypoints": handlers.NewEntrypointViews(
|
"Entrypoints": handlers.NewEntrypointViews(
|
||||||
[]database.Entrypoint{entrypoint},
|
[]database.Entrypoint{entrypoint},
|
||||||
),
|
),
|
||||||
"Targets": delivery.NewTargetViews(nil),
|
"Targets": delivery.NewTargetViews(nil),
|
||||||
"Events": []database.Event{},
|
dataKeyEvents: []database.Event{},
|
||||||
"BaseURL": "https://hooks.example.com",
|
"BaseURL": "https://hooks.example.com",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Contains(t, createBody, "Delivery attempts:</label>")
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, createBody, maxRetriesHelp,
|
t, createBody, maxRetriesHelp,
|
||||||
"the add-target form must explain max_retries as total attempts",
|
"the add-target form must explain max_retries as total attempts",
|
||||||
@@ -416,8 +419,33 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, editBody, maxRetriesHelp,
|
t, editBody, maxRetriesHelp,
|
||||||
"the target edit form must explain max_retries as total attempts",
|
"the target edit form must explain max_retries as total attempts",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new
|
||||||
|
// webhook page gives the database target its Archive checkbox creates,
|
||||||
|
// and that target's settings, to the ones the target forms use.
|
||||||
|
func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
||||||
|
dataKeyError: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, body, "created with an archive target")
|
||||||
|
assert.Contains(t, body, `class="label">Archive expiry</label>`)
|
||||||
|
assert.Contains(t, body, `class="label">Archive rotation</label>`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSourceCreate shows the form to create a new webhook.
|
||||||
|
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderTemplate(
|
||||||
|
w, r, "sources_new.html",
|
||||||
|
newSourceFormData("", sourceFormInput{
|
||||||
|
RetentionDays: strconv.Itoa(
|
||||||
|
database.DefaultRetentionDays,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceFormInput carries the raw values of the new webhook form. A
|
||||||
|
// refused submission is shown again from it, so every value entered
|
||||||
|
// comes back, retention included.
|
||||||
|
type sourceFormInput struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
RetentionDays string
|
||||||
|
// HTTPURL, when not empty, asks for an HTTP target with this
|
||||||
|
// destination.
|
||||||
|
HTTPURL string
|
||||||
|
// Archive asks for a database (archive) target, whose rows expire
|
||||||
|
// after ArchiveExpiry and whose files rotate by ArchiveRotation.
|
||||||
|
Archive bool
|
||||||
|
ArchiveExpiry string
|
||||||
|
ArchiveRotation string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSourceFormData builds the template data for the webhook creation
|
||||||
|
// form. It carries the retention default, which the form's help text
|
||||||
|
// names, from database.DefaultRetentionDays rather than a hardcoded
|
||||||
|
// copy of the same policy.
|
||||||
|
func newSourceFormData(
|
||||||
|
errMsg string, in sourceFormInput,
|
||||||
|
) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
tmplKeyError: errMsg,
|
||||||
|
"Form": in,
|
||||||
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
||||||
|
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
|
||||||
|
in.ArchiveExpiry,
|
||||||
|
),
|
||||||
|
tmplKeyArchiveRotationChoices: archiveRotationOptions(
|
||||||
|
in.ArchiveRotation,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSourceCreateSubmit handles the webhook creation form
|
||||||
|
// submission.
|
||||||
|
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
in := sourceFormInput{
|
||||||
|
Name: r.PostFormValue("name"),
|
||||||
|
Description: r.PostFormValue("description"),
|
||||||
|
RetentionDays: r.PostFormValue("retention_days"),
|
||||||
|
HTTPURL: r.PostFormValue("http_url"),
|
||||||
|
Archive: r.PostFormValue("archive") != "",
|
||||||
|
ArchiveExpiry: r.PostFormValue("archive_expiry"),
|
||||||
|
ArchiveRotation: r.PostFormValue("archive_rotation"),
|
||||||
|
}
|
||||||
|
|
||||||
|
refuse := func(errMsg string) {
|
||||||
|
h.renderTemplateStatus(
|
||||||
|
w, r, "sources_new.html",
|
||||||
|
newSourceFormData(errMsg, in),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Name == "" {
|
||||||
|
refuse("Name is required")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionDays, errMsg := parseRetentionDays(
|
||||||
|
in.RetentionDays, database.DefaultRetentionDays,
|
||||||
|
)
|
||||||
|
if errMsg != "" {
|
||||||
|
refuse(errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, errMsg, err := h.newWebhookTargets(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
refuse(errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.createWebhookWithEntrypoint(w, r, &database.Webhook{
|
||||||
|
UserID: userID,
|
||||||
|
Name: in.Name,
|
||||||
|
Description: in.Description,
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
}, targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebhookTargets validates the targets the new webhook form asks
|
||||||
|
// for and returns the rows to create with the webhook, or the message
|
||||||
|
// the form shows for the first one it refuses. A filled-in HTTP URL
|
||||||
|
// asks for an HTTP target named "HTTP", and the archive checkbox for a
|
||||||
|
// database target named "Archive". Each goes through newTarget, as on
|
||||||
|
// the webhook page's add target form. The rows have no WebhookID yet:
|
||||||
|
// the webhook has no ID until it is created.
|
||||||
|
func (h *Handlers) newWebhookTargets(
|
||||||
|
ctx context.Context,
|
||||||
|
in sourceFormInput,
|
||||||
|
) ([]*database.Target, string, error) {
|
||||||
|
var requested []targetFormInput
|
||||||
|
|
||||||
|
if in.HTTPURL != "" {
|
||||||
|
requested = append(requested, targetFormInput{
|
||||||
|
Name: "HTTP",
|
||||||
|
Type: database.TargetTypeHTTP,
|
||||||
|
URL: in.HTTPURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Archive {
|
||||||
|
requested = append(requested, targetFormInput{
|
||||||
|
Name: "Archive",
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Expiry: in.ArchiveExpiry,
|
||||||
|
Rotation: in.ArchiveRotation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]*database.Target, 0, len(requested))
|
||||||
|
|
||||||
|
for _, form := range requested {
|
||||||
|
target, errMsg, err := h.newTarget(ctx, "", form)
|
||||||
|
if err != nil || errMsg != "" {
|
||||||
|
return nil, errMsg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWebhookWithEntrypoint creates a webhook, its default
|
||||||
|
// entrypoint and the given targets in a transaction.
|
||||||
|
func (h *Handlers) createWebhookWithEntrypoint(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
targets []*database.Target,
|
||||||
|
) {
|
||||||
|
err := h.commitWebhook(webhook, targets)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to create webhook", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.dbMgr.CreateDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to create webhook event database",
|
||||||
|
"webhook_id", webhook.ID, "error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("webhook created",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"name", webhook.Name, "user_id", webhook.UserID,
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitWebhook creates a webhook, its default entrypoint and the
|
||||||
|
// given targets in a transaction. Returns an error on failure (rolls
|
||||||
|
// back).
|
||||||
|
func (h *Handlers) commitWebhook(
|
||||||
|
webhook *database.Webhook,
|
||||||
|
targets []*database.Target,
|
||||||
|
) error {
|
||||||
|
tx := h.db.DB().Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tx.Create(webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entrypoint := &database.Entrypoint{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
Description: "Default entrypoint",
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Create(entrypoint).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
target.WebhookID = webhook.ID
|
||||||
|
|
||||||
|
err = tx.Create(target).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSourceDelete handles webhook deletion.
|
||||||
|
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.deleteWebhookResources(w, r, webhook, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The messages deleteWebhookResources logs when a file of the event
|
||||||
|
// database cannot be removed: the database file itself, or only a
|
||||||
|
// sidecar once the database file is gone.
|
||||||
|
const (
|
||||||
|
eventDBLeftMsg = "webhook deleted, but its event database file is " +
|
||||||
|
"still on disk; remove it by hand"
|
||||||
|
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
|
||||||
|
"-wal or -shm sidecar of its event database is " +
|
||||||
|
"still on disk; remove it by hand"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deleteWebhookResources soft-deletes config and hard-deletes
|
||||||
|
// the per-webhook event database.
|
||||||
|
func (h *Handlers) deleteWebhookResources(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
userID string,
|
||||||
|
) {
|
||||||
|
// The configuration delete commits before the event database
|
||||||
|
// is touched. No transaction spans the main database and the
|
||||||
|
// filesystem, so one side has to go first: committing the
|
||||||
|
// configuration first means a later failure leaves an unused
|
||||||
|
// event database file on disk, while removing the event
|
||||||
|
// database first would mean a failed commit destroys the
|
||||||
|
// history of a webhook that still exists. A leftover file can
|
||||||
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to delete webhook", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info(
|
||||||
|
"webhook deleted",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"user_id", userID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Release the delivery engine's per-webhook archiving state
|
||||||
|
// so a deleted webhook's archive writer (and any handle open
|
||||||
|
// within its debounce window) does not linger for the
|
||||||
|
// process lifetime. The archive file itself is deliberately
|
||||||
|
// left on disk; see evictArchiveWriter.
|
||||||
|
h.evictArchiveWriter(webhook.ID)
|
||||||
|
|
||||||
|
err = h.dbMgr.DeleteDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
// The configuration is committed, so the webhook is gone,
|
||||||
|
// but a file of its event database is still on disk with
|
||||||
|
// nothing referencing it. Report the failure rather than
|
||||||
|
// redirecting as though everything succeeded: the file
|
||||||
|
// needs removing by hand, and the logged error names it.
|
||||||
|
// When only a sidecar is left, the events are already
|
||||||
|
// gone, and the message must not suggest they survive.
|
||||||
|
msg := eventDBLeftMsg
|
||||||
|
if errors.Is(err, database.ErrSidecarNotRemoved) {
|
||||||
|
msg = sidecarLeftMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
h.serverError(w, r, msg, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||||
|
// targets and the webhook row in one transaction. Every
|
||||||
|
// statement is checked and any failure rolls the whole
|
||||||
|
// transaction back, so a caller that gets an error knows the
|
||||||
|
// configuration is untouched and the event database must be
|
||||||
|
// left alone.
|
||||||
|
func (h *Handlers) commitWebhookDeletion(
|
||||||
|
webhook *database.Webhook,
|
||||||
|
) error {
|
||||||
|
tx := h.db.DB().Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tx.Where(
|
||||||
|
"webhook_id = ?", webhook.ID,
|
||||||
|
).Delete(&database.Entrypoint{}).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Where(
|
||||||
|
"webhook_id = ?", webhook.ID,
|
||||||
|
).Delete(&database.Target{}).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Delete(webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictArchiveWriter asks the delivery engine to drop the cached
|
||||||
|
// archive writers of a webhook's database targets, closing their
|
||||||
|
// archive file handles.
|
||||||
|
//
|
||||||
|
// The archive database files are NOT deleted. Unlike the event
|
||||||
|
// database — which is per-webhook working storage and is
|
||||||
|
// hard-deleted with the webhook — an archive is explicitly
|
||||||
|
// long-term storage that an operator may want to keep or move
|
||||||
|
// away for offline retention. Destroying it as a side effect of
|
||||||
|
// deleting a webhook would be a surprising and unrecoverable
|
||||||
|
// data loss, so the file is left for the operator to handle.
|
||||||
|
func (h *Handlers) evictArchiveWriter(webhookID string) {
|
||||||
|
if h.archives == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.archives.EvictWebhook(webhookID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/reqtls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSourceDetail shows details for a specific webhook.
|
||||||
|
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSourceDetail loads and renders a source detail page. With a
|
||||||
|
// targetErr, it is the page shown again for a refused add target
|
||||||
|
// form: it answers 400, and the form opens on targetForm's type with
|
||||||
|
// its values and the message.
|
||||||
|
func (h *Handlers) renderSourceDetail(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
targetForm targetFormInput,
|
||||||
|
targetErr string,
|
||||||
|
) {
|
||||||
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
|
h.db.DB().Where(
|
||||||
|
"webhook_id = ?", webhook.ID,
|
||||||
|
).Find(&entrypoints)
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
h.db.DB().Where(
|
||||||
|
"webhook_id = ?", webhook.ID,
|
||||||
|
).Find(&targets)
|
||||||
|
|
||||||
|
entrypointViews := NewEntrypointViews(entrypoints)
|
||||||
|
|
||||||
|
var events []RecentEventView
|
||||||
|
|
||||||
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err = loadRecentEvents(
|
||||||
|
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load recent events", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = addEntrypointEvents(
|
||||||
|
webhookDB, &webhook, entrypointViews, time.Now(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to count entrypoint events", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if reqtls.IsTLS(r) {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host is the client's Host header, unvalidated. It is
|
||||||
|
// inert only because source_detail.html renders BaseURL as
|
||||||
|
// text, inside a <code> element and in an entrypoint's delete
|
||||||
|
// prompt; putting it in an href or any other URL context
|
||||||
|
// needs it constrained first.
|
||||||
|
baseURL := scheme + "://" + r.Host
|
||||||
|
|
||||||
|
// The template calls Webhook methods, which take pointer
|
||||||
|
// receivers; html/template cannot address a value stored in a map.
|
||||||
|
data := map[string]any{
|
||||||
|
tmplKeyWebhook: &webhook,
|
||||||
|
// Targets are projected to a display-safe view: a
|
||||||
|
// target's stored config blob holds a credential, and it
|
||||||
|
// must never reach a template.
|
||||||
|
"Entrypoints": entrypointViews,
|
||||||
|
"Targets": h.targetRows(&webhook, targets),
|
||||||
|
"Events": events,
|
||||||
|
"BaseURL": baseURL,
|
||||||
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
|
tmplKeyTargetForm: targetForm,
|
||||||
|
"TargetError": targetErr,
|
||||||
|
// The add target form's selects start on its expiry and
|
||||||
|
// rotation through Alpine, so no choice is selected here.
|
||||||
|
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
|
||||||
|
tmplKeyArchiveRotationChoices: archiveRotationChoices(),
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if targetErr != "" {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSourceEdit shows the form to edit a webhook.
|
||||||
|
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderWebhookEdit(
|
||||||
|
w, r, &webhook,
|
||||||
|
webhook.Name, webhook.Description,
|
||||||
|
strconv.Itoa(webhook.RetentionDays),
|
||||||
|
"", http.StatusOK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSourceEditSubmit handles the webhook edit form
|
||||||
|
// submission.
|
||||||
|
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
h.renameMu.Lock()
|
||||||
|
defer h.renameMu.Unlock()
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.applyWebhookEdit(w, r, &webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWebhookEdit validates and saves webhook edits. A refused save
|
||||||
|
// shows the edit form again with the values submitted and the reason.
|
||||||
|
func (h *Handlers) applyWebhookEdit(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
) {
|
||||||
|
// The body size cap is enforced by the MaxBodySize middleware,
|
||||||
|
// which runs before CSRF parses the form.
|
||||||
|
name := r.PostFormValue("name")
|
||||||
|
description := r.PostFormValue("description")
|
||||||
|
retention := r.PostFormValue("retention_days")
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
h.renderWebhookEdit(
|
||||||
|
w, r, webhook, name, description, retention,
|
||||||
|
"Name is required", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty field falls back to the stored value, so submitting the
|
||||||
|
// form without touching retention leaves the policy alone.
|
||||||
|
retentionDays, errMsg := parseRetentionDays(
|
||||||
|
retention, webhook.RetentionDays,
|
||||||
|
)
|
||||||
|
if errMsg != "" {
|
||||||
|
h.renderWebhookEdit(
|
||||||
|
w, r, webhook, name, description, retention,
|
||||||
|
errMsg, http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// edited is the webhook as the submission leaves it; webhook stays
|
||||||
|
// as stored, for the page shown again when the save is refused.
|
||||||
|
edited := *webhook
|
||||||
|
edited.Name = name
|
||||||
|
edited.Description = description
|
||||||
|
edited.RetentionDays = retentionDays
|
||||||
|
|
||||||
|
// A new name renames the archive files before it is saved (see
|
||||||
|
// delivery.Engine.Rename). If either step fails, the same targets'
|
||||||
|
// archives go back to the name that is still stored, without
|
||||||
|
// reading the main database again.
|
||||||
|
targets, err := h.renameWebhookArchives(
|
||||||
|
webhook.ID, webhook.Name, edited.Name,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
err = h.db.DB().Save(&edited).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
restoreErr := h.renameArchives(targets, webhook.Name)
|
||||||
|
if restoreErr != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to rename archives back",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"error", restoreErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||||
|
h.renderWebhookEdit(
|
||||||
|
w, r, webhook, name, description, retention,
|
||||||
|
"Not saved: "+err.Error()+
|
||||||
|
". Move that archive out of the data directory, "+
|
||||||
|
"its .db together with any -wal and -shm beside "+
|
||||||
|
"it, then save again.",
|
||||||
|
http.StatusConflict,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.serverError(w, r, "failed to update webhook", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWebhookEdit renders the webhook edit page for the webhook as
|
||||||
|
// stored, its form showing name, description and retentionDays, with
|
||||||
|
// an optional error message above it.
|
||||||
|
func (h *Handlers) renderWebhookEdit(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
name, description, retentionDays, errMsg string,
|
||||||
|
status int,
|
||||||
|
) {
|
||||||
|
data := map[string]any{
|
||||||
|
tmplKeyWebhook: webhook,
|
||||||
|
tmplKeyError: errMsg,
|
||||||
|
"Name": name,
|
||||||
|
"Description": description,
|
||||||
|
"RetentionDays": retentionDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renameWebhookArchives renames the archive file of every database
|
||||||
|
// target of a webhook from the webhook name oldName to newName,
|
||||||
|
// keeping each target's own name. It does nothing when the name is
|
||||||
|
// unchanged. It returns the targets it read, so that a failed edit can
|
||||||
|
// move those same archives back with renameArchives.
|
||||||
|
func (h *Handlers) renameWebhookArchives(
|
||||||
|
webhookID, oldName, newName string,
|
||||||
|
) ([]database.Target, error) {
|
||||||
|
if h.archives == nil || oldName == newName {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
err := h.db.DB().
|
||||||
|
Where(
|
||||||
|
"webhook_id = ? AND type = ?",
|
||||||
|
webhookID, database.TargetTypeDatabase,
|
||||||
|
).
|
||||||
|
Find(&targets).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets, h.renameArchives(targets, newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renameArchives renames the archive file of each of the given
|
||||||
|
// database targets to the webhook name webhookName, keeping each
|
||||||
|
// target's own name. It tries every target even after one fails, so
|
||||||
|
// that moving the archives back after a failed edit leaves none under
|
||||||
|
// the new name, and returns every failure joined.
|
||||||
|
func (h *Handlers) renameArchives(
|
||||||
|
targets []database.Target, webhookName string,
|
||||||
|
) error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
err := h.archives.Rename(
|
||||||
|
targets[i].ID, webhookName, targets[i].Name,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebhookListItem holds data for the webhook list view.
|
||||||
|
type WebhookListItem struct {
|
||||||
|
database.Webhook
|
||||||
|
|
||||||
|
EntrypointCount int
|
||||||
|
InactiveEntrypointCount int
|
||||||
|
TargetCount int
|
||||||
|
InactiveTargetCount int
|
||||||
|
|
||||||
|
// EventCount is how many events the webhook holds, LastEventAt
|
||||||
|
// when the newest arrived (nil before the first), and
|
||||||
|
// FailedLast24Hours how many of its deliveries failed in the last
|
||||||
|
// 24 hours. When the webhook's event database could not be read,
|
||||||
|
// EventsUnreadable is set and these three are not known.
|
||||||
|
EventCount int64
|
||||||
|
LastEventAt *time.Time
|
||||||
|
FailedLast24Hours int64
|
||||||
|
EventsUnreadable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSourceList shows a list of user's webhooks.
|
||||||
|
func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var webhooks []database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"user_id = ?", userID,
|
||||||
|
).Order("created_at DESC").Find(&webhooks).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to list webhooks", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := h.buildWebhookListItems(webhooks)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to list webhooks", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Webhooks": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, r, "sources_list.html", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWebhookListItems builds the list's entry for each webhook. It
|
||||||
|
// fails when the main database cannot be read. A webhook whose event
|
||||||
|
// database cannot be read is marked on its own entry, and the error is
|
||||||
|
// logged.
|
||||||
|
func (h *Handlers) buildWebhookListItems(
|
||||||
|
webhooks []database.Webhook,
|
||||||
|
) ([]WebhookListItem, error) {
|
||||||
|
items := make([]WebhookListItem, len(webhooks))
|
||||||
|
since := time.Now().Add(-longWindow)
|
||||||
|
|
||||||
|
for i := range webhooks {
|
||||||
|
item := &items[i]
|
||||||
|
item.Webhook = webhooks[i]
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
item.EntrypointCount, item.InactiveEntrypointCount, err =
|
||||||
|
h.countWithInactive(&database.Entrypoint{}, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item.TargetCount, item.InactiveTargetCount, err =
|
||||||
|
h.countWithInactive(&database.Target{}, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening an event database that does not exist would create
|
||||||
|
// it, and it would hold nothing to count.
|
||||||
|
if !h.dbMgr.DBExists(item.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.readListEventFigures(item, since)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to read webhook list figures",
|
||||||
|
"webhook_id", item.ID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
item.EventsUnreadable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countWithInactive returns how many entrypoints or targets, as model
|
||||||
|
// says, a webhook has, and how many of them are inactive.
|
||||||
|
func (h *Handlers) countWithInactive(
|
||||||
|
model any, webhookID string,
|
||||||
|
) (int, int, error) {
|
||||||
|
var active []bool
|
||||||
|
|
||||||
|
err := h.db.DB().Model(model).
|
||||||
|
Where("webhook_id = ?", webhookID).
|
||||||
|
Pluck("active", &active).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf(
|
||||||
|
"reading active flags of webhook %s: %w", webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inactive := 0
|
||||||
|
|
||||||
|
for _, a := range active {
|
||||||
|
if !a {
|
||||||
|
inactive++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(active), inactive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readListEventFigures fills in the figures the list shows from the
|
||||||
|
// webhook's event database, with the statistics pane's own queries:
|
||||||
|
// the event count and last arrival from the event totals row, and the
|
||||||
|
// deliveries that failed since the given time from the deliveries'
|
||||||
|
// status index.
|
||||||
|
func (h *Handlers) readListEventFigures(
|
||||||
|
item *WebhookListItem, since time.Time,
|
||||||
|
) error {
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals database.EventTotals
|
||||||
|
|
||||||
|
err = webhookDB.Take(&totals).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading event totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.EventCount = totals.Events - totals.EventsRemoved
|
||||||
|
item.LastEventAt = totals.LastEventAt
|
||||||
|
|
||||||
|
byTarget, err := finishedByTarget(webhookDB, since)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range byTarget {
|
||||||
|
item.FailedLast24Hours += f.Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -131,8 +131,9 @@ const (
|
|||||||
//
|
//
|
||||||
// - Lines carrying an AUTHENTICATED operator's own input, which
|
// - Lines carrying an AUTHENTICATED operator's own input, which
|
||||||
// are not truncated at all: the webhook name on "webhook
|
// are not truncated at all: the webhook name on "webhook
|
||||||
// created" and the target host on "target URL blocked by SSRF
|
// created" (internal/handlers/webhook_create.go) and the target
|
||||||
// protection" (both internal/handlers/source_management.go),
|
// host on "target URL blocked by SSRF protection"
|
||||||
|
// (internal/handlers/target_create.go),
|
||||||
// and target_name in internal/delivery/engine.go and
|
// and target_name in internal/delivery/engine.go and
|
||||||
// target_http.go. Each is bounded only by the 1 MB form body
|
// target_http.go. Each is bounded only by the 1 MB form body
|
||||||
// cap, so a 100 KB name writes one line of roughly 600 KB.
|
// cap, so a 100 KB name writes one line of roughly 600 KB.
|
||||||
@@ -278,12 +279,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// after the '?'. Keeping the path and dropping the query is what makes
|
// after the '?'. Keeping the path and dropping the query is what makes
|
||||||
// this branch as bounded as the pattern branches below.
|
// this branch as bounded as the pattern branches below.
|
||||||
//
|
//
|
||||||
// Nothing debuggable is lost. One route in the service reads a query
|
// Nothing debuggable is lost. The only query parameters the service
|
||||||
// parameter at all — `page`, on the authenticated pagination links in
|
// reads are the login page's `next`, the page to return to,
|
||||||
// internal/handlers/source_management.go — and the alternatives that
|
// `notice`, which names the line a page shows after an action, and
|
||||||
// would preserve more (a key count, a key allowlist) all require
|
// the event log's `show`, which picks the events it lists. The
|
||||||
// parsing an attacker-sized query on every request, which is work an
|
// alternatives that would preserve more (a key count, a key
|
||||||
// unauthenticated client would then be choosing for us.
|
// allowlist) all require parsing an attacker-sized query on every
|
||||||
|
// request, which is work an unauthenticated client would then be
|
||||||
|
// choosing for us.
|
||||||
func concreteLogURL(r *http.Request) string {
|
func concreteLogURL(r *http.Request) string {
|
||||||
path := r.URL.EscapedPath()
|
path := r.URL.EscapedPath()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -142,6 +143,14 @@ func (n *noopArchives) Rename(_, _, _ string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type noopCircuitBreakers struct{}
|
||||||
|
|
||||||
|
func (n *noopCircuitBreakers) StateAndCooldown(
|
||||||
|
string,
|
||||||
|
) (delivery.CircuitState, time.Duration) {
|
||||||
|
return delivery.CircuitClosed, 0
|
||||||
|
}
|
||||||
|
|
||||||
// newServerApp starts the real login path against dir: the handlers,
|
// newServerApp starts the real login path against dir: the handlers,
|
||||||
// the middleware that bounds password verification, the session store
|
// the middleware that bounds password verification, the session store
|
||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
@@ -174,6 +183,9 @@ func newServerApp(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.Archives { return &noopArchives{} },
|
func() delivery.Archives { return &noopArchives{} },
|
||||||
|
func() delivery.CircuitBreakers {
|
||||||
|
return &noopCircuitBreakers{}
|
||||||
|
},
|
||||||
metrics.NewRegistry,
|
metrics.NewRegistry,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
// this order. A new check is one more line here.
|
// this order. A new check is one more line here.
|
||||||
checkAddEntrypoint(ctx, t, page)
|
checkAddEntrypoint(ctx, t, page)
|
||||||
checkAddEachTargetType(ctx, t, page)
|
checkAddEachTargetType(ctx, t, page)
|
||||||
checkArchiveExpiry(ctx, t, page)
|
checkArchiveChoices(ctx, t, page)
|
||||||
checkRefusedTarget(ctx, t, page)
|
checkRefusedTarget(ctx, t, page)
|
||||||
checkTargetDeliveries(ctx, t, page, target.Name,
|
checkTargetDeliveries(ctx, t, page, target.Name,
|
||||||
"0 in total, 0 in the last 24 hours",
|
"0 in total, 0 in the last 24 hours",
|
||||||
@@ -98,20 +98,25 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
|
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
|
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
|
||||||
// userID: an entrypoint, two events, and a target whose delivery of the
|
// userID: an entrypoint, two events, and a target whose delivery of the
|
||||||
// newer event failed once with a 502. It returns the webhook, the older
|
// newer event failed once with a 502. The webhook's name and the newer
|
||||||
// and the newer event, and the target.
|
// event's content type are each too long for one line on a phone. It
|
||||||
|
// returns the webhook, the older and the newer event, and the target.
|
||||||
func seedBrowserWebhook(
|
func seedBrowserWebhook(
|
||||||
t *testing.T, env *testEnv, userID string,
|
t *testing.T, env *testEnv, userID string,
|
||||||
) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
|
) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
webhook := env.seedWebhook(t, userID)
|
webhook := env.seedWebhook(t, userID)
|
||||||
|
require.NoError(t, env.db.DB().Model(webhook).Update(
|
||||||
|
"name", "payment_provider_production_notifications",
|
||||||
|
).Error)
|
||||||
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
||||||
&database.Entrypoint{
|
&database.Entrypoint{
|
||||||
WebhookID: webhook.ID,
|
WebhookID: webhook.ID,
|
||||||
@@ -126,6 +131,9 @@ func seedBrowserWebhook(
|
|||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, webhookDB.Model(event).Update(
|
||||||
|
"content_type", "application/vnd.paymentprovider.event+json",
|
||||||
|
).Error)
|
||||||
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
||||||
&database.DeliveryResult{
|
&database.DeliveryResult{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
@@ -318,31 +326,35 @@ const (
|
|||||||
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Each target type, with the fields its add target form submits, in
|
// Each target type, with the badge its targets are listed with and
|
||||||
// page order. Only http and slack have a url field.
|
// the fields its add target form submits, in page order. Only http
|
||||||
|
// and slack have a url field.
|
||||||
targetTypes := []struct {
|
targetTypes := []struct {
|
||||||
name string
|
name string
|
||||||
|
badge string
|
||||||
fields string
|
fields string
|
||||||
values map[string]string
|
values map[string]string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"http", "csrf_token name type url headers timeout max_retries",
|
"http", "http",
|
||||||
|
"csrf_token name type url headers timeout max_retries",
|
||||||
map[string]string{"url": publicTargetURL},
|
map[string]string{"url": publicTargetURL},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slack", "csrf_token name type url max_retries",
|
"slack", "slack", "csrf_token name type url max_retries",
|
||||||
map[string]string{"url": publicTargetURL},
|
map[string]string{"url": publicTargetURL},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"database", "csrf_token name type expiry",
|
"database", "archive", "csrf_token name type expiry rotation",
|
||||||
map[string]string{"expiry": "720h"},
|
map[string]string{"expiry": "720h", "rotation": "daily"},
|
||||||
},
|
},
|
||||||
{"log", "csrf_token name type", nil},
|
{"log", "log", "csrf_token name type", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range targetTypes {
|
for _, tt := range targetTypes {
|
||||||
checkAddTarget(
|
checkAddTarget(
|
||||||
ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values,
|
ctx, t, url, tt.name, tt.badge,
|
||||||
|
strings.Fields(tt.fields), tt.values,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,11 +365,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
|||||||
// type's own fields in place of the choice, and the form then submits
|
// type's own fields in place of the choice, and the form then submits
|
||||||
// exactly fields, so a field another type uses, such as url, is absent;
|
// exactly fields, so a field another type uses, such as url, is absent;
|
||||||
// Cancel closes it again. It then adds a target of the type, filling in
|
// Cancel closes it again. It then adds a target of the type, filling in
|
||||||
// values, and checks that the section lists it with that type.
|
// values, and checks that the section lists it with badge.
|
||||||
func checkAddTarget(
|
func checkAddTarget(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
url, targetType string,
|
url, targetType, badge string,
|
||||||
fields []string,
|
fields []string,
|
||||||
values map[string]string,
|
values map[string]string,
|
||||||
) {
|
) {
|
||||||
@@ -412,8 +424,8 @@ func checkAddTarget(
|
|||||||
|
|
||||||
click(ctx, t, saveButton)
|
click(ctx, t, saveButton)
|
||||||
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
||||||
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
`"]/following-sibling::div/span[text()="`+badge+`"]`),
|
||||||
"%s: the added target is not listed with its type", targetType)
|
"%s: the added target is not listed as %s", targetType, badge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
||||||
@@ -435,14 +447,18 @@ func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
|||||||
"%s: Add still shows while the form is open", targetType)
|
"%s: Add still shows while the form is open", targetType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkArchiveExpiry loads a webhook page and checks that the add
|
// checkArchiveChoices loads a webhook page and checks that the add
|
||||||
// target form's archive expiry starts on never, that the database
|
// target form's archive expiry starts on never and its archive
|
||||||
// target checkAddTarget added with 720h is listed as 30 days, and that
|
// rotation on none, that the database target checkAddTarget added with
|
||||||
// its edit form starts on 720h.
|
// 720h and daily is listed as 30 days and daily, and that its edit
|
||||||
func checkArchiveExpiry(ctx context.Context, t *testing.T, url string) {
|
// form starts on 720h and daily.
|
||||||
|
func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const expiry = `form[action$="/targets"] select[name="expiry"]`
|
const (
|
||||||
|
expiry = `form[action$="/targets"] select[name="expiry"]`
|
||||||
|
rotation = `form[action$="/targets"] select[name="rotation"]`
|
||||||
|
)
|
||||||
|
|
||||||
row := `//span[text()="added-database"]/ancestor::div[@class="p-4"][1]`
|
row := `//span[text()="added-database"]/ancestor::div[@class="p-4"][1]`
|
||||||
|
|
||||||
@@ -450,26 +466,36 @@ func checkArchiveExpiry(ctx context.Context, t *testing.T, url string) {
|
|||||||
|
|
||||||
chooseTargetType(ctx, t, "database")
|
chooseTargetType(ctx, t, "database")
|
||||||
|
|
||||||
var start, edited string
|
var startExpiry, startRotation, editedExpiry, editedRotation string
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.Value(expiry, &start, chromedp.ByQuery),
|
ctx,
|
||||||
|
chromedp.Value(expiry, &startExpiry, chromedp.ByQuery),
|
||||||
|
chromedp.Value(rotation, &startRotation, chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
assert.Equal(t, "never", start,
|
assert.Equal(t, "never", startExpiry,
|
||||||
"the add target form's archive expiry does not start on never")
|
"the add target form's archive expiry does not start on never")
|
||||||
|
assert.Equal(t, "none", startRotation,
|
||||||
|
"the add target form's archive rotation does not start on none")
|
||||||
|
|
||||||
assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+
|
assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+
|
||||||
`/following-sibling::span[text()="30 days"]`),
|
`/following-sibling::span[text()="30 days"]`),
|
||||||
"a database target added with 720h is not listed as 30 days")
|
"a database target added with 720h is not listed as 30 days")
|
||||||
|
assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+
|
||||||
|
`/following-sibling::span[text()="daily"]`),
|
||||||
|
"a database target added with daily is not listed as daily")
|
||||||
|
|
||||||
click(ctx, t, row+`//a[text()="Edit"]`)
|
click(ctx, t, row+`//a[text()="Edit"]`)
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
chromedp.WaitReady("#expiry", chromedp.ByQuery),
|
chromedp.WaitReady("#expiry", chromedp.ByQuery),
|
||||||
chromedp.Value("#expiry", &edited, chromedp.ByQuery),
|
chromedp.Value("#expiry", &editedExpiry, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#rotation", &editedRotation, chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
assert.Equal(t, "720h", edited,
|
assert.Equal(t, "720h", editedExpiry,
|
||||||
"the edit form does not start on the stored archive expiry")
|
"the edit form does not start on the stored archive expiry")
|
||||||
|
assert.Equal(t, "daily", editedRotation,
|
||||||
|
"the edit form does not start on the stored archive rotation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkRefusedTarget submits an http target the server refuses, a
|
// checkRefusedTarget submits an http target the server refuses, a
|
||||||
@@ -765,14 +791,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
|||||||
"the event's own page does not show its body")
|
"the event's own page does not show its body")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEventLog loads the event log and checks an event's row. Clicking
|
// checkEventLog loads the event log and checks that of its events only
|
||||||
// its ID expands the event, and in there clicking its delivery shows the
|
// the newest, eventID, starts expanded: its row says so and its caret is
|
||||||
// delivery's attempts and clicking again hides them. Clicking the row's
|
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
||||||
// caret collapses the event, clicking it again expands it, and clicking
|
// the newest event, clicking its delivery shows the delivery's attempts
|
||||||
// the ID again collapses it. While the event is expanded the row says so
|
// and clicking again hides them. Clicking the row's caret collapses the
|
||||||
// and its caret is turned up, and while it is collapsed neither. It then
|
// event, clicking it again expands it, clicking its ID collapses it and
|
||||||
// runs checkEventSelection on the log's last event, lastEventID, and
|
// clicking the ID again expands it. While the event is collapsed the row
|
||||||
// checkEventKeyboard on eventID.
|
// says so and its caret is turned down. It then runs checkEventSelection
|
||||||
|
// on lastEventID and checkEventKeyboard on eventID.
|
||||||
func checkEventLog(
|
func checkEventLog(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
@@ -790,20 +817,19 @@ func checkEventLog(
|
|||||||
caretUp := caret + `[contains(@class, "rotate-180")]`
|
caretUp := caret + `[contains(@class, "rotate-180")]`
|
||||||
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
|
||||||
|
|
||||||
click(ctx, t, id)
|
|
||||||
assert.True(t, shown(ctx, expanded),
|
|
||||||
"clicking the event's ID does not expand it")
|
|
||||||
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
||||||
"the expanded event's row does not say it is expanded")
|
"the expanded event's row does not say it is expanded")
|
||||||
assert.True(t, shown(ctx, caretUp),
|
assert.True(t, shown(ctx, caretUp),
|
||||||
"the expanded event's caret does not turn up")
|
"the expanded event's caret does not turn up")
|
||||||
|
assert.True(t, hidden(ctx, lastExpanded),
|
||||||
|
"an older event starts expanded")
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||||
|
|
||||||
@@ -829,7 +855,11 @@ func checkEventLog(
|
|||||||
|
|
||||||
click(ctx, t, id)
|
click(ctx, t, id)
|
||||||
assert.True(t, hidden(ctx, expanded),
|
assert.True(t, hidden(ctx, expanded),
|
||||||
"clicking the event's ID again does not collapse it")
|
"clicking the event's ID does not collapse it")
|
||||||
|
|
||||||
|
click(ctx, t, id)
|
||||||
|
assert.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the event's ID again does not expand it")
|
||||||
|
|
||||||
checkEventSelection(ctx, t, url, lastEventID)
|
checkEventSelection(ctx, t, url, lastEventID)
|
||||||
checkEventKeyboard(ctx, t, url, eventID)
|
checkEventKeyboard(ctx, t, url, eventID)
|
||||||
@@ -852,6 +882,7 @@ func checkEventSelection(
|
|||||||
id := `//span[text()="` + eventID + `"]`
|
id := `//span[text()="` + eventID + `"]`
|
||||||
row := id + `/ancestor::div[@role="button"]`
|
row := id + `/ancestor::div[@role="button"]`
|
||||||
caret := row + `//*[local-name()="svg"]`
|
caret := row + `//*[local-name()="svg"]`
|
||||||
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
|
||||||
var (
|
var (
|
||||||
selected, state string
|
selected, state string
|
||||||
@@ -877,8 +908,11 @@ func checkEventSelection(
|
|||||||
`window.getSelection().removeAllRanges()`, nil,
|
`window.getSelection().removeAllRanges()`, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The newest event starts expanded, which can push this one below
|
||||||
|
// the short window, where the mouse cannot reach it.
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
||||||
|
chromedp.ScrollIntoView(id, chromedp.BySearch),
|
||||||
))
|
))
|
||||||
|
|
||||||
selectText(ctx, t, id)
|
selectText(ctx, t, id)
|
||||||
@@ -892,6 +926,11 @@ func checkEventSelection(
|
|||||||
assert.Equal(t, "true", state,
|
assert.Equal(t, "true", state,
|
||||||
"clicking the caret does not expand the event at once")
|
"clicking the caret does not expand the event at once")
|
||||||
|
|
||||||
|
// The row says it is expanded before its expanded part is shown, so
|
||||||
|
// the scroll waits for that part, to end at the expanded page's end.
|
||||||
|
require.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the caret does not show the event's expanded part")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
||||||
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
|
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
|
||||||
&scrolled,
|
&scrolled,
|
||||||
@@ -916,14 +955,15 @@ func checkEventSelection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkEventKeyboard loads the event log and checks that Tab from the
|
// checkEventKeyboard loads the event log and checks that Tab from the
|
||||||
// page's Back link reaches the event's row, the first after it, and that
|
// page's Pending link, the last link above the list, reaches the row of
|
||||||
// Enter then expands the event and Space collapses it.
|
// the newest event, the first after it, and that Enter then collapses
|
||||||
|
// that event, which starts expanded, and Space expands it again.
|
||||||
func checkEventKeyboard(
|
func checkEventKeyboard(
|
||||||
ctx context.Context, t *testing.T, url, eventID string,
|
ctx context.Context, t *testing.T, url, eventID string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
back := `//a[contains(text(), "Back to")]`
|
pending := `//a[starts-with(text(), "Pending")]`
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
|
||||||
var focused string
|
var focused string
|
||||||
@@ -931,18 +971,18 @@ func checkEventKeyboard(
|
|||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
loadPage(url),
|
loadPage(url),
|
||||||
chromedp.Focus(back, chromedp.BySearch),
|
chromedp.Focus(pending, chromedp.BySearch),
|
||||||
chromedp.KeyEvent(kb.Tab),
|
chromedp.KeyEvent(kb.Tab),
|
||||||
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
|
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
|
||||||
))
|
))
|
||||||
require.Contains(t, focused, eventID,
|
require.Contains(t, focused, eventID,
|
||||||
"Tab from the Back link does not reach the event's row")
|
"Tab from the Pending link does not reach the event's row")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
||||||
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
|
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
||||||
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
|
assert.True(t, shown(ctx, expanded), "Space does not expand the event")
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectText selects the text of the element matching an XPath
|
// selectText selects the text of the element matching an XPath
|
||||||
@@ -1139,7 +1179,9 @@ func checkNewWebhookTargets(
|
|||||||
pruningChoice, expiry, chromedp.BySearch,
|
pruningChoice, expiry, chromedp.BySearch,
|
||||||
)))
|
)))
|
||||||
|
|
||||||
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry + `"}`
|
// The rotation choice is left on none.
|
||||||
|
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry +
|
||||||
|
`","rotation":"none"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
click(ctx, t, createButton)
|
click(ctx, t, createButton)
|
||||||
@@ -1180,8 +1222,8 @@ func targetConfigs(
|
|||||||
// checkRefusedNewWebhook submits the new webhook page with archive
|
// checkRefusedNewWebhook submits the new webhook page with archive
|
||||||
// checked and an HTTP target URL the server refuses, a loopback
|
// checked and an HTTP target URL the server refuses, a loopback
|
||||||
// destination, and checks that the page comes back with the reason and
|
// destination, and checks that the page comes back with the reason and
|
||||||
// every value entered, archive still checked and its pruning choice
|
// every value entered, archive still checked and its pruning and
|
||||||
// showing.
|
// rotation choices showing.
|
||||||
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1198,17 +1240,19 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
|||||||
click(ctx, t, archiveBox)
|
click(ctx, t, archiveBox)
|
||||||
require.True(t, shown(ctx, pruningChoice),
|
require.True(t, shown(ctx, pruningChoice),
|
||||||
"checking archive does not show the pruning choice")
|
"checking archive does not show the pruning choice")
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
require.NoError(t, chromedp.Run(
|
||||||
pruningChoice, "2160h", chromedp.BySearch,
|
ctx,
|
||||||
)))
|
chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch),
|
||||||
|
chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery),
|
||||||
|
))
|
||||||
click(ctx, t, createButton)
|
click(ctx, t, createButton)
|
||||||
|
|
||||||
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
||||||
"a refused webhook does not show the reason")
|
"a refused webhook does not show the reason")
|
||||||
|
|
||||||
var (
|
var (
|
||||||
name, description, retention, typed, expiry string
|
name, description, retention, typed, expiry, rotation string
|
||||||
checked bool
|
checked bool
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
@@ -1218,6 +1262,7 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
|||||||
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
||||||
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
||||||
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
||||||
|
chromedp.Value("#archive_rotation", &rotation, chromedp.ByQuery),
|
||||||
chromedp.Evaluate(archiveIsOn, &checked),
|
chromedp.Evaluate(archiveIsOn, &checked),
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -1228,6 +1273,7 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
|||||||
assert.True(t, checked, "archive is no longer checked")
|
assert.True(t, checked, "archive is no longer checked")
|
||||||
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
||||||
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
|
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
|
||||||
|
assert.Equal(t, "monthly", rotation, "the rotation chosen is lost")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||||
@@ -1254,3 +1300,60 @@ func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
|||||||
click(ctx, t, button)
|
click(ctx, t, button)
|
||||||
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scrollsSideways reports whether the page is wider than the window. A
|
||||||
|
// page's clientWidth is the window's width less its scroll bar.
|
||||||
|
const scrollsSideways = `document.documentElement.scrollWidth >
|
||||||
|
document.documentElement.clientWidth`
|
||||||
|
|
||||||
|
// cutOffElements lists each element, without elements inside it, that
|
||||||
|
// is shown but runs past the page's edge or its card's, by more than a
|
||||||
|
// pixel of rounding. A card hides what runs past its edge.
|
||||||
|
const cutOffElements = `[...document.querySelectorAll("body *")]
|
||||||
|
.filter((el) => {
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
const card = el.closest(".card")?.getBoundingClientRect();
|
||||||
|
const left = card ? card.left : 0;
|
||||||
|
const right = card ? card.right : document.documentElement.clientWidth;
|
||||||
|
return el.children.length === 0 && box.width > 0 &&
|
||||||
|
(box.left < left - 1 || box.right > right + 1);
|
||||||
|
})
|
||||||
|
.map((el) => el.outerHTML.slice(0, 120))`
|
||||||
|
|
||||||
|
// checkPhoneWidth loads the webhook page, url, and its event log,
|
||||||
|
// eventLog, in a phone-sized window, the event log with the attempts of
|
||||||
|
// the newest event's delivery to targetName shown. It checks that
|
||||||
|
// neither page scrolls sideways and that nothing shown on either, no
|
||||||
|
// status, time or control, is cut off at the page's or its card's edge.
|
||||||
|
func checkPhoneWidth(
|
||||||
|
ctx context.Context, t *testing.T, url, eventLog, targetName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
sideways bool
|
||||||
|
cutOff []string
|
||||||
|
)
|
||||||
|
|
||||||
|
measure := chromedp.Tasks{
|
||||||
|
chromedp.Evaluate(scrollsSideways, &sideways),
|
||||||
|
chromedp.Evaluate(cutOffElements, &cutOff),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
||||||
|
loadPage(url),
|
||||||
|
measure,
|
||||||
|
))
|
||||||
|
assert.False(t, sideways, "the webhook page scrolls sideways on a phone")
|
||||||
|
assert.Empty(t, cutOff, "the webhook page cuts these off on a phone")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(eventLog)))
|
||||||
|
click(ctx, t, `//span[text()="`+targetName+`"]`)
|
||||||
|
require.True(t, shown(ctx, `//span[text()="Attempt 1"]`),
|
||||||
|
"clicking the delivery does not show its attempts")
|
||||||
|
require.NoError(t, chromedp.Run(ctx, measure))
|
||||||
|
assert.False(t, sideways, "the event log scrolls sideways on a phone")
|
||||||
|
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
|
|||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, env.post(path, csrfForm(token), cookies),
|
t, env.post(path, csrfForm(token), cookies),
|
||||||
logsPath, "resubmit-no-targets",
|
logsPath, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
|
|||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, env.post(path, csrfForm(token), cookies),
|
t, env.post(path, csrfForm(token), cookies),
|
||||||
logsPath, "resubmit-no-targets",
|
logsPath, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
|
|||||||
csrfForm(token), cookies,
|
csrfForm(token), cookies,
|
||||||
),
|
),
|
||||||
intrudersLogs, "resubmit-no-targets",
|
intrudersLogs, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ func (e *noopArchives) Rename(_, _, _ string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noopCircuitBreakers satisfies handlers.New's
|
||||||
|
// delivery.CircuitBreakers dependency with no target's deliveries
|
||||||
|
// paused.
|
||||||
|
type noopCircuitBreakers struct{}
|
||||||
|
|
||||||
|
func (b *noopCircuitBreakers) StateAndCooldown(
|
||||||
|
string,
|
||||||
|
) (delivery.CircuitState, time.Duration) {
|
||||||
|
return delivery.CircuitClosed, 0
|
||||||
|
}
|
||||||
|
|
||||||
// testEnv is the real router from routes.go plus the collaborators
|
// testEnv is the real router from routes.go plus the collaborators
|
||||||
// tests need to seed users and forge sessions.
|
// tests need to seed users and forge sessions.
|
||||||
type testEnv struct {
|
type testEnv struct {
|
||||||
@@ -126,6 +137,9 @@ func newTestEnvWithConfig(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.Archives { return &noopArchives{} },
|
func() delivery.Archives { return &noopArchives{} },
|
||||||
|
func() delivery.CircuitBreakers {
|
||||||
|
return &noopCircuitBreakers{}
|
||||||
|
},
|
||||||
metrics.NewRegistry,
|
metrics.NewRegistry,
|
||||||
metrics.New,
|
metrics.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
@@ -900,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
|||||||
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSignInAndSignOutWording pins one wording for both: the sign-in
|
||||||
|
// page's button reads "Sign in" and the navbar's buttons "Sign out", as
|
||||||
|
// the sign-in page's heading, the error page's link and the notice
|
||||||
|
// after signing out do.
|
||||||
|
func TestSignInAndSignOutWording(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, env.get("/pages/login", nil).Body.String(), ">Sign in</button>",
|
||||||
|
)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "reader", "somepassword")
|
||||||
|
page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String()
|
||||||
|
|
||||||
|
assert.Equal(t, 2, strings.Count(page, ">Sign out</button>"),
|
||||||
|
"the desktop and the mobile navbar each say Sign out")
|
||||||
|
}
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
@@ -1349,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
)
|
)
|
||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, w, logsPath, "resubmit-no-targets",
|
t, w, logsPath, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||||
@@ -1365,9 +1399,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
|
|
||||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||||
// the tests above do not: the navbar's "Webhooks" links, the back and
|
// the tests above do not: the navbar's "Webhooks" links, the back and
|
||||||
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
// Cancel links, the list's link to a webhook, and the "Full Event Log"
|
||||||
// link beside the recent events, and the event log's page links. Each
|
// link beside the recent events. Each must point where it should, and
|
||||||
// must point where it should, and that page must render.
|
// that page must render.
|
||||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1378,12 +1412,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
wh := env.seedWebhook(t, userID)
|
wh := env.seedWebhook(t, userID)
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
tgt := env.seedTarget(t, wh.ID)
|
||||||
|
|
||||||
// The event log shows 25 events a page; one more gives it a second
|
|
||||||
// page, so it renders its Next and Previous links.
|
|
||||||
for range 26 {
|
|
||||||
env.seedEvent(t, wh.ID, "paged")
|
|
||||||
}
|
|
||||||
|
|
||||||
list := "/hooks"
|
list := "/hooks"
|
||||||
newForm := list + "/new"
|
newForm := list + "/new"
|
||||||
page := "/hook/" + wh.ID
|
page := "/hook/" + wh.ID
|
||||||
@@ -1415,8 +1443,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
|||||||
{targetEdit, back, page},
|
{targetEdit, back, page},
|
||||||
{targetEdit, cancel, page},
|
{targetEdit, cancel, page},
|
||||||
{events, back, page},
|
{events, back, page},
|
||||||
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
|
||||||
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
|
||||||
} {
|
} {
|
||||||
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
||||||
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
||||||
|
|||||||
@@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
|||||||
|
|
||||||
// sentryKeepsHeader reports whether a request header is routing or
|
// sentryKeepsHeader reports whether a request header is routing or
|
||||||
// content metadata rather than client-chosen payload. Referer is kept
|
// content metadata rather than client-chosen payload. Referer is kept
|
||||||
// on the reasoning that it is browser-set, that this service emits
|
// on the reasoning that it is browser-set, that the only query
|
||||||
// only ?page= in its own links, and that Referrer-Policy is set to
|
// parameters in this service's own URLs are the login page's `next`,
|
||||||
|
// `notice` and the event log's `show`, and that Referrer-Policy is set to
|
||||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||||
// local access log line, which holds the rest of the detail.
|
// local access log line, which holds the rest of the detail.
|
||||||
func sentryKeepsHeader(name string) bool {
|
func sentryKeepsHeader(name string) bool {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "10.11.0",
|
||||||
|
"prettier": "3.9.9"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f"
|
||||||
|
}
|
||||||
+5
-4
@@ -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 is deliberately not installed: linting runs
|
# make, or go). golangci-lint, node, ESLint and prettier are deliberately
|
||||||
# only in docker, via script/lint and Dockerfile.lint.
|
# not installed: they run only in docker, via script/lint and script/fmt.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -60,9 +60,10 @@ main() {
|
|||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Not installed here: docker is platform-specific and out of scope for a
|
# Not installed here: docker is platform-specific and out of scope for a
|
||||||
# package-manager bootstrap, but script/lint and script/css need it.
|
# package-manager bootstrap, but script/lint, script/fmt and script/css
|
||||||
|
# need it.
|
||||||
if missing docker; then
|
if missing docker; then
|
||||||
echo "bootstrap: docker not found; script/lint and script/css require it" >&2
|
echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile runs the checks
|
# script/cibuild: run the CI build. The Dockerfile runs the checks (the
|
||||||
# (make fmt-check, lint, test), so a successful build implies a green
|
# gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown
|
||||||
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
|
# check, make test), so a successful build implies a green repo. Generic:
|
||||||
# push.
|
# needs no adaptation. The Gitea workflow runs this on push.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt: format all files (writes).
|
# script/fmt: format all files (writes): the Go code with gofmt and
|
||||||
|
# goimports, the Markdown with prettier. prettier is never installed
|
||||||
|
# locally: it runs in docker, in the Dockerfile's Markdown stages.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -10,6 +12,7 @@ main() {
|
|||||||
if command -v goimports >/dev/null 2>&1; then
|
if command -v goimports >/dev/null 2>&1; then
|
||||||
goimports -w .
|
goimports -w .
|
||||||
fi
|
fi
|
||||||
|
docker build --target markdown-output --output type=local,dest=. .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ main() {
|
|||||||
gofmt -s -l .
|
gofmt -s -l .
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
docker build --target markdown-check --output type=cacheonly .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+18
-2
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter. golangci-lint is never installed locally: it
|
# script/lint: run the linters, golangci-lint over the Go code and then
|
||||||
# runs via docker only, one way, everywhere — script/lint builds
|
# ESLint over static/js/. Neither is ever installed locally.
|
||||||
|
#
|
||||||
|
# golangci-lint runs via docker only, one way, everywhere — script/lint builds
|
||||||
# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image
|
# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image
|
||||||
# and lints as a build step. This works even when the docker daemon is remote
|
# and lints as a build step. This works even when the docker daemon is remote
|
||||||
# and bind mounts are impossible, and it removes the host linter's shared
|
# and bind mounts are impossible, and it removes the host linter's shared
|
||||||
@@ -50,6 +52,20 @@ main() {
|
|||||||
echo " still matches a stage in Dockerfile.lint." >&2
|
echo " still matches a stage in Dockerfile.lint." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ESLint runs in the Dockerfile's js-lint stage, which the image build
|
||||||
|
# runs too. It prints nothing on a pass, so there is no summary to look
|
||||||
|
# for. Instead the stage is named once, for both flags: --target fails
|
||||||
|
# on a name that matches no stage, so a rename cannot leave
|
||||||
|
# --no-cache-filter silently ignored. The js-deps stage, which installs
|
||||||
|
# ESLint, keeps its cache, so ESLint is not downloaded again.
|
||||||
|
js_stage=js-lint
|
||||||
|
docker build \
|
||||||
|
--target "$js_stage" \
|
||||||
|
--no-cache-filter="$js_stage" \
|
||||||
|
--progress=plain \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+5
-2
@@ -72,8 +72,8 @@ document.addEventListener("alpine:init", function () {
|
|||||||
// Something a click shows and hides: the mobile menu, an add form,
|
// Something a click shows and hides: the mobile menu, an add form,
|
||||||
// an entrypoint's edit form, an event in the event log or in the
|
// an entrypoint's edit form, an event in the event log or in the
|
||||||
// recent events, a delivery's attempts, the new webhook page's
|
// recent events, a delivery's attempts, the new webhook page's
|
||||||
// archive pruning choice. It starts hidden, or shown when its
|
// archive pruning and rotation choices. It starts hidden, or shown
|
||||||
// element has the data-open attribute.
|
// when its element has the data-open attribute.
|
||||||
window.Alpine.data("collapsible", function () {
|
window.Alpine.data("collapsible", function () {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
@@ -140,6 +140,7 @@ document.addEventListener("alpine:init", function () {
|
|||||||
timeout: "",
|
timeout: "",
|
||||||
maxRetries: "",
|
maxRetries: "",
|
||||||
expiry: "",
|
expiry: "",
|
||||||
|
rotation: "",
|
||||||
init() {
|
init() {
|
||||||
const refused = this.$root.dataset;
|
const refused = this.$root.dataset;
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ document.addEventListener("alpine:init", function () {
|
|||||||
this.timeout = refused.timeout;
|
this.timeout = refused.timeout;
|
||||||
this.maxRetries = refused.maxRetries;
|
this.maxRetries = refused.maxRetries;
|
||||||
this.expiry = refused.expiry;
|
this.expiry = refused.expiry;
|
||||||
|
this.rotation = refused.rotation;
|
||||||
},
|
},
|
||||||
add() {
|
add() {
|
||||||
this.choosing = true;
|
this.choosing = true;
|
||||||
@@ -169,6 +171,7 @@ document.addEventListener("alpine:init", function () {
|
|||||||
this.timeout = "";
|
this.timeout = "";
|
||||||
this.maxRetries = "";
|
this.maxRetries = "";
|
||||||
this.expiry = "";
|
this.expiry = "";
|
||||||
|
this.rotation = "";
|
||||||
this.$refs.form.reset();
|
this.$refs.form.reset();
|
||||||
},
|
},
|
||||||
get filling() {
|
get filling() {
|
||||||
|
|||||||
@@ -5,11 +5,16 @@
|
|||||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{range .Results}}
|
{{range .Results}}
|
||||||
|
{{/* Inside this loop, dot is one attempt and $ the whole delivery. */}}
|
||||||
<div class="rounded-md bg-white border border-gray-200 p-2">
|
<div class="rounded-md bg-white border border-gray-200 p-2">
|
||||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||||
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
|
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
|
||||||
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
|
<span class="text-gray-500" title="{{.RanUTC}}">{{.Ran}}</span>
|
||||||
|
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if not .Success}}failure{{else if eq $.Target.Type "database"}}archived{{else if eq $.Target.Type "log"}}written to the log{{else}}success{{end}}</span>
|
||||||
|
{{/* A database or log target sends no HTTP request, so its attempts have no status code. */}}
|
||||||
|
{{if not (eq $.Target.Type "database" "log")}}
|
||||||
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}}</span>
|
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}}</span>
|
||||||
|
{{end}}
|
||||||
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
|
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "delivery_row"}}
|
||||||
|
<!-- The line that heads a delivery, as handlers.DeliveryView holds it:
|
||||||
|
in the event log, inside the button that shows its attempts, and on
|
||||||
|
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-wrap items-center gap-3">
|
||||||
|
<span class="text-sm text-gray-700 wrap-anywhere">{{.Target.DisplayName}}</span>
|
||||||
|
{{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>
|
||||||
|
<span class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-400" title="{{.CreatedUTC}}">created {{.Created}}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
|
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
|
||||||
<dd class="text-gray-900">{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC</dd>
|
<dd class="text-gray-900">{{.ReceivedUTC}}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
|
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
|
||||||
@@ -50,6 +50,15 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Request</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{{template "event_request" .}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200">
|
<div class="p-4 border-b border-gray-200">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
||||||
@@ -66,13 +75,7 @@
|
|||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Deliveries}}
|
{{range .Deliveries}}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
{{template "delivery_row" .}}
|
||||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
|
||||||
<span class="flex flex-wrap items-center gap-3">
|
|
||||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
|
||||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
{{template "delivery_attempts" .}}
|
{{template "delivery_attempts" .}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "event_request"}}
|
||||||
|
<!-- The entrypoint an event arrived at and its request headers, as
|
||||||
|
handlers.EventLogView carries them: the same in the event log and
|
||||||
|
the event's own page. The entrypoint's URL is never shown. A
|
||||||
|
resubmitted copy, even a copy of a copy, did not arrive at an
|
||||||
|
entrypoint; the request it copies did. -->
|
||||||
|
<div class="space-y-2 text-xs">
|
||||||
|
{{if .ResubmittedFrom}}
|
||||||
|
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500">Arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .HeadersCut}}
|
||||||
|
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
|
||||||
|
{{else if .Headers}}
|
||||||
|
<p class="text-gray-500">Request headers</p>
|
||||||
|
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Headers}}</pre>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500">No request headers.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
{{define "title"}}Login - Webhooker{{end}}
|
{{define "title"}}Sign in - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary w-full py-3">Sign In</button>
|
<button type="submit" class="btn-primary w-full py-3">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{{if .CSRFToken}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-secondary">Logout</button>
|
<button type="submit" class="btn-secondary">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{{if .CSRFToken}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-secondary w-full">Logout</button>
|
<button type="submit" class="btn-secondary w-full">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,21 +6,28 @@
|
|||||||
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
||||||
event log, the navbar and the footer, so an entrypoint URL fits on
|
event log, the navbar and the footer, so an entrypoint URL fits on
|
||||||
one line. An inline style, because the committed tailwind.css has
|
one line. An inline style, because the committed tailwind.css has
|
||||||
no class this wide. -->
|
no class this wide. wrap-anywhere goes only on names and
|
||||||
|
descriptions: a row too wide for a phone must still run past the
|
||||||
|
edge, where the browser test sees it, rather than break its
|
||||||
|
controls mid-word. -->
|
||||||
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
|
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hooks" class="btn-small">← Back to webhooks</a>
|
<a href="/hooks" class="btn-small">← Back to webhooks</a>
|
||||||
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900 wrap-anywhere">{{.Webhook.Name}}</h1>
|
||||||
{{if .Webhook.Description}}
|
{{if .Webhook.Description}}
|
||||||
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
|
<p class="text-sm text-gray-500 mt-1 wrap-anywhere">{{.Webhook.Description}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
<!-- The delete prompts are the browser's own, so they
|
||||||
|
work without the page's scripts. The template
|
||||||
|
escapes each name for the script, so a quote or a
|
||||||
|
backslash in it shows as typed. -->
|
||||||
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook "{{.Webhook.Name}}"?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -56,7 +63,7 @@
|
|||||||
{{range .Entrypoints}}
|
{{range .Entrypoints}}
|
||||||
<div class="p-4" x-data="collapsible">
|
<div class="p-4" x-data="collapsible">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||||
<span x-show="closed" class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
<span x-show="closed" class="text-sm font-medium text-gray-900 wrap-anywhere">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
||||||
<!-- Edit shows this form in place of the
|
<!-- Edit shows this form in place of the
|
||||||
description and hides until it closes, and
|
description and hides until it closes, and
|
||||||
Cancel resets what was typed. With
|
Cancel resets what was typed. With
|
||||||
@@ -83,7 +90,7 @@
|
|||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -129,7 +136,8 @@
|
|||||||
data-headers="{{.TargetForm.Headers}}"
|
data-headers="{{.TargetForm.Headers}}"
|
||||||
data-timeout="{{.TargetForm.Timeout}}"
|
data-timeout="{{.TargetForm.Timeout}}"
|
||||||
data-max-retries="{{.TargetForm.MaxRetries}}"
|
data-max-retries="{{.TargetForm.MaxRetries}}"
|
||||||
data-expiry="{{.TargetForm.Expiry}}">
|
data-expiry="{{.TargetForm.Expiry}}"
|
||||||
|
data-rotation="{{.TargetForm.Rotation}}">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
<button type="button" @click="add" x-show="closed" class="btn-small">
|
<button type="button" @click="add" x-show="closed" class="btn-small">
|
||||||
@@ -154,7 +162,7 @@
|
|||||||
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Archive</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
||||||
@@ -177,10 +185,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Delivery attempts:</label>
|
||||||
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -193,25 +201,38 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Delivery attempts:</label>
|
||||||
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isDatabase">
|
<template x-if="isDatabase">
|
||||||
<div>
|
<div class="space-y-3">
|
||||||
<input type="hidden" name="type" value="database">
|
<input type="hidden" name="type" value="database">
|
||||||
<div class="flex gap-2 items-center">
|
<div>
|
||||||
<label class="text-sm text-gray-700">Archive expiry:</label>
|
<div class="flex gap-2 items-center">
|
||||||
<select name="expiry" :value="expiry" class="input text-sm w-24">
|
<label class="text-sm text-gray-700">Archive expiry:</label>
|
||||||
{{range .ArchiveExpiryChoices}}
|
<select name="expiry" :value="expiry" class="input text-sm w-24">
|
||||||
<option value="{{.Value}}">{{.Label}}</option>
|
{{range .ArchiveExpiryChoices}}
|
||||||
{{end}}
|
<option value="{{.Value}}">{{.Label}}</option>
|
||||||
</select>
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<label class="text-sm text-gray-700">Archive rotation:</label>
|
||||||
|
<select name="rotation" :value="rotation" class="input text-sm w-28">
|
||||||
|
{{range .ArchiveRotationChoices}}
|
||||||
|
<option value="{{.Value}}">{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Starts a new archive file each month, day or hour (UTC), named for it; none keeps one file.</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isLog">
|
<template x-if="isLog">
|
||||||
@@ -231,9 +252,9 @@
|
|||||||
{{range .Targets}}
|
{{range .Targets}}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||||
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
<span class="text-sm font-medium text-gray-900 wrap-anywhere">{{.Name}}</span>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="badge-info">{{.Type}}</span>
|
<span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
|
||||||
{{if .Active}}
|
{{if .Active}}
|
||||||
<span class="badge-success">Active</span>
|
<span class="badge-success">Active</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -249,12 +270,18 @@
|
|||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target "{{.Name}}"?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{with .Paused}}
|
||||||
|
<div class="text-xs text-yellow-600 mt-1">
|
||||||
|
<span class="font-medium">Deliveries paused:</span>
|
||||||
|
<span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{range .Config}}
|
{{range .Config}}
|
||||||
<div class="text-xs text-gray-500 break-all mt-1">
|
<div class="text-xs text-gray-500 break-all mt-1">
|
||||||
<span class="font-medium text-gray-700">{{.Label}}:</span>
|
<span class="font-medium text-gray-700">{{.Label}}:</span>
|
||||||
@@ -263,17 +290,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{with .Archive}}
|
{{with .Archive}}
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
<span class="font-medium text-gray-700">Archive File:</span>
|
<span class="font-medium text-gray-700">Archive file:</span>
|
||||||
<span class="break-all">{{.Name}}</span>
|
<span class="break-all">{{.Name}}</span>
|
||||||
{{with .Note}}<span>({{.}})</span>{{end}}
|
{{with .Note}}<span>({{.}})</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .Size}}
|
{{if .Files}}
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
<span class="font-medium text-gray-700">Archive Size:</span>
|
<span class="font-medium text-gray-700">Archive size:</span>
|
||||||
<span>{{.Size}}</span>
|
<span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
<span class="font-medium text-gray-700">Last Written:</span>
|
<span class="font-medium text-gray-700">Last written:</span>
|
||||||
<span title="{{.WrittenUTC}}">{{.Written}}</span>
|
<span title="{{.WrittenUTC}}">{{.Written}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+35
-38
@@ -3,25 +3,38 @@
|
|||||||
{{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">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-small wrap-anywhere">← Back to {{.Webhook.Name}}</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
<!-- Under a filter, this counts the events the filter lists. -->
|
||||||
|
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Plain links, so they work without the page's script library. The current one is marked. -->
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small{{if eq .Show ""}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show ""}} aria-current="page"{{end}}>All</a>
|
||||||
|
<a href="/hook/{{.Webhook.ID}}/events?show=failed" title="Events with at least one failed delivery" class="btn-small{{if eq .Show "failed"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "failed"}} aria-current="page"{{end}}>Failed ({{.FailedEvents}})</a>
|
||||||
|
<a href="/hook/{{.Webhook.ID}}/events?show=pending" title="Events with a delivery still pending or retrying" class="btn-small{{if eq .Show "pending"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "pending"}} aria-current="page"{{end}}>Pending ({{.PendingEvents}})</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
<!-- Only the newest event starts expanded. -->
|
||||||
<div class="p-4" x-data="collapsible">
|
{{range $i, $event := .Events}}
|
||||||
|
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
|
||||||
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
||||||
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
||||||
<span class="flex flex-wrap items-center gap-3">
|
<span class="flex flex-wrap items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
<span class="text-sm font-mono text-gray-700 wrap-anywhere">{{.ID}}</span>
|
||||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
<span class="text-sm text-gray-500 wrap-anywhere">{{.ContentType}}</span>
|
||||||
{{if .ResubmittedFrom}}
|
{{if .ResubmittedFrom}}
|
||||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -32,10 +45,10 @@
|
|||||||
<span class="flex flex-wrap items-center gap-4">
|
<span class="flex flex-wrap items-center gap-4">
|
||||||
{{range .Deliveries}}
|
{{range .Deliveries}}
|
||||||
<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}}">
|
<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}}">
|
||||||
{{.Target.DisplayName}}: {{.Status}}
|
{{.Target.DisplayName}}{{if .Replay}} (replay){{end}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}}
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span class="text-xs text-gray-400" title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||||
<!-- The caret has no text to select, so a click on it toggles at once. -->
|
<!-- The caret has no text to select, so a click on it toggles at once. -->
|
||||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" @click.stop="toggle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
@@ -46,15 +59,17 @@
|
|||||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a>.{{end}}
|
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono wrap-anywhere">{{.ResubmittedFromID}}</a>.{{end}}
|
||||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
|
||||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{template "event_request" .}}
|
||||||
|
</div>
|
||||||
{{template "event_body" .Body}}
|
{{template "event_body" .Body}}
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
@@ -64,22 +79,17 @@
|
|||||||
{{range .Deliveries}}
|
{{range .Deliveries}}
|
||||||
<div class="py-2" x-data="collapsible">
|
<div class="py-2" x-data="collapsible">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-2 text-left" @click="toggle">
|
<button type="button" class="btn-small flex-1 gap-3 text-left" @click="toggle">
|
||||||
<span class="flex flex-wrap items-center gap-3">
|
{{template "delivery_row" .}}
|
||||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</span>
|
</svg>
|
||||||
<span class="flex flex-wrap items-center gap-3">
|
|
||||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
|
||||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{{if .Status.Terminal}}
|
{{if and .Status.Terminal (not .Target.Deleted)}}
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<!-- The list to return to. -->
|
||||||
|
<input type="hidden" name="show" value="{{$.Show}}">
|
||||||
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -96,22 +106,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
|
<div class="p-12 text-center text-sm text-gray-500">{{if eq $.Show "failed"}}No event has a failed delivery.{{else if eq $.Show "pending"}}No event has a delivery pending or retrying.{{else}}No events recorded yet.{{end}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{{if or .HasPrev .HasNext}}
|
|
||||||
<div class="flex justify-center gap-2 mt-6">
|
|
||||||
{{if .HasPrev}}
|
|
||||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
|
||||||
{{end}}
|
|
||||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
|
||||||
{{if .HasNext}}
|
|
||||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -38,23 +38,32 @@
|
|||||||
<p class="text-xs text-gray-500 mt-1">Optional. When filled in, the webhook is created with an HTTP target that delivers each event to this URL.</p>
|
<p class="text-xs text-gray-500 mt-1">Optional. When filled in, the webhook is created with an HTTP target that delivers each event to this URL.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The checkbox shows the pruning choice while checked. With
|
<!-- The checkbox shows the pruning and rotation choices while
|
||||||
autocomplete="off", going back to the page does not
|
checked. With autocomplete="off", going back to the page
|
||||||
check the box again with the choice hidden. -->
|
does not check the box again with the choices hidden. -->
|
||||||
<div class="form-group" x-data="collapsible"{{if .Form.Archive}} data-open{{end}}>
|
<div class="form-group" x-data="collapsible"{{if .Form.Archive}} data-open{{end}}>
|
||||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||||
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
||||||
Archive
|
Archive
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with a database target that keeps a copy of every event.</p>
|
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with an archive target that keeps a copy of every event.</p>
|
||||||
<div x-show="open" x-cloak class="mt-3">
|
<div x-show="open" x-cloak class="mt-3">
|
||||||
<label for="archive_expiry" class="label">Archive pruning</label>
|
<label for="archive_expiry" class="label">Archive expiry</label>
|
||||||
<select id="archive_expiry" name="archive_expiry" class="input">
|
<select id="archive_expiry" name="archive_expiry" class="input">
|
||||||
{{range .ArchiveExpiryChoices}}
|
{{range .ArchiveExpiryChoices}}
|
||||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
<p class="text-xs text-gray-500 mt-1">Archived events older than this are deleted from the archive; never keeps them all.</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label for="archive_rotation" class="label">Archive rotation</label>
|
||||||
|
<select id="archive_rotation" name="archive_rotation" class="input">
|
||||||
|
{{range .ArchiveRotationChoices}}
|
||||||
|
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Starts a new archive file each month, day or hour (UTC), named for it; none keeps one file.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user