Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce13e42eda | ||
|
|
74a96b2226 | ||
|
|
8b617efa63 | ||
|
|
d8c60c9b67 | ||
|
|
ea8384f4a2 | ||
|
|
17e6c85dd8 |
@@ -15,6 +15,9 @@ bin/
|
||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||
# needed. The tarball in 3p/ must stay in the context.
|
||||
static/js/alpine.min.js
|
||||
# The js-deps stage installs ESLint; a host copy would overwrite it at the
|
||||
# js-lint stage's `COPY . .`.
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
|
||||
@@ -33,5 +33,5 @@ jobs:
|
||||
# and built cannot report success from cache.
|
||||
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 make fmt-check, golangci-lint, the stylesheet check, ESLint, make test, make build)
|
||||
run: script/cibuild
|
||||
|
||||
@@ -15,6 +15,9 @@ bin/
|
||||
# Go vendor directory
|
||||
vendor/
|
||||
|
||||
# ESLint and its dependencies, installed from yarn.lock
|
||||
node_modules/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
+17
-1
@@ -65,15 +65,31 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
||||
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
|
||||
# stays cached until those two files change. 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.
|
||||
# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
|
||||
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
|
||||
WORKDIR /src
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
FROM js-deps AS js-lint
|
||||
COPY . .
|
||||
RUN --network=none node_modules/.bin/eslint static/js
|
||||
|
||||
# Build stage
|
||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
||||
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 and JavaScript lint stages passing
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=css-check /out/tailwind.css /dev/null
|
||||
COPY --from=js-lint /src/yarn.lock /dev/null
|
||||
|
||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||
# suite executes. git is what script/version derives the version with.
|
||||
|
||||
@@ -26,7 +26,9 @@ before deploying one.
|
||||
golangci-lint is not a prerequisite and must not be installed on the
|
||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||
digest-pinned linter image via `Dockerfile.lint`. The same holds for
|
||||
tailwindcss (see [Stylesheet](#stylesheet)).
|
||||
tailwindcss (see [Stylesheet](#stylesheet)). ESLint, node and yarn are
|
||||
not prerequisites either, and `make lint` never uses a host copy of them
|
||||
(see [Linting](#linting)).
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -58,7 +60,7 @@ make setup # Bootstrap + install git pre-commit hook
|
||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
||||
make fmt # Format code (gofmt + goimports)
|
||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
make lint # Run golangci-lint and ESLint in Docker
|
||||
make test # Run tests with race detection
|
||||
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
||||
make check # test + lint + fmt-check + css-check (CI gate)
|
||||
@@ -736,7 +738,8 @@ The app runs as a non-root user (`webhooker`, UID 1000), exposes port
|
||||
The `/var/lib/webhooker` volume holds all SQLite databases: the main
|
||||
application database (`webhooker.db`), the per-webhook event databases
|
||||
(`events-{uuid}.db`), and any archive databases written by `database`
|
||||
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`). Mount
|
||||
targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`, with
|
||||
`-{period}` before `.db` for a target that rotates). Mount
|
||||
this as a persistent volume to
|
||||
preserve data across container restarts.
|
||||
|
||||
@@ -976,9 +979,11 @@ is both the simplest and the only complete rule:
|
||||
- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries,
|
||||
delivery results.
|
||||
- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per
|
||||
`database` target**. Archived events. The two names are made safe for
|
||||
a file name, and the file is renamed when the webhook or the target is
|
||||
(see [Database Architecture](#database-architecture)).
|
||||
`database` target**, or one per month, day or hour for a target that
|
||||
rotates, with `-{period}` before `.db`. Archived events. The two names
|
||||
are made safe for a file name, and the files are renamed when the
|
||||
webhook or the target is (see
|
||||
[Database Architecture](#database-architecture)).
|
||||
|
||||
`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their
|
||||
canonical 36-character hyphenated form, so a real filename looks like
|
||||
@@ -1318,7 +1323,8 @@ We provide:
|
||||
- `script/test` — run the test suite
|
||||
- `script/test-browser` — run the browser test in Docker (see
|
||||
[Third-party browser assets](#third-party-browser-assets))
|
||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||
- `script/lint` — run golangci-lint and ESLint in Docker (see Linting
|
||||
below)
|
||||
- `script/fmt` — format all code (writes)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes;
|
||||
@@ -1364,17 +1370,18 @@ an entrypoint's Edit button shows its edit form in place of its description and
|
||||
hides until the form closes, Cancel hides the form and drops what was typed, as
|
||||
does leaving the page and going back to it, and Save changes the description;
|
||||
of the recent events on the webhook page only the newest starts expanded, each
|
||||
expands and collapses, and Open leads to the event's own page; an event in the
|
||||
event log expands and collapses when its row's caret or its ID is clicked, and
|
||||
from the keyboard, but not when its ID is selected with the mouse, and a
|
||||
delivery's attempts inside it expand and collapse; and at phone width the menu
|
||||
button opens and closes the mobile menu. It also fails if the browser reports a
|
||||
console warning or error, an uncaught exception, or anything the policy refused.
|
||||
`make check` and the image build lint it but do not run it, and `make test`
|
||||
leaves it out (its file is built only with the `browser` build tag). Run it with
|
||||
`make test-browser` after changing `templates/` or `static/js/`: that builds
|
||||
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
|
||||
image, so the host needs no browser.
|
||||
expands and collapses, and Open leads to the event's own page; of the events in
|
||||
the event log only the newest starts expanded, and an event there expands and
|
||||
collapses when its row's caret or its ID is clicked, and from the keyboard, but
|
||||
not when its ID is selected with the mouse, and a delivery's attempts inside it
|
||||
expand and collapse; and at phone width the menu button opens and closes the
|
||||
mobile menu. It also fails if the browser reports a console warning or error, an
|
||||
uncaught exception, or anything the policy refused. `make check` and the image
|
||||
build lint it but do not run it, and `make test` leaves it out (its file is
|
||||
built only with the `browser` build tag). Run it with `make test-browser` after
|
||||
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
||||
runs the test in a digest-pinned headless browser image, so the host needs no
|
||||
browser.
|
||||
|
||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||
@@ -1611,8 +1618,9 @@ The new webhook form can also give the webhook its first targets: an
|
||||
optional HTTP target URL creates an `http` target named `HTTP`, and the
|
||||
archive checkbox creates a `database` target named `Archive` whose
|
||||
`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d
|
||||
or 365d). Both are validated as on the add target form, and the webhook
|
||||
and its targets are created together or not at all.
|
||||
or 365d) and whose `rotation` is the rotation chosen below that (none,
|
||||
monthly, daily or hourly). Both are validated as on the add target form,
|
||||
and the webhook and its targets are created together or not at all.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------- | ----------- |
|
||||
@@ -1721,12 +1729,14 @@ events should be forwarded.
|
||||
own archive database
|
||||
(`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term
|
||||
retention, with an optional creation-validated expiry (default: keep
|
||||
forever). The new webhook form, the add target form and the target edit
|
||||
form all offer the same expiries: never, 1h, 12h, 24h, 30d, 90d or 365d.
|
||||
The target list shows the expiry in plain units, such as "30 days". No
|
||||
external delivery and no retries; an archive write failure fails the
|
||||
delivery. See the database target section under
|
||||
"Per-Webhook Event Databases" for the full semantics.
|
||||
forever) and rotation (default: none, one file). The new webhook form,
|
||||
the add target form and the target edit form all offer the same
|
||||
expiries: never, 1h, 12h, 24h, 30d, 90d or 365d, and the same
|
||||
rotations: none, monthly, daily or hourly. The target list shows the
|
||||
expiry in plain units, such as "30 days", and the rotation. No external
|
||||
delivery and no retries; an archive write failure fails the delivery.
|
||||
See the database target section under "Per-Webhook Event Databases"
|
||||
for the full semantics.
|
||||
- **`log`** — Write the event to the application log (stdout). Useful
|
||||
for debugging.
|
||||
|
||||
@@ -1830,6 +1840,7 @@ status across potentially multiple attempts.
|
||||
| `target_id`| UUID | Foreign key → Target |
|
||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||
| `replay` | boolean | Whether the delivery was created by **Replay** |
|
||||
|
||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||
DeliveryResults.
|
||||
@@ -1849,7 +1860,8 @@ NEW `pending` delivery for the same event and target and hands it to
|
||||
the engine on the ordinary path — same retries, same SSRF guard, same
|
||||
circuit breaker as a first attempt. It never touches the delivery it
|
||||
repeats: that row's status, timestamps and recorded attempts stand as
|
||||
the record of what happened.
|
||||
the record of what happened. The new delivery records `replay`, and the
|
||||
event log and the event's page label it a replay.
|
||||
|
||||
What is re-sent is the stored event body, against the target's
|
||||
configuration **as it stands now** — the point of a replay is to
|
||||
@@ -1903,6 +1915,10 @@ A `database` or `log` target sends no HTTP request, so in the event log and
|
||||
on the event's page its attempts show no status: a successful one reads
|
||||
"archived" or "written to the log".
|
||||
|
||||
The event log and the event's page show when each attempt was recorded and
|
||||
when each delivery was created, as the recent events list shows when an event
|
||||
arrived: how long ago, with the full UTC time on hover.
|
||||
|
||||
**Relations:** Belongs to Delivery.
|
||||
|
||||
#### EventTotals, TargetTotals and EntrypointTotals
|
||||
@@ -2097,15 +2113,31 @@ single `-`, no `-` at either end, cut to 40 characters, and `unnamed`
|
||||
when nothing is left. The target UUID keeps the file name unique. A
|
||||
webhook named `Orders (EU)` with a target named `Long-term archive`
|
||||
archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`.
|
||||
Renaming the webhook or the target renames the file, under the same
|
||||
lock the archive writes and the archive sweeper take. Webhook edits,
|
||||
target edits and target creation run one at a time, so no edit can
|
||||
rename the file between another's rename and save, and the name on disk
|
||||
matches the UI. A rename never replaces a file: if one already has
|
||||
the new name, the edit is refused with an error naming that file, and
|
||||
the stored name stays. If the archive is not there (the operator moved
|
||||
it away), the rename is not an error, and the next write creates the
|
||||
file under the new name.
|
||||
|
||||
An optional `rotation` in the target's config JSON (e.g.
|
||||
`{"rotation":"daily"}`) is `none`, the default, which keeps the one
|
||||
file, or `monthly`, `daily` or `hourly`. A target that rotates writes
|
||||
each event to a file named for the period of the event's receive time,
|
||||
in UTC, put before the `.db`:
|
||||
`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly,
|
||||
`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds
|
||||
exactly its period's events, and the first event of a new period starts
|
||||
the next file, so a finished period's file can be moved away like any
|
||||
archive. A changed rotation applies from the next event: the files
|
||||
already written keep their names and stay, pruned, shown and downloaded
|
||||
with the rest, since every file named for the target is its archive,
|
||||
whichever rotation wrote it.
|
||||
|
||||
Renaming the webhook or the target renames every one of the target's
|
||||
files, each keeping its period, under the same lock the archive writes
|
||||
and the archive sweeper take. Webhook edits, target edits and target
|
||||
creation run one at a time, so no edit can rename the files between
|
||||
another's rename and save, and the names on disk match the UI. A rename
|
||||
never replaces a file: if one already has a new name, the edit is
|
||||
refused with an error naming that file, nothing is moved, and the
|
||||
stored name stays. If the archive is not there (the operator moved it
|
||||
away), the rename is not an error, and the next write creates the file
|
||||
under the new name.
|
||||
|
||||
The file is moved just before the new name is saved. If the process
|
||||
stops between the two, the archive is left under the new name while the
|
||||
@@ -2144,18 +2176,29 @@ interleave with a write, and it leaves the archive closed afterwards so
|
||||
the move-the-file-away workflow keeps working. Archives with no expiry,
|
||||
or the expiry `never`, are not touched by the sweep at all.
|
||||
|
||||
For a target with several files, the write path prunes only the file it
|
||||
writes to, and the sweep prunes every one of them, taking the target's
|
||||
lock for one file at a time, so a write to the target waits for at most
|
||||
one file's prune. A file that is gone by the time the sweep reaches it
|
||||
is skipped. A file named for a period that the sweep leaves empty is
|
||||
deleted, with any `-wal` and `-shm` beside it; the file without a period
|
||||
is kept even when empty, as it always has been.
|
||||
|
||||
Because each `database` target has its own archive file, a target's
|
||||
`expiry` governs only its own archive. Two `database` targets on one
|
||||
webhook with different expiries keep two archives, each pruned on its
|
||||
own schedule.
|
||||
|
||||
The webhook page shows, for each `database` target, its archive file's
|
||||
name, its size on disk and when it was last written. The size counts
|
||||
the `.db` and its `-wal` together, and the last write is the later of
|
||||
their two modification times, since a write lands in the `-wal` first.
|
||||
Both are read from the files' metadata; the archive is never opened.
|
||||
Before the first write, and after the file has been moved away, the page
|
||||
shows `not created yet` beside the name.
|
||||
The webhook page shows, for each `database` target, the name of the
|
||||
archive file an event received now would go to, the size on disk of all
|
||||
the target's archive files together, how many there are when there is
|
||||
more than one, and when the latest of them was last written. The size
|
||||
counts each `.db` and its `-wal` together, and the last write is the
|
||||
latest of their modification times, since a write lands in the `-wal`
|
||||
first. All are read from the files' metadata; the archive is never
|
||||
opened. While the named file does not exist — before the first write,
|
||||
before the first event of a new period, and after the file has been
|
||||
moved away — the page shows `not created yet` beside the name.
|
||||
|
||||
Each `database` target on the webhook page has a **Download** button,
|
||||
which returns its archive as one gzipped JSON file,
|
||||
@@ -2163,22 +2206,31 @@ which returns its archive as one gzipped JSON file,
|
||||
names made safe as above and the time in UTC. The file holds one
|
||||
object: `webhook` and `target`, each an `id` and a `name`;
|
||||
`exported_at`; and `archived_events`, one object per archived row with
|
||||
every column, keyed by column name. A body that is not valid UTF-8 is
|
||||
written in base64, with `"body_encoding": "base64"` beside it. An
|
||||
archive that does not exist yet, or was moved away, downloads with an
|
||||
empty `archived_events`; the download never creates the file.
|
||||
every column, keyed by column name. The rows come from every one of the
|
||||
target's files: the file without a period first, then the others in
|
||||
the order of their periods, oldest first, and each row from a file named
|
||||
for a period has that `period` beside its columns. A body that is not
|
||||
valid UTF-8 is written in base64, with `"body_encoding": "base64"`
|
||||
beside it. An archive that does not exist yet, or was moved away,
|
||||
downloads with an empty `archived_events`; the download never creates a
|
||||
file.
|
||||
|
||||
The download streams: each row is read and written out compressed
|
||||
before the next is read, so neither the archive nor the JSON is held in
|
||||
memory. It reads on a connection of its own, inside one read-only
|
||||
transaction, so the file holds the archive as it stood when the
|
||||
download started, and archive writes go on meanwhile, since under WAL a
|
||||
reader never blocks a writer. While it runs, the `-wal` cannot be
|
||||
checkpointed past what it reads, so a long download lets the `-wal`
|
||||
grow. It finds the file by the stored names under the lock that webhook
|
||||
edits, target edits and target creation hold, and lets go once the file
|
||||
is open: a rename during the download moves the file without affecting
|
||||
it.
|
||||
memory. When it starts it lists the target's files by the stored names,
|
||||
under the lock that webhook edits, target edits and target creation
|
||||
hold, and lets go. It then opens one file at a time, only when its rows
|
||||
are about to be written out, and closes it before it opens the next, so
|
||||
it never has more than one of the target's files open. To open each, it
|
||||
takes the lock again just long enough to find the file by its period
|
||||
under the names stored then, so a rename during the download loses no
|
||||
file; a file that is gone by then, emptied by the sweep or moved away,
|
||||
is skipped. Each file is read on a connection of its own inside one
|
||||
read-only transaction, so it is written out as it stood when it was
|
||||
opened, and archive writes go on meanwhile, since under WAL a reader
|
||||
never blocks a writer. Until the open file is closed its `-wal` cannot
|
||||
be checkpointed past what the download reads, so a long download lets
|
||||
that `-wal` grow.
|
||||
|
||||
Deleting a webhook releases its archives: the delivery engine's cached
|
||||
archive writers are dropped and their file handles closed, so nothing
|
||||
@@ -2536,8 +2588,9 @@ The query string is never logged; it is replaced by the fixed marker
|
||||
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
|
||||
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
||||
buy the same amplification as an invented path. Nothing debuggable is
|
||||
lost: `page`, on the authenticated pagination links, is the only query
|
||||
parameter this service reads.
|
||||
lost: the only query parameters this service reads are the login page's
|
||||
`next`, the page to return to, and `notice`, which names the line a page
|
||||
shows after an action.
|
||||
|
||||
Client-supplied request content does not leave the host by the other
|
||||
route either. The Sentry SDK attaches the request to every event it
|
||||
@@ -3051,7 +3104,7 @@ returns to the page that was asked for.
|
||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at, its request headers, its whole body and every delivery of it |
|
||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one its original arrived at), its request headers, its whole body and every delivery of it |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
||||
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||
@@ -3144,6 +3197,7 @@ webhooker/
|
||||
│ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target
|
||||
│ │ ├── target_database.go # Database archive target
|
||||
│ │ ├── target_database_archive.go # Archive file lifecycle and pruning
|
||||
│ │ ├── target_database_rotation.go # Archive rotation and file names
|
||||
│ │ ├── target_database_export.go # Archive download as gzipped JSON
|
||||
│ │ ├── target_log.go # Log target (stdout)
|
||||
│ │ ├── target_config_view.go # Masked target config for templates
|
||||
@@ -3198,12 +3252,14 @@ webhooker/
|
||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Stages: lint, stylesheet, test+build, Alpine runtime
|
||||
├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, test+build, Alpine runtime
|
||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||
├── Dockerfile.browser # Browser test image built by script/test-browser
|
||||
├── Makefile # 13 of 19 targets shim script/; 6 are inline
|
||||
├── go.mod / go.sum
|
||||
└── .golangci.yml # Linter configuration
|
||||
├── package.json / yarn.lock # ESLint, pinned, for the JavaScript lint stage
|
||||
├── eslint.config.mjs # ESLint configuration for static/js/
|
||||
└── .golangci.yml # golangci-lint configuration
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
@@ -3513,6 +3569,20 @@ Three properties are load-bearing:
|
||||
`golangci-lint run` silently ignores config keys it does not
|
||||
recognize, so a typo would disable a setting with no warning.
|
||||
|
||||
ESLint never runs on the host either. It lints `static/js/` (not the
|
||||
extracted Alpine.js) in the Dockerfile's `js-lint` stage, which
|
||||
`script/lint` builds after `Dockerfile.lint` and the image build runs
|
||||
before the builder stage. Its version is pinned in `package.json` and
|
||||
every package's hash in `yarn.lock`. The `js-deps` stage before it
|
||||
installs ESLint and stays cached until either file changes, so only the
|
||||
lint step re-runs and ESLint is not downloaded again.
|
||||
`eslint.config.mjs` turns on the rules of the JavaScript styleguide
|
||||
`REPO_POLICIES.md` links to that a linter can check: `no-var` and
|
||||
`prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no
|
||||
summary line to look for; it names the stage once for both `--target`
|
||||
and `--no-cache-filter`, and `--target` fails on a name that matches no
|
||||
stage.
|
||||
|
||||
### Docker
|
||||
|
||||
The Dockerfile uses a multi-stage build. Each stage is pinned by
|
||||
@@ -3531,8 +3601,12 @@ linter's version is fixed independently of the compiler's:
|
||||
`css-check` fails when the committed file differs from the generated
|
||||
one, and `make css` writes the generated file out from `css-output`
|
||||
(see [Stylesheet](#stylesheet)).
|
||||
3. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||
and `css-check` stages passing (it copies a file from each), runs
|
||||
3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) —
|
||||
`js-deps` installs ESLint from `yarn.lock` and `js-lint` runs it over
|
||||
`static/js/` (see [Linting](#linting)).
|
||||
4. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint,
|
||||
`css-check` and `js-lint` stages passing (it copies a file from
|
||||
each), runs
|
||||
`make test` and `make build` (both extract Alpine.js from `3p/`
|
||||
first), and finally
|
||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||
@@ -3542,7 +3616,7 @@ linter's version is fixed independently of the compiler's:
|
||||
given, otherwise derived from the `.git` in the context, and the
|
||||
stage fails if a context with `.git` would stamp `unknown` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
4. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
5. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||
directory for all SQLite databases, exposes port 8080, and includes
|
||||
a health check against `/.well-known/healthcheck`. It sets no
|
||||
@@ -3562,8 +3636,9 @@ linked, which is what lets it run on the Alpine runtime image.
|
||||
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
||||
inside the image, so a build that succeeds is a repo that is formatted,
|
||||
linted, tested and compiled, with a current stylesheet. `script/lint`
|
||||
also uses Docker (`Dockerfile.lint`, see Linting above), so `make lint`
|
||||
and `make check` run the same pinned linter version the gate does; of
|
||||
also uses Docker (`Dockerfile.lint` and the `js-lint` stage, see Linting
|
||||
above), so `make lint` and `make check` run the same pinned linter
|
||||
versions the gate does; of
|
||||
the steps `make check` runs, only `script/test` and `script/fmt-check`
|
||||
run on the host.
|
||||
|
||||
@@ -3577,8 +3652,8 @@ the hash of the commit being checked, so every commit, docs-only ones
|
||||
and a squash merge whose tree matches an already-built branch included,
|
||||
gets a new fingerprint, invalidates the `COPY . .` layer of every check
|
||||
stage, and really runs `make fmt-check`, `golangci-lint`, the stylesheet
|
||||
check, `make test`, and `make build`. A run that reports success ran
|
||||
them.
|
||||
check, ESLint, `make test`, and `make build`. A run that reports success
|
||||
ran them.
|
||||
|
||||
The module download layer sits above `COPY . .` and stays cached.
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -56,6 +56,10 @@ type Delivery struct {
|
||||
// the index.
|
||||
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:
|
||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||
// JSON could loop.
|
||||
|
||||
@@ -163,6 +163,17 @@ func (env *archiveEnv) seedArchiveRows(
|
||||
t.Helper()
|
||||
|
||||
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(
|
||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||
@@ -182,7 +193,7 @@ func (env *archiveEnv) seedArchiveRows(
|
||||
for i, at := range archivedAt {
|
||||
row := delivery.ExportArchivedEvent{
|
||||
EventID: fmt.Sprintf("ev-%d", i),
|
||||
WebhookID: tgt.WebhookID,
|
||||
WebhookID: webhookID,
|
||||
Method: http.MethodPost,
|
||||
Body: `{"seeded":true}`,
|
||||
ArchivedAt: at,
|
||||
@@ -191,8 +202,6 @@ func (env *archiveEnv) seedArchiveRows(
|
||||
}
|
||||
|
||||
require.NoError(t, sqlDB.Close())
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// archivedEventIDs returns the event ids currently stored in an
|
||||
|
||||
@@ -295,12 +295,13 @@ func (e *Engine) EvictTarget(targetID string) {
|
||||
e.dbTarget.evict(targetID)
|
||||
}
|
||||
|
||||
// Rename implements Archives. It renames a database target's
|
||||
// archive file to ArchiveFileName(webhookName, targetName,
|
||||
// targetID), under the lock the target's archive writes and the
|
||||
// idle sweep take. It never replaces a file: if one already has the
|
||||
// new name, the error is ErrArchiveNameTaken. The caller renames
|
||||
// before it saves the new name: see databaseTarget.rename.
|
||||
// Rename implements Archives. It renames every one of a database
|
||||
// target's archive files to ArchiveFileName(webhookName, targetName,
|
||||
// targetID), each keeping the period in its name, under the lock the
|
||||
// target's archive writes and the idle sweep take. It never replaces
|
||||
// a file: if one already has a new name, the error is
|
||||
// ErrArchiveNameTaken. The caller renames before it saves the new
|
||||
// name: see databaseTarget.rename.
|
||||
func (e *Engine) Rename(
|
||||
targetID, webhookName, targetName string,
|
||||
) error {
|
||||
|
||||
@@ -501,23 +501,32 @@ func NewExportArchiveWriter(
|
||||
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(
|
||||
row ExportArchivedEvent, expiry time.Duration,
|
||||
) 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.
|
||||
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.
|
||||
func (e *ExportArchiveWriter) Reopen(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.reopen(expiry)
|
||||
return e.w.reopen(e.w.path, expiry)
|
||||
}
|
||||
|
||||
// SetNow replaces the clock the writer measures its reopen
|
||||
@@ -547,7 +556,7 @@ func (e *ExportArchiveWriter) Path() string {
|
||||
func (e *ExportArchiveWriter) OpenExisting(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.openMode(archiveModeExisting, expiry)
|
||||
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
|
||||
}
|
||||
|
||||
// SweepExpired runs an idle sweep of the archive.
|
||||
|
||||
@@ -41,6 +41,8 @@ type TargetConfigForm struct {
|
||||
Timeout string
|
||||
// Expiry is the database (archive) target's row expiry.
|
||||
Expiry string
|
||||
// Rotation is the database (archive) target's rotation.
|
||||
Rotation string
|
||||
}
|
||||
|
||||
// NewTargetConfigForm parses a target's stored configuration into
|
||||
@@ -85,11 +87,13 @@ func NewTargetConfigForm(
|
||||
}
|
||||
}
|
||||
|
||||
// databaseConfigForm parses an archive target's optional expiry.
|
||||
// An absent, empty or never expiry yields an empty expiry, on which
|
||||
// the edit form starts at never; saving it unchanged stores never,
|
||||
// which means the same as an empty expiry. An expiry that is set
|
||||
// but not a valid duration is an error, not a blank field.
|
||||
// databaseConfigForm parses an archive target's optional expiry and
|
||||
// rotation. An absent, empty or never expiry yields an empty expiry,
|
||||
// on which the edit form starts at never; saving it unchanged stores
|
||||
// never, which means the same as an empty expiry. An absent rotation
|
||||
// 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(
|
||||
configJSON string,
|
||||
) (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 {
|
||||
return TargetConfigForm{}, nil
|
||||
return form, nil
|
||||
}
|
||||
|
||||
err = ValidateArchiveExpiry(cfg.Expiry)
|
||||
@@ -115,5 +126,7 @@ func databaseConfigForm(
|
||||
return TargetConfigForm{}, err
|
||||
}
|
||||
|
||||
return TargetConfigForm{Expiry: cfg.Expiry}, nil
|
||||
form.Expiry = cfg.Expiry
|
||||
|
||||
return form, nil
|
||||
}
|
||||
|
||||
@@ -192,8 +192,9 @@ func maxRetriesField(t *database.Target) ConfigField {
|
||||
|
||||
// databaseConfigFields describes an archive target by its
|
||||
// expiry in plain units, such as "30 days", or "never" when
|
||||
// the archive is kept forever. An expiry that is set but not
|
||||
// a valid duration is reported as unavailable rather than
|
||||
// the archive is kept forever, and by its rotation. An expiry
|
||||
// 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.
|
||||
func databaseConfigFields(configJSON string) []ConfigField {
|
||||
expiry, err := parseArchiveExpiry(configJSON)
|
||||
@@ -201,6 +202,11 @@ func databaseConfigFields(configJSON string) []ConfigField {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
rotation, err := parseArchiveRotation(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
value := archiveExpiryNever
|
||||
if expiry > 0 {
|
||||
value = plainDuration(expiry)
|
||||
@@ -209,6 +215,9 @@ func databaseConfigFields(configJSON string) []ConfigField {
|
||||
return []ConfigField{{
|
||||
Label: "Archive Expiry",
|
||||
Value: value,
|
||||
}, {
|
||||
Label: "Archive Rotation",
|
||||
Value: rotation,
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -328,13 +328,49 @@ func TestNewTargetViews_Database(t *testing.T) {
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{"Archive Expiry": tc.want},
|
||||
map[string]string{
|
||||
"Archive Expiry": tc.want,
|
||||
"Archive Rotation": rotationNone,
|
||||
},
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -382,6 +418,10 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"a fortnight"}`,
|
||||
},
|
||||
"invalid archive rotation": {
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: weeklyConfig,
|
||||
},
|
||||
}
|
||||
|
||||
for name, target := range tests {
|
||||
|
||||
@@ -20,7 +20,8 @@ const archiveNameMaxLen = 40
|
||||
// from the per-webhook event database. The event is already
|
||||
// persisted in the per-webhook event DB by the time delivery runs;
|
||||
// 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
|
||||
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
||||
// and expiry semantics.
|
||||
@@ -146,8 +147,9 @@ func (t *databaseTarget) Deliver(
|
||||
}
|
||||
|
||||
// archive writes the full event as a row into the target's
|
||||
// archive database, honouring the optional per-target expiry
|
||||
// parsed from the target config JSON.
|
||||
// archive database, honouring the optional per-target expiry and
|
||||
// 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 {
|
||||
webhookID := d.Event.WebhookID
|
||||
if webhookID == "" {
|
||||
@@ -159,6 +161,19 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -174,7 +189,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
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,
|
||||
@@ -275,10 +290,10 @@ func (t *databaseTarget) releaseSweepWriter(
|
||||
delete(t.writers, targetID)
|
||||
}
|
||||
|
||||
// newWriter builds the writer for a database target's archive. The
|
||||
// file is the one ArchivePath gives for the webhook and the target as
|
||||
// newWriter builds the writer for a database target's archive. Its
|
||||
// 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 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(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
@@ -306,10 +321,10 @@ func (t *databaseTarget) newWriter(
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// rename moves a database target's archive file to the name for
|
||||
// webhookName and targetName. It goes through the target's writer,
|
||||
// so the move holds the lock that writes and the idle sweep take,
|
||||
// and later writes use the new name.
|
||||
// rename moves every one of a database target's archive files to the
|
||||
// name for webhookName and targetName. It goes through the target's
|
||||
// writer, so the move holds the lock that writes and the idle sweep
|
||||
// take, and later writes use the new name.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -85,6 +85,10 @@ type databaseTargetConfig struct {
|
||||
// archived rows are pruned, or "never" (the default) to
|
||||
// keep them forever.
|
||||
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
|
||||
@@ -178,7 +182,7 @@ func ValidateArchiveExpiry(expiry string) error {
|
||||
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
|
||||
// the file (debounced to at most once per debounce window) so
|
||||
// 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
|
||||
// on every open.
|
||||
type archiveWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
mu sync.Mutex
|
||||
|
||||
// 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
|
||||
debounce time.Duration
|
||||
db *gorm.DB
|
||||
@@ -236,12 +248,14 @@ func newArchiveWriter(
|
||||
}
|
||||
}
|
||||
|
||||
// write appends the event as a row, then applies the debounced
|
||||
// close/reopen. 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.
|
||||
// write appends the event as a row to the archive file for period
|
||||
// (see archivePeriodPath), then applies the debounced close/reopen.
|
||||
// When period names a different file from the one open, the open one
|
||||
// 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(
|
||||
row archivedEvent, expiry time.Duration,
|
||||
row archivedEvent, expiry time.Duration, period string,
|
||||
) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -252,8 +266,10 @@ func (w *archiveWriter) write(
|
||||
)
|
||||
}
|
||||
|
||||
if w.db == nil || !fileExists(w.path) {
|
||||
err := w.reopen(expiry)
|
||||
file := archivePeriodPath(w.path, period)
|
||||
|
||||
if w.db == nil || w.current != file || !fileExists(file) {
|
||||
err := w.reopen(file, expiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -264,41 +280,41 @@ func (w *archiveWriter) write(
|
||||
err := w.db.Create(&row).Error
|
||||
if err != nil {
|
||||
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 {
|
||||
return w.reopen(expiry)
|
||||
return w.reopen(file, expiry)
|
||||
}
|
||||
|
||||
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
|
||||
// when expiry is positive.
|
||||
func (w *archiveWriter) open(expiry time.Duration) error {
|
||||
return w.openMode(archiveModeCreate, expiry)
|
||||
func (w *archiveWriter) open(file string, expiry time.Duration) error {
|
||||
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
|
||||
// prunes expired rows when expiry is positive. The write path
|
||||
// passes archiveModeCreate so a missing file is recreated; the
|
||||
// idle sweep passes archiveModeExisting so a missing file is an
|
||||
// error rather than a newly conjured empty archive.
|
||||
func (w *archiveWriter) openMode(
|
||||
mode string, expiry time.Duration,
|
||||
file, mode string, expiry time.Duration,
|
||||
) error {
|
||||
// Opened through database.OpenSQLite so an archive file carries
|
||||
// the same WAL journaling, busy timeout, immediate-transaction
|
||||
// locking, and pool bounds as every other database file. See
|
||||
// internal/database/sqlite_open.go.
|
||||
sqlDB, err := database.OpenSQLite(w.path, mode)
|
||||
sqlDB, err := database.OpenSQLite(file, mode)
|
||||
if err != nil {
|
||||
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(
|
||||
"connecting to archive database %s: %w",
|
||||
w.path, err,
|
||||
file, err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,11 +339,12 @@ func (w *archiveWriter) openMode(
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return fmt.Errorf(
|
||||
"migrating archive database %s: %w", w.path, err,
|
||||
"migrating archive database %s: %w", file, err,
|
||||
)
|
||||
}
|
||||
|
||||
w.db = gdb
|
||||
w.current = file
|
||||
w.lastReopen = w.now()
|
||||
w.reopens++
|
||||
|
||||
@@ -338,12 +355,12 @@ func (w *archiveWriter) openMode(
|
||||
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.
|
||||
func (w *archiveWriter) reopen(expiry time.Duration) error {
|
||||
func (w *archiveWriter) reopen(file string, expiry time.Duration) error {
|
||||
w.close()
|
||||
|
||||
return w.open(expiry)
|
||||
return w.open(file, expiry)
|
||||
}
|
||||
|
||||
// close closes the underlying handle, if any.
|
||||
@@ -360,22 +377,56 @@ func (w *archiveWriter) close() {
|
||||
w.db = nil
|
||||
}
|
||||
|
||||
// sweepExpired prunes an archive that may have gone idle, with
|
||||
// no write to trigger the usual on-reopen prune. It takes the
|
||||
// writer's own mutex for the whole operation, so a sweep is
|
||||
// ordered against concurrent writes rather than reaching around
|
||||
// them to the file.
|
||||
// sweepExpired prunes the target's archive files, which may have
|
||||
// gone idle, with no write to trigger the usual on-reopen prune. It
|
||||
// lists the files under the writer's own mutex, then takes the mutex
|
||||
// again for one file at a time, so a write waits for at most one
|
||||
// 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,
|
||||
// and the reopen uses archiveModeExisting so SQLite itself
|
||||
// refuses to create one if the file disappears between the
|
||||
// check and the open.
|
||||
// It never creates an archive file: it prunes only the files
|
||||
// archiveFiles lists, skips one that is gone by the time it is
|
||||
// reached (moved away, or renamed since the listing), and opens each
|
||||
// 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
|
||||
// no handle is what keeps the operator's move-the-file-away
|
||||
// workflow working; the next write reopens (and recreates) the
|
||||
// file as it always has.
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -393,26 +444,56 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
||||
// freshly opened file, matching the write path's semantics.
|
||||
w.close()
|
||||
|
||||
err := w.openMode(archiveModeExisting, expiry)
|
||||
err := w.openMode(file.path, archiveModeExisting, expiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if file.period == "" {
|
||||
w.close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var rows int64
|
||||
|
||||
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// rename gives the archive file a new name in the same directory,
|
||||
// and the writer uses the file under that name from now on. The
|
||||
// handle is closed first, which folds the -wal into the .db; any
|
||||
// -wal or -shm still beside the file (left by a crash) is moved with
|
||||
// it, because SQLite finds them by name. A missing file is not an
|
||||
// error: the operator may have moved it away, and the next write
|
||||
// creates it under the new name.
|
||||
// rename gives every one of the target's archive files the new
|
||||
// name, keeping the period in the name of each (see
|
||||
// archivePeriodPath), and the writer uses the files under that name
|
||||
// from now on. The handle is closed first, which folds the -wal into
|
||||
// the .db; any -wal or -shm still beside a file (left by a crash) is
|
||||
// moved with it, because SQLite finds them by name. A target with no
|
||||
// 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
|
||||
// error is ErrArchiveNameTaken. If one file fails to move, those
|
||||
// If a file already has one of the new names, nothing is moved and
|
||||
// the error is ErrArchiveNameTaken. If one file fails to move, those
|
||||
// already moved are moved back before the error is returned, so the
|
||||
// archive is never split across two names.
|
||||
func (w *archiveWriter) rename(name string) error {
|
||||
@@ -430,38 +511,53 @@ func (w *archiveWriter) rename(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
suffixes := []string{"", "-wal", "-shm"}
|
||||
files, err := archiveFiles(w.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, suffix := range suffixes {
|
||||
if fileExists(path + suffix) {
|
||||
// from[i] moves to to[i].
|
||||
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(
|
||||
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
||||
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
w.close()
|
||||
|
||||
for i, suffix := range suffixes {
|
||||
err := os.Rename(w.path+suffix, path+suffix)
|
||||
for i := range from {
|
||||
err = os.Rename(from[i], to[i])
|
||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, moved := range suffixes[:i] {
|
||||
backErr := os.Rename(path+moved, w.path+moved)
|
||||
for j := range i {
|
||||
backErr := os.Rename(to[j], from[j])
|
||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
||||
w.log.Error(
|
||||
"failed to move archive file back",
|
||||
"from", path+moved,
|
||||
"to", w.path+moved,
|
||||
"from", to[j],
|
||||
"to", from[j],
|
||||
"error", backErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
w.log.Error(
|
||||
"failed to prune expired archive rows",
|
||||
"path", w.path,
|
||||
"path", w.current,
|
||||
"error", res.Error,
|
||||
)
|
||||
|
||||
@@ -509,48 +605,76 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
||||
if res.RowsAffected > 0 {
|
||||
w.log.Info(
|
||||
"pruned expired archive rows",
|
||||
"path", w.path,
|
||||
"path", w.current,
|
||||
"rows_deleted", res.RowsAffected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||
// file says about it.
|
||||
// files says about them.
|
||||
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
|
||||
|
||||
// Written is when the file or its -wal was last modified, whichever
|
||||
// is later: a write lands in the -wal first.
|
||||
// Written is when a file or a -wal was last modified, whichever is
|
||||
// latest: a write lands in the -wal first.
|
||||
Written time.Time
|
||||
}
|
||||
|
||||
// StatArchive reads the metadata of the archive file at path and of
|
||||
// its -wal, without opening the archive. With no file at path, which is
|
||||
// so before the first write and after the operator moved it away, the
|
||||
// StatArchive reads the metadata of a database target's archive
|
||||
// files, given the path ArchivePath gives it (see archiveFiles), and
|
||||
// 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.
|
||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||
file, err := os.Stat(path)
|
||||
files, err := archiveFiles(path)
|
||||
if err != nil {
|
||||
return ArchiveFileInfo{}, err
|
||||
}
|
||||
|
||||
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
|
||||
var info ArchiveFileInfo
|
||||
|
||||
wal, err := os.Stat(path + "-wal")
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return info, nil
|
||||
for _, file := range files {
|
||||
db, err := os.Stat(file.path)
|
||||
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 {
|
||||
return ArchiveFileInfo{}, err
|
||||
}
|
||||
|
||||
info.Size += wal.Size()
|
||||
|
||||
if wal.ModTime().After(info.Written) {
|
||||
info.Written = wal.ModTime()
|
||||
if info.Files == 0 {
|
||||
return ArchiveFileInfo{}, fmt.Errorf(
|
||||
"no archive file for %s: %w", path, fs.ErrNotExist,
|
||||
)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
|
||||
@@ -6,10 +6,14 @@ import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -50,21 +54,46 @@ func ArchiveExportFileName(
|
||||
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
||||
}
|
||||
|
||||
// ArchiveExport is a database target's archive opened for download.
|
||||
// It reads the file on its own connection, inside one read-only
|
||||
// transaction, so it writes out the archive as it stood when
|
||||
// OpenArchiveExport returned.
|
||||
// ArchiveExport is a database target's archive listed for download. It
|
||||
// opens one of the target's files at a time, only when its rows are
|
||||
// about to be written out, and closes it before it opens the next, so
|
||||
// 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
|
||||
// never blocks a writer: archive writes go on while an export is open,
|
||||
// and the export does not see them. SQLite cannot checkpoint the -wal
|
||||
// past an open snapshot, so the -wal grows until the export is closed.
|
||||
// Each file is read on its own connection inside one read-only
|
||||
// transaction, so its rows are written out as the file stood when it
|
||||
// was opened. Archives are in WAL mode, where a reader works from a
|
||||
// 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 {
|
||||
// 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
|
||||
tx *gorm.DB
|
||||
|
||||
// empty is true when there is nothing to read: no file, or a file
|
||||
// without the archive's table yet.
|
||||
// period is the period in the file's name, "" for none.
|
||||
period string
|
||||
|
||||
// empty is true for a file without the archive's table yet.
|
||||
empty bool
|
||||
}
|
||||
|
||||
@@ -74,26 +103,46 @@ type exportedName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// OpenArchiveExport opens the archive file at path for export and
|
||||
// takes the snapshot the export reads. It never creates the file: with
|
||||
// no file at path, the export has no rows.
|
||||
// NewArchiveExport lists a database target's archive files for export,
|
||||
// given the path ArchivePath gives it (see archiveFiles). It opens none
|
||||
// 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
|
||||
// does not affect the export, which reads the same file under its new
|
||||
// name.
|
||||
//
|
||||
// The transaction lasts as long as ctx does, so ctx must last for the
|
||||
// whole export.
|
||||
func OpenArchiveExport(
|
||||
ctx context.Context, path string, log *slog.Logger,
|
||||
// WriteGzipJSON, called without lock held, finds each file again by its
|
||||
// period under the path currentPath gives, holding lock while it does
|
||||
// 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
|
||||
// away, is skipped. The export never creates a file: with no files, it
|
||||
// has no rows.
|
||||
func NewArchiveExport(
|
||||
path string,
|
||||
lock sync.Locker,
|
||||
currentPath func() (string, error),
|
||||
log *slog.Logger,
|
||||
) (*ArchiveExport, error) {
|
||||
if !fileExists(path) {
|
||||
return &ArchiveExport{empty: true}, nil
|
||||
files, err := archiveFiles(path)
|
||||
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 {
|
||||
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(
|
||||
@@ -106,7 +155,7 @@ func OpenArchiveExport(
|
||||
if err != nil {
|
||||
_ = 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
|
||||
@@ -116,7 +165,9 @@ func OpenArchiveExport(
|
||||
if tx.Error != nil {
|
||||
_ = 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.
|
||||
@@ -127,22 +178,27 @@ func OpenArchiveExport(
|
||||
_ = tx.Rollback()
|
||||
_ = 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:
|
||||
// webhook and target, each an id and a name; exported_at; and
|
||||
// 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
|
||||
// written in base64, with "body_encoding": "base64" beside it.
|
||||
// archived_events, one object per archived row, keyed by column name,
|
||||
// the files in the order archiveFiles lists them. A row from a file
|
||||
// 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
|
||||
// archive nor its JSON is ever held in memory whole. After an error
|
||||
// the gzip stream is left unfinished, so what was written does not
|
||||
// decompress as a whole file.
|
||||
// archive nor its JSON is ever held in memory whole, and each file is
|
||||
// closed once its rows are written, before the next is opened. When it
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
@@ -169,15 +225,35 @@ func (x *ArchiveExport) WriteGzipJSON(
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// Close ends the export's transaction and closes its connection.
|
||||
func (x *ArchiveExport) Close() error {
|
||||
if x.db == nil {
|
||||
return nil
|
||||
// openFile finds the target's archive file for period under the path
|
||||
// currentPath gives now, and opens it for the export, holding x.lock
|
||||
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
|
||||
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,
|
||||
@@ -207,46 +283,77 @@ func (x *ArchiveExport) writeJSON(
|
||||
return err
|
||||
}
|
||||
|
||||
// writeRows writes each archived row to w, oldest first, one per line,
|
||||
// separated by commas.
|
||||
// writeRows writes the archived rows of each file to w, one per line,
|
||||
// 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 {
|
||||
if x.empty {
|
||||
return nil
|
||||
sep := "\n"
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for sep := "\n"; rows.Next(); sep = ",\n" {
|
||||
for ; rows.Next(); sep = ",\n" {
|
||||
var ev archivedEvent
|
||||
|
||||
err = x.tx.ScanRows(rows, &ev)
|
||||
err = f.tx.ScanRows(rows, &ev)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = io.WriteString(w, sep)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = writeRow(w, &ev)
|
||||
err = writeRow(w, &ev, f.period)
|
||||
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
|
||||
// column name, its body in base64 when it is not valid UTF-8.
|
||||
func writeRow(w io.Writer, ev *archivedEvent) error {
|
||||
// column name, its body in base64 when it is not valid UTF-8, with
|
||||
// the period of its file beside them unless that is "".
|
||||
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
||||
row := map[string]any{
|
||||
"id": ev.ID,
|
||||
"event_id": ev.EventID,
|
||||
@@ -264,6 +371,10 @@ func writeRow(w io.Writer, ev *archivedEvent) error {
|
||||
row["body_encoding"] = "base64"
|
||||
}
|
||||
|
||||
if period != "" {
|
||||
row["period"] = period
|
||||
}
|
||||
|
||||
line, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -29,14 +31,8 @@ const (
|
||||
exportTargetName = "Long-term archive"
|
||||
)
|
||||
|
||||
const (
|
||||
// binaryBody is a body that is not valid UTF-8.
|
||||
binaryBody = "\xff\xfe\x00\x01binary\x80"
|
||||
|
||||
// openedEventID is the event the snapshot tests archive before
|
||||
// they open the export.
|
||||
openedEventID = "opened"
|
||||
)
|
||||
// binaryBody is a body that is not valid UTF-8.
|
||||
const binaryBody = "\xff\xfe\x00\x01binary\x80"
|
||||
|
||||
// writeExportTo writes export to w as the archive of the export tests'
|
||||
// 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
|
||||
// its JSON, decompressed and parsed.
|
||||
func exportArchive(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
export, err := delivery.OpenArchiveExport(
|
||||
t.Context(), path, archiveTestLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, export.Close()) }()
|
||||
|
||||
return writeExport(t, export)
|
||||
return writeExport(t, listExport(t, path))
|
||||
}
|
||||
|
||||
// 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
|
||||
// archive as it was when it was opened, and holds up no archive
|
||||
// write: a row written while the export is open is stored, and is not
|
||||
// in the export. A write held up for the whole busy timeout would
|
||||
// fail.
|
||||
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
|
||||
// export unlocks its lock right after it opens a file.
|
||||
type unlockHook struct {
|
||||
sync.Mutex
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive.db")
|
||||
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(
|
||||
t.Context(), path, archiveTestLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
opened := &unlockHook{fn: func() {
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
||||
}}
|
||||
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: "during"}, 0))
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{openedEventID}, exportedEventIDs(t, writeExport(t, export)),
|
||||
[]string{"listed", "before-open"},
|
||||
exportedEventIDs(t, writeExport(t, export)),
|
||||
)
|
||||
|
||||
var stored int64
|
||||
|
||||
require.NoError(t, openArchiveDBForRead(t, path).
|
||||
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
|
||||
// while an export of it is open, as renaming its webhook or target
|
||||
// does, leaves the export reading the same file.
|
||||
func TestArchiveExport_SurvivesRename(t *testing.T) {
|
||||
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
|
||||
// archive after an export has listed it, as renaming its webhook or
|
||||
// target does, loses no file: the export finds each file again by its
|
||||
// period under the new name. A file moved away by then is skipped.
|
||||
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
|
||||
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)
|
||||
|
||||
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(
|
||||
t.Context(), path, archiveTestLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, export.Close()) }()
|
||||
current := path
|
||||
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
||||
return current, nil
|
||||
})
|
||||
|
||||
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,
|
||||
[]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
|
||||
// the largest heap it saw at a write. It collects garbage before each
|
||||
// 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))
|
||||
}
|
||||
|
||||
export, err := delivery.OpenArchiveExport(
|
||||
t.Context(), path, archiveTestLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, export.Close()) }()
|
||||
export := listExport(t, path)
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, got.Files)
|
||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
||||
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.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{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"720h"}`,
|
||||
Config: `{"expiry":"720h","rotation":"daily"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "720h", form.Expiry)
|
||||
assert.Equal(t, rotationDaily, form.Rotation)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeLog,
|
||||
@@ -248,6 +249,10 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"soon"}`,
|
||||
},
|
||||
{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: weeklyConfig,
|
||||
},
|
||||
{Type: database.TargetType("nope")},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ const (
|
||||
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
|
||||
)
|
||||
|
||||
// archiveExpiryChoice is one entry of a database target's archive
|
||||
// expiry select: the expiry stored, the label shown, and whether the
|
||||
// select starts on it.
|
||||
type archiveExpiryChoice struct {
|
||||
// archiveChoice is one entry of a database target's archive expiry
|
||||
// or archive rotation select: the value stored, the label shown, and
|
||||
// whether the select starts on it.
|
||||
type archiveChoice struct {
|
||||
Value string
|
||||
Label string
|
||||
Selected bool
|
||||
@@ -21,8 +21,8 @@ type archiveExpiryChoice struct {
|
||||
|
||||
// archiveExpiryChoices lists the archive expiries offered by the new
|
||||
// webhook page, the add target form and the target edit form.
|
||||
func archiveExpiryChoices() []archiveExpiryChoice {
|
||||
return []archiveExpiryChoice{
|
||||
func archiveExpiryChoices() []archiveChoice {
|
||||
return []archiveChoice{
|
||||
{Value: archiveExpiryNever, Label: archiveExpiryNever},
|
||||
{Value: "1h", Label: "1h"},
|
||||
{Value: "12h", Label: "12h"},
|
||||
@@ -37,7 +37,7 @@ func archiveExpiryChoices() []archiveExpiryChoice {
|
||||
// empty expiry selects never. An expiry that is not one of the
|
||||
// choices comes first as its own selected entry, so saving the form
|
||||
// unchanged keeps it.
|
||||
func archiveExpiryOptions(expiry string) []archiveExpiryChoice {
|
||||
func archiveExpiryOptions(expiry string) []archiveChoice {
|
||||
if expiry == "" {
|
||||
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/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -49,21 +50,39 @@ func expiryShown(
|
||||
}
|
||||
|
||||
// expirySelected returns the target edit page and the expiries its
|
||||
// select starts on.
|
||||
// expiry select starts on.
|
||||
func expirySelected(
|
||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||
) (string, []string) {
|
||||
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(
|
||||
env, http.MethodGet,
|
||||
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"gorm.io/gorm"
|
||||
@@ -283,6 +282,7 @@ func createReplayDelivery(
|
||||
EventID: event.ID,
|
||||
TargetID: target.ID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Replay: true,
|
||||
}
|
||||
|
||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -329,24 +329,15 @@ func replayBody(body string) *string {
|
||||
}
|
||||
|
||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||
// log it was triggered from, carrying the outcome as its notice and
|
||||
// the page number the form submitted.
|
||||
// log it was triggered from, carrying the outcome as its notice.
|
||||
func redirectToEventLog(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code noticeCode,
|
||||
) {
|
||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
// this is a POST, and its query string is what logs and Referer
|
||||
// headers record.
|
||||
if page := pageOrFirst(
|
||||
r.PostFormValue("page"),
|
||||
); page > 1 {
|
||||
dest += "&page=" + strconv.Itoa(page)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
http.Redirect(
|
||||
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers_test
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -528,3 +529,58 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
assert.NotContains(t, unknown, "alert-success")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,11 @@ type DeliveryResultView struct {
|
||||
AttemptNum int
|
||||
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,
|
||||
// which is why the page asks HasStatusCode rather than
|
||||
// printing the number.
|
||||
@@ -160,6 +166,8 @@ func (r *deliveryResultRow) view(
|
||||
return DeliveryResultView{
|
||||
AttemptNum: r.AttemptNum,
|
||||
Success: r.Success,
|
||||
Ran: humanize.Time(r.CreatedAt),
|
||||
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||
StatusCode: r.StatusCode,
|
||||
Error: redactor.Redact(r.Error),
|
||||
DurationMS: r.Duration,
|
||||
|
||||
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
|
||||
}).Error)
|
||||
}
|
||||
|
||||
views := h.LoadEventLogViewsForTest(
|
||||
httptest.NewRecorder(), *wh, 1,
|
||||
)
|
||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||
require.Len(t, views, 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
|
||||
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
||||
|
||||
views := h.LoadEventLogViewsForTest(
|
||||
httptest.NewRecorder(), *wh, 1,
|
||||
)
|
||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||
require.Len(t, views, 1)
|
||||
require.Len(t, views[0].Deliveries, 1)
|
||||
require.Len(t, views[0].Deliveries[0].Results, 1)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// The page shows every request header.
|
||||
views, ok := h.eventLogViews(
|
||||
w, r, webhookDB, webhook.ID, rows, targets,
|
||||
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
|
||||
@@ -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>`)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
@@ -15,16 +16,20 @@ import (
|
||||
// bytes rather than characters, so the cap bounds the page in
|
||||
// bytes whatever the payload's encoding. Cutting in SQLite
|
||||
// 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, " +
|
||||
"resubmitted_from_id, entrypoint_id, headers, " +
|
||||
"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, " +
|
||||
"length(cast(body as blob)) AS body_bytes"
|
||||
|
||||
// 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, " +
|
||||
"resubmitted_from_id, entrypoint_id, headers, " +
|
||||
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||
"cast(body as blob) AS body, " +
|
||||
"length(cast(body as blob)) AS body_bytes"
|
||||
|
||||
@@ -33,20 +38,29 @@ const eventColumns = "id, created_at, method, content_type, " +
|
||||
// DeliveryView and TargetView.
|
||||
type EventLogView struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Method string
|
||||
ContentType string
|
||||
|
||||
// Received is how long ago the event arrived, and ReceivedUTC
|
||||
// the full timestamp.
|
||||
Received string
|
||||
ReceivedUTC string
|
||||
|
||||
Body BodyView
|
||||
|
||||
// Entrypoint names the entrypoint the event arrived at: its
|
||||
// description, "Entrypoint" when it has none, or "deleted
|
||||
// entrypoint". Never its URL, which is the entrypoint's secret.
|
||||
// Entrypoint names the entrypoint the event arrived at, or for a
|
||||
// resubmitted copy, which did not arrive, the one its original
|
||||
// arrived at: its 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, one "Name: value"
|
||||
// per value, sorted by name.
|
||||
Headers []string
|
||||
// per value, sorted by name. HeadersCut reports headers left
|
||||
// out because they hold more than maxRenderedBodyBytes, stored
|
||||
// or as lines; only the event log leaves them out.
|
||||
Headers []string
|
||||
HeadersCut bool
|
||||
|
||||
// ResubmittedFromID names the event this one was copied
|
||||
// from, empty for an event that arrived on the receiver.
|
||||
@@ -68,9 +82,9 @@ func (v EventLogView) ResubmittedFrom() bool {
|
||||
}
|
||||
|
||||
// eventLogRow is one row of the event log projection, or of
|
||||
// eventColumns. In the event log its body column arrives
|
||||
// already cut to the cap by SQLite, with the true size beside
|
||||
// it.
|
||||
// eventColumns. In the event log its headers and body columns
|
||||
// arrive already cut to the cap by SQLite, each with its true
|
||||
// size beside it.
|
||||
type eventLogRow struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
@@ -79,39 +93,50 @@ type eventLogRow struct {
|
||||
ResubmittedFromID *string
|
||||
EntrypointID string
|
||||
Headers string
|
||||
HeadersBytes int64
|
||||
Body []byte
|
||||
BodyBytes int64
|
||||
}
|
||||
|
||||
// view projects a loaded row of the webhook's events for
|
||||
// rendering.
|
||||
func (r *eventLogRow) view(webhookID string) EventLogView {
|
||||
// rendering. It shows the request headers when the row holds them
|
||||
// whole and their lines hold at most maxHeaderBytes.
|
||||
func (r *eventLogRow) view(
|
||||
webhookID string, maxHeaderBytes int,
|
||||
) EventLogView {
|
||||
var from string
|
||||
if r.ResubmittedFromID != nil {
|
||||
from = *r.ResubmittedFromID
|
||||
}
|
||||
|
||||
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
|
||||
|
||||
return EventLogView{
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
Method: r.Method,
|
||||
ContentType: r.ContentType,
|
||||
Received: humanize.Time(r.CreatedAt),
|
||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||
Body: newBodyView(
|
||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||
),
|
||||
Headers: requestHeaderLines(r.Headers),
|
||||
Headers: headers,
|
||||
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
||||
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 show as none.
|
||||
func requestHeaderLines(headersJSON string) []string {
|
||||
// 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 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
|
||||
return nil, true
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(headers))
|
||||
@@ -123,13 +148,22 @@ func requestHeaderLines(headersJSON string) []string {
|
||||
|
||||
var lines []string
|
||||
|
||||
size := 0
|
||||
|
||||
for _, name := range names {
|
||||
for _, value := range headers[name] {
|
||||
lines = append(lines, name+": "+value)
|
||||
line := name + ": " + value
|
||||
|
||||
size += len(line)
|
||||
if size > maxBytes {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
return lines, true
|
||||
}
|
||||
|
||||
// entrypointNames maps each of the webhook's entrypoints to the name
|
||||
|
||||
@@ -75,9 +75,7 @@ func seedAndProject(
|
||||
wh := seedWebhook(t, db)
|
||||
seedEventWithBody(t, dbMgr, wh.ID, body)
|
||||
|
||||
views := h.LoadEventLogViewsForTest(
|
||||
httptest.NewRecorder(), *wh, 1,
|
||||
)
|
||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||
require.Len(t, views, 1)
|
||||
|
||||
return views[0]
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,6 +20,25 @@ func arrivedAt(name string) string {
|
||||
return `Arrived at <span class="text-gray-900">` + name + `</span>`
|
||||
}
|
||||
|
||||
// originalArrivedAt is how a page names the entrypoint a resubmitted
|
||||
// copy's original arrived at.
|
||||
func originalArrivedAt(name string) string {
|
||||
return `Its original arrived at <span class="text-gray-900">` +
|
||||
name + `</span>`
|
||||
}
|
||||
|
||||
// headerLine is how a page shows one request header line.
|
||||
func headerLine(line string) string {
|
||||
return `<div class="whitespace-pre-wrap">` + line + `</div>`
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -38,23 +58,20 @@ func (f *recentEventsFixture) entrypoint(
|
||||
}
|
||||
|
||||
// eventAt records an event that arrived at the entrypoint with the
|
||||
// given request headers, stored as the receiver stores them.
|
||||
// given request headers, stored as JSON as the receiver stores them.
|
||||
func (f *recentEventsFixture) eventAt(
|
||||
t *testing.T,
|
||||
ep *database.Entrypoint,
|
||||
headers http.Header,
|
||||
headersJSON string,
|
||||
receivedAt time.Time,
|
||||
) *database.Event {
|
||||
t.Helper()
|
||||
|
||||
headersJSON, err := json.Marshal(headers)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: f.webhook.ID,
|
||||
EntrypointID: ep.ID,
|
||||
Method: http.MethodPost,
|
||||
Headers: string(headersJSON),
|
||||
Headers: headersJSON,
|
||||
Body: "{}",
|
||||
BodyBytes: 2,
|
||||
ContentType: contentTypeJSON,
|
||||
@@ -71,8 +88,8 @@ func (f *recentEventsFixture) eventAt(
|
||||
// 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 and escaped, and never the
|
||||
// entrypoint's URL.
|
||||
// pages, with the headers sorted by name, escaped and keeping their
|
||||
// whitespace, and never the entrypoint's URL.
|
||||
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -82,24 +99,34 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||
billing := f.entrypoint(t, "Billing sender")
|
||||
unnamed := f.entrypoint(t, "")
|
||||
|
||||
older := f.eventAt(t, billing, http.Header{
|
||||
"X-Shop-Event": {"order.created"},
|
||||
"User-Agent": {"shop/1"},
|
||||
}, time.Now().Add(-time.Minute))
|
||||
newer := f.eventAt(t, unnamed, http.Header{
|
||||
"X-Shop-Event": {"order.paid"},
|
||||
"X-Note": {"<b>hi</b>"},
|
||||
}, time.Now())
|
||||
// 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()
|
||||
|
||||
const (
|
||||
accept = "Accept: */*"
|
||||
userAgent = "User-Agent: shop/1 build\t7"
|
||||
shopEvent = "X-Shop-Event: order.created"
|
||||
)
|
||||
|
||||
assert.Contains(t, page, arrivedAt("Billing sender"))
|
||||
assert.Contains(t, page, "<div>User-Agent: shop/1</div>")
|
||||
assert.Contains(t, page, "<div>X-Shop-Event: order.created</div>")
|
||||
assert.Contains(t, page, headerLine(accept))
|
||||
assert.Contains(t, page, headerLine(userAgent))
|
||||
assert.Contains(t, page, headerLine(shopEvent))
|
||||
assert.Less(t,
|
||||
strings.Index(page, "User-Agent: shop/1"),
|
||||
strings.Index(page, "X-Shop-Event: order.created"),
|
||||
strings.Index(page, accept), strings.Index(page, userAgent),
|
||||
"headers are sorted by name",
|
||||
)
|
||||
assert.Less(t,
|
||||
strings.Index(page, userAgent), strings.Index(page, shopEvent),
|
||||
"headers are sorted by name",
|
||||
)
|
||||
assert.NotContains(t, page, "order.paid")
|
||||
@@ -110,8 +137,8 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||
t.Helper()
|
||||
|
||||
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
||||
assert.Contains(t, page, "<div>X-Shop-Event: order.paid</div>")
|
||||
assert.Contains(t, page, "<div>X-Note: <b>hi</b></div>")
|
||||
assert.Contains(t, page, headerLine("X-Shop-Event: order.paid"))
|
||||
assert.Contains(t, page, headerLine("X-Note: <b>hi</b>"))
|
||||
assert.NotContains(t, page, "<b>hi</b>")
|
||||
assert.NotContains(t, page, "order.created")
|
||||
assert.NotContains(t, page, unnamed.Path)
|
||||
@@ -146,7 +173,7 @@ func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
ep := f.entrypoint(t, "Retired sender")
|
||||
event := f.eventAt(t, ep, nil, time.Now())
|
||||
event := f.eventAt(t, ep, `{}`, time.Now())
|
||||
|
||||
require.NoError(t, f.db.DB().Delete(ep).Error)
|
||||
|
||||
@@ -159,3 +186,91 @@ func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
|
||||
assert.NotContains(t, w.Body.String(), "Retired sender")
|
||||
}
|
||||
|
||||
// TestEventRequest_ResubmittedCopy proves a resubmitted copy says its
|
||||
// original arrived at the entrypoint, in the event log and on its own
|
||||
// page, and never that the copy 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(-time.Minute))
|
||||
copied := f.eventAt(t, ep, `{}`, time.Now())
|
||||
|
||||
require.NoError(t, f.webhookDB.Model(copied).Update(
|
||||
"resubmitted_from_id", original.ID,
|
||||
).Error)
|
||||
|
||||
// The log lists the copy first, and the original's Resubmit form
|
||||
// comes after all of the copy and before the original's
|
||||
// entrypoint.
|
||||
copyPart, originalPart, found := strings.Cut(
|
||||
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||
"/events/"+original.ID+"/resubmit",
|
||||
)
|
||||
require.True(t, found)
|
||||
|
||||
assert.Contains(t, copyPart, originalArrivedAt("Billing sender"))
|
||||
assert.NotContains(t, copyPart, arrivedAt("Billing sender"))
|
||||
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
||||
assert.NotContains(t, originalPart, originalArrivedAt("Billing sender"))
|
||||
|
||||
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, copied.ID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), originalArrivedAt("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(), headerLine(tc.line))
|
||||
assert.NotContains(t, w.Body.String(), "Show the request headers")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,6 @@ const (
|
||||
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
|
||||
// verifications were charged for usernames that do not exist. It
|
||||
// lets a test prove the anti-enumeration path ran without timing
|
||||
@@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte {
|
||||
func (s *Handlers) LoadEventLogViewsForTest(
|
||||
w http.ResponseWriter,
|
||||
webhook database.Webhook,
|
||||
page int,
|
||||
) []EventLogView {
|
||||
views, _, _ := s.loadEventsWithDeliveries(
|
||||
w, newRequestForTest(), webhook, nil, page,
|
||||
w, newRequestForTest(), webhook, nil,
|
||||
)
|
||||
|
||||
return views
|
||||
@@ -167,7 +160,16 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
||||
// buildDatabaseTargetConfig for use in the handlers_test
|
||||
// package.
|
||||
func BuildDatabaseTargetConfigForTest(
|
||||
expiry string,
|
||||
expiry, rotation string,
|
||||
) (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 (
|
||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||
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
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
|
||||
// tmplKeyError is the template data key for an error message.
|
||||
tmplKeyError = "Error"
|
||||
@@ -100,7 +99,9 @@ type Handlers struct {
|
||||
// Interleaved, one could rename an archive between another's
|
||||
// rename and save, leaving the file named for one edit and the
|
||||
// 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
|
||||
|
||||
// dummyVerifications counts the equivalent-cost verifications
|
||||
@@ -167,11 +168,11 @@ func New(
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate(
|
||||
"source_logs.html", "event_request.html", "event_body.html",
|
||||
"delivery_attempts.html",
|
||||
"delivery_row.html", "delivery_attempts.html",
|
||||
),
|
||||
"event_detail.html": parsePageTemplate(
|
||||
"event_detail.html", "event_request.html", "event_body.html",
|
||||
"delivery_attempts.html",
|
||||
"delivery_row.html", "delivery_attempts.html",
|
||||
),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
"error.html": parsePageTemplate("error.html"),
|
||||
|
||||
@@ -477,23 +477,37 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Empty expiry: the keep-forever default, empty config.
|
||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
||||
// Empty expiry and rotation: the keep-forever, one-file default,
|
||||
// empty config.
|
||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
assert.Empty(t, cfg)
|
||||
|
||||
// Explicit never is stored as config.
|
||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||
|
||||
// A positive duration is stored as config.
|
||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
||||
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, errMsg)
|
||||
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(
|
||||
@@ -502,7 +516,7 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
||||
t.Parallel()
|
||||
|
||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
||||
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(
|
||||
@@ -512,3 +526,20 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) {
|
||||
// retention, each with archive on. Nothing is created, and the form
|
||||
// comes back with the reason and every value entered: name,
|
||||
// description, retention, URL, the checked archive box and the pruning
|
||||
// choice.
|
||||
// and rotation choices.
|
||||
func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -152,6 +152,7 @@ func TestHandleSourceCreateSubmit_RefusedFormKeepsEveryValue(
|
||||
form.Set("http_url", tc.httpURL)
|
||||
form.Set("archive", "on")
|
||||
form.Set("archive_expiry", "2160h")
|
||||
form.Set("archive_rotation", "hourly")
|
||||
|
||||
w := submitCreateForm(env, form)
|
||||
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, `x-data="collapsible" data-open`)
|
||||
assert.Contains(t, page, `<option value="2160h" selected>`)
|
||||
assert.Contains(t, page, `<option value="hourly" selected>`)
|
||||
|
||||
assertNothingCreated(t, env.db)
|
||||
})
|
||||
|
||||
@@ -275,3 +275,99 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
||||
`<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"?")
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -153,3 +156,55 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
|
||||
assert.Contains(t, body, tgt.Name)
|
||||
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_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)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -95,6 +96,14 @@ type DeliveryView struct {
|
||||
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
|
||||
@@ -300,9 +309,10 @@ type sourceFormInput struct {
|
||||
// destination.
|
||||
HTTPURL string
|
||||
// Archive asks for a database (archive) target, whose rows expire
|
||||
// after ArchiveExpiry.
|
||||
Archive bool
|
||||
ArchiveExpiry string
|
||||
// after ArchiveExpiry and whose files rotate by ArchiveRotation.
|
||||
Archive bool
|
||||
ArchiveExpiry string
|
||||
ArchiveRotation string
|
||||
}
|
||||
|
||||
// newSourceFormData builds the template data for the webhook creation
|
||||
@@ -319,6 +329,9 @@ func newSourceFormData(
|
||||
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(
|
||||
in.ArchiveExpiry,
|
||||
),
|
||||
tmplKeyArchiveRotationChoices: archiveRotationOptions(
|
||||
in.ArchiveRotation,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,12 +358,13 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||
}
|
||||
|
||||
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"),
|
||||
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) {
|
||||
@@ -421,9 +435,10 @@ func (h *Handlers) newWebhookTargets(
|
||||
|
||||
if in.Archive {
|
||||
requested = append(requested, targetFormInput{
|
||||
Name: "Archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Expiry: in.ArchiveExpiry,
|
||||
Name: "Archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Expiry: in.ArchiveExpiry,
|
||||
Rotation: in.ArchiveRotation,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -612,8 +627,9 @@ func (h *Handlers) renderSourceDetail(
|
||||
|
||||
// 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; putting it in an href or any
|
||||
// other URL context needs it constrained first.
|
||||
// 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
|
||||
@@ -630,9 +646,10 @@ func (h *Handlers) renderSourceDetail(
|
||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||
tmplKeyTargetForm: targetForm,
|
||||
"TargetError": targetErr,
|
||||
// The add target form's select starts on its expiry
|
||||
// through Alpine, so no choice is selected here.
|
||||
tmplKeyArchiveExpiryChoices: archiveExpiryChoices(),
|
||||
// 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
|
||||
@@ -1112,30 +1129,17 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
page := h.parsePage(r)
|
||||
|
||||
evts, total, ok := h.loadEventsWithDeliveries(
|
||||
w, r, webhook, targets, page,
|
||||
w, r, webhook, targets,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int(total) / paginationPerPage
|
||||
if int(total)%paginationPerPage != 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Events": evts,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"TotalEvents": total,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < totalPages,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_logs.html", data)
|
||||
@@ -1195,15 +1199,11 @@ func (h *Handlers) loadTargetMap(
|
||||
return targetMap, nil
|
||||
}
|
||||
|
||||
// parsePage extracts a page number from the query string.
|
||||
func (h *Handlers) parsePage(r *http.Request) int {
|
||||
return pageOrFirst(r.URL.Query().Get("page"))
|
||||
}
|
||||
|
||||
// loadEventsWithDeliveries loads paginated events and their
|
||||
// deliveries from the per-webhook database. Events come back
|
||||
// as capped projections rather than database.Event rows: see
|
||||
// eventLogColumns for why the cut happens in SQL.
|
||||
// loadEventsWithDeliveries loads the recentEventLimit newest events
|
||||
// and their deliveries from the per-webhook database, and the total
|
||||
// number of events stored. 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
|
||||
@@ -1213,7 +1213,6 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
targetMap map[string]eventLogTarget,
|
||||
page int,
|
||||
) ([]EventLogView, int64, bool) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
return nil, 0, true
|
||||
@@ -1228,12 +1227,11 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
rows, totalEvents := loadEventLogRows(
|
||||
webhookDB, webhook.ID, page,
|
||||
)
|
||||
rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
|
||||
|
||||
result, ok := h.eventLogViews(
|
||||
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||
maxRenderedBodyBytes,
|
||||
)
|
||||
|
||||
return result, totalEvents, ok
|
||||
@@ -1241,7 +1239,8 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
|
||||
// eventLogViews projects loaded events for rendering, each with
|
||||
// its deliveries, how many times it has been resubmitted and the
|
||||
// entrypoint it arrived at. Like
|
||||
// entrypoint it arrived at, and with its request headers only when
|
||||
// their lines hold at most maxHeaderBytes. Like
|
||||
// loadEventsWithDeliveries, it reports false once it has answered
|
||||
// the request with an error.
|
||||
func (h *Handlers) eventLogViews(
|
||||
@@ -1251,6 +1250,7 @@ func (h *Handlers) eventLogViews(
|
||||
webhookID string,
|
||||
rows []eventLogRow,
|
||||
targetMap map[string]eventLogTarget,
|
||||
maxHeaderBytes int,
|
||||
) ([]EventLogView, bool) {
|
||||
result := make([]EventLogView, len(rows))
|
||||
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||
@@ -1260,7 +1260,7 @@ func (h *Handlers) eventLogViews(
|
||||
eventIDs := make([]string, len(rows))
|
||||
|
||||
for i := range rows {
|
||||
result[i] = rows[i].view(webhookID)
|
||||
result[i] = rows[i].view(webhookID, maxHeaderBytes)
|
||||
eventIDs[i] = rows[i].ID
|
||||
|
||||
webhookDB.Where(
|
||||
@@ -1318,10 +1318,11 @@ func (h *Handlers) eventLogViews(
|
||||
return result, true
|
||||
}
|
||||
|
||||
// loadEventLogRows reads one page of the event log projection, newest
|
||||
// first, and the total number of events the pager counts against.
|
||||
// loadEventLogRows reads the event log projection of the
|
||||
// recentEventLimit newest events, newest first, and the total number
|
||||
// of events stored.
|
||||
func loadEventLogRows(
|
||||
webhookDB *gorm.DB, webhookID string, page int,
|
||||
webhookDB *gorm.DB, webhookID string,
|
||||
) ([]eventLogRow, int64) {
|
||||
var totalEvents int64
|
||||
|
||||
@@ -1332,12 +1333,10 @@ func loadEventLogRows(
|
||||
var rows []eventLogRow
|
||||
|
||||
webhookDB.Model(&database.Event{}).Select(
|
||||
eventLogColumns, maxRenderedBodyBytes,
|
||||
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
|
||||
).Where(
|
||||
"webhook_id = ?", webhookID,
|
||||
).Order("created_at DESC").Offset(
|
||||
(page - 1) * paginationPerPage,
|
||||
).Limit(paginationPerPage).Find(&rows)
|
||||
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
|
||||
|
||||
return rows, totalEvents
|
||||
}
|
||||
@@ -1346,9 +1345,9 @@ func loadEventLogRows(
|
||||
// events have been resubmitted from it.
|
||||
//
|
||||
// One grouped query covers the page rather than one query per event.
|
||||
// A page holds paginationPerPage ids, far below SQLite's bound
|
||||
// parameter ceiling, so it needs no chunking as the delivery result
|
||||
// load does.
|
||||
// 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) {
|
||||
@@ -1445,6 +1444,7 @@ func (h *Handlers) newDeliveryViews(
|
||||
for i := range deliveries {
|
||||
target := targetMap[deliveries[i].TargetID]
|
||||
rows := attempts[deliveries[i].ID]
|
||||
created := deliveries[i].CreatedAt
|
||||
|
||||
results, omitted := renderedAttempts(
|
||||
rows, target.Redactor,
|
||||
@@ -1454,6 +1454,9 @@ func (h *Handlers) newDeliveryViews(
|
||||
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,
|
||||
@@ -1774,24 +1777,6 @@ func (h *Handlers) setTargetFromForm(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// pageOrFirst parses a paginated page number, answering 1 for
|
||||
// anything empty, unparseable or out of range.
|
||||
//
|
||||
// Falling back rather than rejecting is correct here and only here:
|
||||
// a page number is where to send the browser next, not configuration
|
||||
// the operator is storing, and the actions that submit one have
|
||||
// already completed by the time it is read — answering 400 would
|
||||
// report a failure that did not happen. Anything an operator SETS
|
||||
// must be validated instead; see parseMaxRetries.
|
||||
func pageOrFirst(s string) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || v < 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1815,6 +1800,8 @@ type targetFormInput struct {
|
||||
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
|
||||
@@ -1838,6 +1825,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput {
|
||||
Timeout: r.PostFormValue("timeout"),
|
||||
MaxRetries: r.PostFormValue("max_retries"),
|
||||
Expiry: r.PostFormValue("expiry"),
|
||||
Rotation: r.PostFormValue("rotation"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1858,7 +1846,7 @@ func (h *Handlers) buildTargetConfig(
|
||||
case database.TargetTypeSlack:
|
||||
return h.buildSlackTargetConfig(ctx, in.URL)
|
||||
case database.TargetTypeDatabase:
|
||||
return buildDatabaseTargetConfig(in.Expiry)
|
||||
return buildDatabaseTargetConfig(in.Expiry, in.Rotation)
|
||||
case database.TargetTypeLog:
|
||||
return "", "", nil
|
||||
default:
|
||||
@@ -1979,22 +1967,41 @@ func marshalTargetConfig(cfg any) (string, error) {
|
||||
}
|
||||
|
||||
// buildDatabaseTargetConfig builds config JSON for a database
|
||||
// (archive) target. The optional expiry is validated here, at
|
||||
// creation time, so an unparseable value is refused instead of
|
||||
// failing every subsequent delivery. An empty expiry yields an
|
||||
// empty config (the keep-forever default).
|
||||
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
|
||||
// (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)
|
||||
if expiry == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
err := delivery.ValidateArchiveExpiry(expiry)
|
||||
if err != nil {
|
||||
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
|
||||
}
|
||||
|
||||
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -27,13 +27,11 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
webhook, target, export, ok := h.openTargetArchive(ctx, w, r)
|
||||
webhook, target, export, ok := h.listTargetArchive(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = export.Close() }()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
@@ -81,16 +79,15 @@ func (d downloadWriter) Write(b []byte) (int, error) {
|
||||
return d.w.Write(b)
|
||||
}
|
||||
|
||||
// openTargetArchive opens the archive of the request's database target
|
||||
// for export, with its reads under ctx. It reports false once it has
|
||||
// written the response.
|
||||
// listTargetArchive lists the archive files of the request's database
|
||||
// target for export. It reports false once it has written the response.
|
||||
//
|
||||
// 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
|
||||
// the one those names give. It lets go before the export is streamed:
|
||||
// once the file is open, a rename does not affect the export.
|
||||
func (h *Handlers) openTargetArchive(
|
||||
ctx context.Context,
|
||||
// reads the stored names and lists the files, so the files it lists
|
||||
// are the ones those names give. It lets go before the export is
|
||||
// streamed, which takes renameMu again for each file only while it
|
||||
// finds the file under the names stored then and opens it.
|
||||
func (h *Handlers) listTargetArchive(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) {
|
||||
@@ -108,14 +105,43 @@ func (h *Handlers) openTargetArchive(
|
||||
return database.Webhook{}, nil, nil, false
|
||||
}
|
||||
|
||||
export, err := delivery.OpenArchiveExport(
|
||||
ctx, delivery.ArchivePath(h.dbMgr, &webhook, target), h.log,
|
||||
export, err := delivery.NewArchiveExport(
|
||||
delivery.ArchivePath(h.dbMgr, &webhook, target),
|
||||
&h.renameMu,
|
||||
func() (string, error) {
|
||||
return h.storedArchivePath(webhook.ID, target.ID)
|
||||
},
|
||||
h.log,
|
||||
)
|
||||
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 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/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -85,7 +87,7 @@ func TestHandleTargetDownload(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// named for the target's new name.
|
||||
func TestHandleTargetDownload_WaitsForRename(t *testing.T) {
|
||||
@@ -148,18 +150,17 @@ func (s *stalledWriter) Write(b []byte) (int, error) {
|
||||
return s.ResponseRecorder.Write(b)
|
||||
}
|
||||
|
||||
// TestHandleTargetDownload_StreamsWithoutTheLock proves a download
|
||||
// lets go of the rename lock once its archive is open: 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)
|
||||
// startStalledDownload starts a download of the target and returns once
|
||||
// it is stalled at its first write, which comes before it opens any
|
||||
// archive file. Closing the writer's resume lets it go on; the returned
|
||||
// channel is closed when it has finished.
|
||||
func startStalledDownload(
|
||||
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||
) (*stalledWriter, <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
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 {
|
||||
req.AddCookie(c)
|
||||
@@ -179,6 +180,21 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
||||
|
||||
<-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)
|
||||
|
||||
go func() {
|
||||
@@ -197,6 +213,62 @@ func TestHandleTargetDownload_StreamsWithoutTheLock(t *testing.T) {
|
||||
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
|
||||
// body of bodySize random bytes, which do not compress. Its table has
|
||||
// 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,
|
||||
MaxRetries: strconv.Itoa(target.MaxRetries),
|
||||
Expiry: cfg.Expiry,
|
||||
Rotation: cfg.Rotation,
|
||||
}
|
||||
|
||||
h.renderTargetEdit(
|
||||
@@ -242,6 +243,9 @@ func (h *Handlers) renderTargetEdit(
|
||||
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
||||
tmplKeyError: errMsg,
|
||||
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
|
||||
tmplKeyArchiveRotationChoices: archiveRotationOptions(
|
||||
form.Rotation,
|
||||
),
|
||||
}
|
||||
|
||||
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
|
||||
|
||||
@@ -591,7 +591,8 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
||||
"Invalid max retries",
|
||||
},
|
||||
{
|
||||
database.TargetTypeDatabase, "name=edited&expiry=7d",
|
||||
database.TargetTypeDatabase,
|
||||
"name=edited&expiry=7d&rotation=daily",
|
||||
"Invalid archive expiry",
|
||||
},
|
||||
{database.TargetTypeLog, "name=", "Name is required"},
|
||||
@@ -613,15 +614,15 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
||||
page := w.Body.String()
|
||||
assert.Contains(t, page, `class="alert-error">`+tc.reason)
|
||||
|
||||
// headers is the form's one textarea and expiry its one
|
||||
// select; every other field is an input.
|
||||
// headers is the form's one textarea, and expiry and
|
||||
// rotation its selects; every other field is an input.
|
||||
for field := range form {
|
||||
shown := `name="` + field + `" value="` + form.Get(field) + `"`
|
||||
|
||||
switch field {
|
||||
case "headers":
|
||||
shown = ">" + form.Get(field) + "</textarea>"
|
||||
case "expiry":
|
||||
case "expiry", "rotation":
|
||||
shown = `<option value="` + form.Get(field) + `" selected>`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -21,8 +22,8 @@ type TargetRowView struct {
|
||||
// and is nil when the webhook's event database could not be read.
|
||||
Deliveries *TargetDeliveries
|
||||
|
||||
// Archive is a database target's archive file, and nil for a target
|
||||
// of any other type.
|
||||
// Archive is a database target's archive files, and nil for a
|
||||
// target of any other type.
|
||||
Archive *ArchiveFileView
|
||||
|
||||
// Paused is set while the target's circuit breaker is turning its
|
||||
@@ -88,8 +89,7 @@ func (h *Handlers) deliveryPausedView(
|
||||
}
|
||||
|
||||
// newPausedView is a PausedView of deliveries paused until the given
|
||||
// time. A time not on the current UTC day is written with its date, as
|
||||
// the event log writes its times.
|
||||
// time. A time not on the current UTC day is written with its date.
|
||||
func newPausedView(until time.Time) *PausedView {
|
||||
until = until.UTC()
|
||||
|
||||
@@ -117,17 +117,23 @@ type TargetDeliveries struct {
|
||||
}
|
||||
|
||||
// ArchiveFileView is what a database target's row shows about its
|
||||
// archive file.
|
||||
// archive files.
|
||||
type ArchiveFileView struct {
|
||||
// Name is the name of the file an event received now goes to.
|
||||
Name string
|
||||
|
||||
// Note stands in for the size and the last write when there are
|
||||
// none to show, and is empty when there are.
|
||||
// Note says that file does not exist yet, or that the files could
|
||||
// not be read, and is empty otherwise.
|
||||
Note string
|
||||
|
||||
// Size is the size on disk. Written is how long ago the file was
|
||||
// last written, and WrittenUTC the full time the page shows on
|
||||
// hover.
|
||||
// Files counts the target's archive files, and is 0 when there are
|
||||
// none, or they could not be read, and so no size or last write to
|
||||
// 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
|
||||
Written string
|
||||
WrittenUTC string
|
||||
@@ -140,6 +146,7 @@ func (h *Handlers) targetRows(
|
||||
) []TargetRowView {
|
||||
views := delivery.NewTargetViews(targets)
|
||||
rows := make([]TargetRowView, len(views))
|
||||
now := time.Now()
|
||||
|
||||
deliveries, err := h.loadTargetDeliveries(webhook.ID)
|
||||
if err != nil {
|
||||
@@ -160,7 +167,7 @@ func (h *Handlers) targetRows(
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -227,34 +234,57 @@ func readTargetDeliveries(
|
||||
return byTarget, nil
|
||||
}
|
||||
|
||||
// archiveFileView describes a database target's archive file from the
|
||||
// file's metadata alone; the archive is never opened. The file is found
|
||||
// by the name the archive writer uses, so it follows a rename of the
|
||||
// webhook or the target.
|
||||
// archiveFileView describes a database target's archive files from
|
||||
// their metadata alone; the archive is never opened. It names the file
|
||||
// an event received at now goes to. The files are found by the name
|
||||
// the archive writer uses, so they follow a rename of the webhook or
|
||||
// the target.
|
||||
func (h *Handlers) archiveFileView(
|
||||
webhook *database.Webhook, target *database.Target,
|
||||
webhook *database.Webhook, target *database.Target, now time.Time,
|
||||
) *ArchiveFileView {
|
||||
path := delivery.ArchivePath(h.dbMgr, webhook, target)
|
||||
view := &ArchiveFileView{Name: filepath.Base(path)}
|
||||
current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now)
|
||||
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 {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
view.Note = "not created yet"
|
||||
// No files: no size or last write to show.
|
||||
case err != nil:
|
||||
h.log.Error(
|
||||
"failed to read archive file metadata",
|
||||
"target_id", target.ID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
view.Note = "could not be read"
|
||||
return h.archiveUnreadable(view, target, err)
|
||||
default:
|
||||
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
|
||||
view.Written = humanize.Time(file.Written)
|
||||
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
|
||||
view.Files = files.Files
|
||||
view.Size = humanize.Bytes(uint64(files.Size)) //nolint:gosec // never negative
|
||||
view.Written = humanize.Time(files.Written)
|
||||
view.WrittenUTC = files.Written.UTC().Format(time.DateTime) + " UTC"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
|
||||
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
|
||||
"Deliveries Paused: held while one delivery tests whether the "+
|
||||
"target has recovered")
|
||||
assert.NotContains(t, list, "UTC")
|
||||
// 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)
|
||||
|
||||
@@ -119,6 +120,15 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
|
||||
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.
|
||||
|
||||
@@ -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 "))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
const (
|
||||
dataKeyWebhook = "Webhook"
|
||||
dataKeyError = "Error"
|
||||
dataKeyEvents = "Events"
|
||||
)
|
||||
|
||||
// testWebhookID is the identifier given to the webhook under test on
|
||||
@@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// A pointer, as in the handlers: source_detail.html calls
|
||||
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
||||
// over their lists, and a list left out renders as empty, so the
|
||||
// lists are left out.
|
||||
// Webhook.RetentionLabel, a pointer method. The webhook page only
|
||||
// ranges over its lists, and a list left out renders as empty, so
|
||||
// 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.ID = testWebhookID
|
||||
|
||||
@@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||
|
||||
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
dataKeyEvents: []handlers.EventLogView{},
|
||||
"TotalEvents": int64(0),
|
||||
})
|
||||
|
||||
@@ -317,9 +320,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
||||
"Entrypoints": handlers.NewEntrypointViews(
|
||||
[]database.Entrypoint{entrypoint},
|
||||
),
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
"Events": []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
dataKeyEvents: []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
})
|
||||
|
||||
assert.Contains(
|
||||
@@ -384,9 +387,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||
"Entrypoints": handlers.NewEntrypointViews(
|
||||
[]database.Entrypoint{entrypoint},
|
||||
),
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
"Events": []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
dataKeyEvents: []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -278,12 +278,13 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
// after the '?'. Keeping the path and dropping the query is what makes
|
||||
// this branch as bounded as the pattern branches below.
|
||||
//
|
||||
// Nothing debuggable is lost. One route in the service reads a query
|
||||
// parameter at all — `page`, on the authenticated pagination links in
|
||||
// internal/handlers/source_management.go — and the alternatives that
|
||||
// would preserve more (a key count, a key allowlist) all require
|
||||
// parsing an attacker-sized query on every request, which is work an
|
||||
// unauthenticated client would then be choosing for us.
|
||||
// Nothing debuggable is lost. The only query parameters the service
|
||||
// reads are the login page's `next`, the page to return to, and
|
||||
// `notice`, which names the line a page shows after an action. The
|
||||
// alternatives that would preserve more (a key count, a key
|
||||
// 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 {
|
||||
path := r.URL.EscapedPath()
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||
// this order. A new check is one more line here.
|
||||
checkAddEntrypoint(ctx, t, page)
|
||||
checkAddEachTargetType(ctx, t, page)
|
||||
checkArchiveExpiry(ctx, t, page)
|
||||
checkArchiveChoices(ctx, t, page)
|
||||
checkRefusedTarget(ctx, t, page)
|
||||
checkTargetDeliveries(ctx, t, page, target.Name,
|
||||
"0 in total, 0 in the last 24 hours",
|
||||
@@ -334,8 +334,8 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
||||
map[string]string{"url": publicTargetURL},
|
||||
},
|
||||
{
|
||||
"database", "csrf_token name type expiry",
|
||||
map[string]string{"expiry": "720h"},
|
||||
"database", "csrf_token name type expiry rotation",
|
||||
map[string]string{"expiry": "720h", "rotation": "daily"},
|
||||
},
|
||||
{"log", "csrf_token name type", nil},
|
||||
}
|
||||
@@ -435,14 +435,18 @@ func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
||||
"%s: Add still shows while the form is open", targetType)
|
||||
}
|
||||
|
||||
// checkArchiveExpiry loads a webhook page and checks that the add
|
||||
// target form's archive expiry starts on never, that the database
|
||||
// target checkAddTarget added with 720h is listed as 30 days, and that
|
||||
// its edit form starts on 720h.
|
||||
func checkArchiveExpiry(ctx context.Context, t *testing.T, url string) {
|
||||
// checkArchiveChoices loads a webhook page and checks that the add
|
||||
// target form's archive expiry starts on never and its archive
|
||||
// rotation on none, that the database target checkAddTarget added with
|
||||
// 720h and daily is listed as 30 days and daily, and that its edit
|
||||
// form starts on 720h and daily.
|
||||
func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
|
||||
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]`
|
||||
|
||||
@@ -450,26 +454,36 @@ func checkArchiveExpiry(ctx context.Context, t *testing.T, url string) {
|
||||
|
||||
chooseTargetType(ctx, t, "database")
|
||||
|
||||
var start, edited string
|
||||
var startExpiry, startRotation, editedExpiry, editedRotation string
|
||||
|
||||
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")
|
||||
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:"]`+
|
||||
`/following-sibling::span[text()="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"]`)
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx,
|
||||
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")
|
||||
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
|
||||
@@ -765,14 +779,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
||||
"the event's own page does not show its body")
|
||||
}
|
||||
|
||||
// checkEventLog loads the event log and checks an event's row. Clicking
|
||||
// its ID expands the event, and in there clicking its delivery shows the
|
||||
// delivery's attempts and clicking again hides them. Clicking the row's
|
||||
// caret collapses the event, clicking it again expands it, and clicking
|
||||
// the ID again collapses it. While the event is expanded the row says so
|
||||
// and its caret is turned up, and while it is collapsed neither. It then
|
||||
// runs checkEventSelection on the log's last event, lastEventID, and
|
||||
// checkEventKeyboard on eventID.
|
||||
// checkEventLog loads the event log and checks that of its events only
|
||||
// the newest, eventID, starts expanded: its row says so and its caret is
|
||||
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
||||
// the newest event, clicking its delivery shows the delivery's attempts
|
||||
// and clicking again hides them. Clicking the row's caret collapses the
|
||||
// event, clicking it again expands it, clicking its ID collapses it and
|
||||
// clicking the ID again expands it. While the event is collapsed the row
|
||||
// says so and its caret is turned down. It then runs checkEventSelection
|
||||
// on lastEventID and checkEventKeyboard on eventID.
|
||||
func checkEventLog(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
@@ -790,20 +805,19 @@ func checkEventLog(
|
||||
caretUp := caret + `[contains(@class, "rotate-180")]`
|
||||
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
|
||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||
attempt := `//span[text()="Attempt 1"]`
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||
|
||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||
|
||||
click(ctx, t, id)
|
||||
assert.True(t, shown(ctx, expanded),
|
||||
"clicking the event's ID does not expand it")
|
||||
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
|
||||
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
||||
"the expanded event's row does not say it is expanded")
|
||||
assert.True(t, shown(ctx, caretUp),
|
||||
"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")
|
||||
|
||||
@@ -829,7 +843,11 @@ func checkEventLog(
|
||||
|
||||
click(ctx, t, id)
|
||||
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)
|
||||
checkEventKeyboard(ctx, t, url, eventID)
|
||||
@@ -878,8 +896,11 @@ func checkEventSelection(
|
||||
`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(
|
||||
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
||||
chromedp.ScrollIntoView(id, chromedp.BySearch),
|
||||
))
|
||||
|
||||
selectText(ctx, t, id)
|
||||
@@ -922,8 +943,9 @@ func checkEventSelection(
|
||||
}
|
||||
|
||||
// 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
|
||||
// Enter then expands the event and Space collapses it.
|
||||
// page's Back link reaches the row of the newest event, the first after
|
||||
// it, and that Enter then collapses that event, which starts expanded,
|
||||
// and Space expands it again.
|
||||
func checkEventKeyboard(
|
||||
ctx context.Context, t *testing.T, url, eventID string,
|
||||
) {
|
||||
@@ -945,10 +967,10 @@ func checkEventKeyboard(
|
||||
"Tab from the Back link does not reach the event's row")
|
||||
|
||||
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(" ")))
|
||||
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
|
||||
@@ -1145,7 +1167,9 @@ func checkNewWebhookTargets(
|
||||
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)
|
||||
@@ -1186,8 +1210,8 @@ func targetConfigs(
|
||||
// checkRefusedNewWebhook submits the new webhook page with archive
|
||||
// checked and an HTTP target URL the server refuses, a loopback
|
||||
// destination, and checks that the page comes back with the reason and
|
||||
// every value entered, archive still checked and its pruning choice
|
||||
// showing.
|
||||
// every value entered, archive still checked and its pruning and
|
||||
// rotation choices showing.
|
||||
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1204,17 +1228,19 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||
click(ctx, t, archiveBox)
|
||||
require.True(t, shown(ctx, pruningChoice),
|
||||
"checking archive does not show the pruning choice")
|
||||
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||
pruningChoice, "2160h", chromedp.BySearch,
|
||||
)))
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx,
|
||||
chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch),
|
||||
chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery),
|
||||
))
|
||||
click(ctx, t, createButton)
|
||||
|
||||
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
||||
"a refused webhook does not show the reason")
|
||||
|
||||
var (
|
||||
name, description, retention, typed, expiry string
|
||||
checked bool
|
||||
name, description, retention, typed, expiry, rotation string
|
||||
checked bool
|
||||
)
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
@@ -1224,6 +1250,7 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
||||
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
||||
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
||||
chromedp.Value("#archive_rotation", &rotation, chromedp.ByQuery),
|
||||
chromedp.Evaluate(archiveIsOn, &checked),
|
||||
))
|
||||
|
||||
@@ -1234,6 +1261,7 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
||||
assert.True(t, checked, "archive is no longer checked")
|
||||
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
||||
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
|
||||
|
||||
@@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
|
||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||
// 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"
|
||||
// link beside the recent events, and the event log's page links. Each
|
||||
// must point where it should, and that page must render.
|
||||
// Cancel links, the list's link to a webhook, and the "Full Event Log"
|
||||
// link beside the recent events. Each must point where it should, and
|
||||
// that page must render.
|
||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1392,12 +1392,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
wh := env.seedWebhook(t, userID)
|
||||
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"
|
||||
newForm := list + "/new"
|
||||
page := "/hook/" + wh.ID
|
||||
@@ -1429,8 +1423,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
{targetEdit, back, page},
|
||||
{targetEdit, cancel, 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)
|
||||
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
|
||||
// content metadata rather than client-chosen payload. Referer is kept
|
||||
// on the reasoning that it is browser-set, that this service emits
|
||||
// only ?page= in its own links, and that Referrer-Policy is set to
|
||||
// on the reasoning that it is browser-set, that the only query
|
||||
// parameters in this service's own URLs are the login page's `next`
|
||||
// and `notice`, and that Referrer-Policy is set to
|
||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||
// local access log line, which holds the rest of the detail.
|
||||
func sentryKeepsHeader(name string) bool {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"eslint": "10.11.0"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
# this repo. Idempotent: every install is guarded by a check so already
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||
# only in docker, via script/lint and Dockerfile.lint.
|
||||
# make, or go). golangci-lint, node and ESLint are deliberately not
|
||||
# installed: linting runs only in docker, via script/lint.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
+18
-2
@@ -1,6 +1,8 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter. golangci-lint is never installed locally: it
|
||||
# runs via docker only, one way, everywhere — script/lint builds
|
||||
# script/lint: run the linters, golangci-lint over the Go code and then
|
||||
# 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
|
||||
# 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
|
||||
@@ -50,6 +52,20 @@ main() {
|
||||
echo " still matches a stage in Dockerfile.lint." >&2
|
||||
exit 1
|
||||
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 "$@"
|
||||
|
||||
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,
|
||||
// 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
|
||||
// archive pruning choice. It starts hidden, or shown when its
|
||||
// element has the data-open attribute.
|
||||
// archive pruning and rotation choices. It starts hidden, or shown
|
||||
// when its element has the data-open attribute.
|
||||
window.Alpine.data("collapsible", function () {
|
||||
return {
|
||||
open: false,
|
||||
@@ -140,6 +140,7 @@ document.addEventListener("alpine:init", function () {
|
||||
timeout: "",
|
||||
maxRetries: "",
|
||||
expiry: "",
|
||||
rotation: "",
|
||||
init() {
|
||||
const refused = this.$root.dataset;
|
||||
|
||||
@@ -151,6 +152,7 @@ document.addEventListener("alpine:init", function () {
|
||||
this.timeout = refused.timeout;
|
||||
this.maxRetries = refused.maxRetries;
|
||||
this.expiry = refused.expiry;
|
||||
this.rotation = refused.rotation;
|
||||
},
|
||||
add() {
|
||||
this.choosing = true;
|
||||
@@ -169,6 +171,7 @@ document.addEventListener("alpine:init", function () {
|
||||
this.timeout = "";
|
||||
this.maxRetries = "";
|
||||
this.expiry = "";
|
||||
this.rotation = "";
|
||||
this.$refs.form.reset();
|
||||
},
|
||||
get filling() {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="rounded-md bg-white border border-gray-200 p-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="text-gray-500">Attempt {{.AttemptNum}}</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")}}
|
||||
|
||||
@@ -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">{{.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 class="flex flex-wrap gap-2">
|
||||
<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 class="flex flex-wrap gap-2">
|
||||
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
|
||||
@@ -75,13 +75,7 @@
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Deliveries}}
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<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}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
|
||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||
</span>
|
||||
</div>
|
||||
{{template "delivery_row" .}}
|
||||
<div class="mt-2 space-y-2">
|
||||
{{template "delivery_attempts" .}}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
{{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. -->
|
||||
the event's own page. The entrypoint's URL is never shown. A
|
||||
resubmitted copy did not arrive at an entrypoint; its original
|
||||
did. -->
|
||||
<div class="space-y-2 text-xs">
|
||||
{{if .ResubmittedFrom}}
|
||||
<p class="text-gray-500">Its original arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||
{{else}}
|
||||
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||
{{if .Headers}}
|
||||
{{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>
|
||||
<div class="rounded-md border border-gray-200 bg-white p-2 font-mono text-gray-700 break-all">
|
||||
{{range .Headers}}<div>{{.}}</div>{{end}}
|
||||
{{range .Headers}}<div class="whitespace-pre-wrap">{{.}}</div>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500">No request headers.</p>
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
<div class="flex gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</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}}">
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
</form>
|
||||
@@ -83,7 +87,7 @@
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</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}}">
|
||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||
</form>
|
||||
@@ -129,7 +133,8 @@
|
||||
data-headers="{{.TargetForm.Headers}}"
|
||||
data-timeout="{{.TargetForm.Timeout}}"
|
||||
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">
|
||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||
<button type="button" @click="add" x-show="closed" class="btn-small">
|
||||
@@ -201,17 +206,30 @@
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="isDatabase">
|
||||
<div>
|
||||
<div class="space-y-3">
|
||||
<input type="hidden" name="type" value="database">
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Archive expiry:</label>
|
||||
<select name="expiry" :value="expiry" class="input text-sm w-24">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}">{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Archive expiry:</label>
|
||||
<select name="expiry" :value="expiry" class="input text-sm w-24">
|
||||
{{range .ArchiveExpiryChoices}}
|
||||
<option value="{{.Value}}">{{.Label}}</option>
|
||||
{{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>
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="isLog">
|
||||
@@ -249,7 +267,7 @@
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</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}}">
|
||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||
</form>
|
||||
@@ -273,10 +291,10 @@
|
||||
<span class="break-all">{{.Name}}</span>
|
||||
{{with .Note}}<span>({{.}})</span>{{end}}
|
||||
</div>
|
||||
{{if .Size}}
|
||||
{{if .Files}}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<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 class="text-xs text-gray-500 mt-1">
|
||||
<span class="font-medium text-gray-700">Last Written:</span>
|
||||
|
||||
+11
-31
@@ -8,14 +8,15 @@
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<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>
|
||||
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4" x-data="collapsible">
|
||||
<!-- Only the newest event starts expanded. -->
|
||||
{{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. -->
|
||||
<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">
|
||||
@@ -32,10 +33,10 @@
|
||||
<span class="flex flex-wrap items-center gap-4">
|
||||
{{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}}">
|
||||
{{.Target.DisplayName}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}}
|
||||
{{.Target.DisplayName}}{{if .Replay}} (replay){{end}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}}
|
||||
</span>
|
||||
{{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. -->
|
||||
<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"/>
|
||||
@@ -51,7 +52,6 @@
|
||||
</div>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -67,22 +67,15 @@
|
||||
{{range .Deliveries}}
|
||||
<div class="py-2" x-data="collapsible">
|
||||
<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">
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
|
||||
</span>
|
||||
<span 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 type="button" class="btn-small flex-1 gap-3 text-left" @click="toggle">
|
||||
{{template "delivery_row" .}}
|
||||
<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>
|
||||
</button>
|
||||
{{if and .Status.Terminal (not .Target.Deleted)}}
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -103,18 +96,5 @@
|
||||
{{end}}
|
||||
</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>
|
||||
{{end}}
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<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>
|
||||
|
||||
<!-- The checkbox shows the pruning choice while checked. With
|
||||
autocomplete="off", going back to the page does not
|
||||
check the box again with the choice hidden. -->
|
||||
<!-- The checkbox shows the pruning and rotation choices while
|
||||
checked. With autocomplete="off", going back to the page
|
||||
does not check the box again with the choices hidden. -->
|
||||
<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">
|
||||
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
||||
@@ -55,6 +55,15 @@
|
||||
{{end}}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rotation" class="label">Archive Rotation</label>
|
||||
<select id="rotation" name="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. A change applies from the next event; existing files keep their names.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@cacheable/memory@^2.2.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@cacheable/memory/-/memory-2.2.0.tgz#72aeb8b051f6d597d10ac8595b94ad8302bd2239"
|
||||
integrity sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==
|
||||
dependencies:
|
||||
"@cacheable/utils" "^2.5.0"
|
||||
"@keyv/bigmap" "^1.3.1"
|
||||
hookified "^1.15.1"
|
||||
keyv "^5.6.0"
|
||||
|
||||
"@cacheable/utils@^2.5.0":
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@cacheable/utils/-/utils-2.5.0.tgz#534c91113aa48fe43baedb169550b6ee070ef303"
|
||||
integrity sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==
|
||||
dependencies:
|
||||
hashery "^1.5.1"
|
||||
keyv "^5.6.0"
|
||||
|
||||
"@eslint-community/eslint-utils@^4.8.0":
|
||||
version "4.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
|
||||
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/regexpp@^4.12.2":
|
||||
version "4.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
|
||||
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
|
||||
|
||||
"@eslint/config-array@^0.23.5":
|
||||
version "0.23.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
|
||||
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^3.0.5"
|
||||
debug "^4.3.1"
|
||||
minimatch "^10.2.4"
|
||||
|
||||
"@eslint/config-helpers@^0.7.0":
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
|
||||
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.2.1"
|
||||
|
||||
"@eslint/core@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
|
||||
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
|
||||
dependencies:
|
||||
"@types/json-schema" "^7.0.15"
|
||||
|
||||
"@eslint/object-schema@^3.0.5":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
|
||||
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
|
||||
|
||||
"@eslint/plugin-kit@^0.7.3":
|
||||
version "0.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1"
|
||||
integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==
|
||||
dependencies:
|
||||
"@eslint/core" "^1.2.1"
|
||||
levn "^0.4.1"
|
||||
|
||||
"@humanfs/core@^0.19.2":
|
||||
version "0.19.2"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
|
||||
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
|
||||
dependencies:
|
||||
"@humanfs/types" "^0.15.0"
|
||||
|
||||
"@humanfs/node@^0.16.6":
|
||||
version "0.16.8"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
|
||||
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
|
||||
dependencies:
|
||||
"@humanfs/core" "^0.19.2"
|
||||
"@humanfs/types" "^0.15.0"
|
||||
"@humanwhocodes/retry" "^0.4.0"
|
||||
|
||||
"@humanfs/types@^0.15.0":
|
||||
version "0.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
|
||||
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
|
||||
|
||||
"@humanwhocodes/module-importer@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
|
||||
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
|
||||
|
||||
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
|
||||
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
|
||||
|
||||
"@keyv/bigmap@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@keyv/bigmap/-/bigmap-1.3.1.tgz#fc82fa83947e7ff68c6798d08907db842771ef2c"
|
||||
integrity sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==
|
||||
dependencies:
|
||||
hashery "^1.4.0"
|
||||
hookified "^1.15.0"
|
||||
|
||||
"@keyv/serialize@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
|
||||
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
|
||||
|
||||
"@types/esrecurse@^4.3.1":
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
|
||||
integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
|
||||
|
||||
"@types/estree@^1.0.6", "@types/estree@^1.0.8":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
|
||||
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
|
||||
|
||||
"@types/json-schema@^7.0.15":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
acorn@^8.16.0:
|
||||
version "8.18.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
|
||||
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
|
||||
|
||||
ajv@^6.14.0:
|
||||
version "6.15.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
|
||||
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.1"
|
||||
fast-json-stable-stringify "^2.0.0"
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
balanced-match@^4.0.2:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
|
||||
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
|
||||
|
||||
brace-expansion@^5.0.8:
|
||||
version "5.0.12"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec"
|
||||
integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==
|
||||
dependencies:
|
||||
balanced-match "^4.0.2"
|
||||
|
||||
cacheable@^2.5.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/cacheable/-/cacheable-2.5.0.tgz#d142d41043e5a865f6053cc70ef4e3ad068bd5ce"
|
||||
integrity sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==
|
||||
dependencies:
|
||||
"@cacheable/memory" "^2.2.0"
|
||||
"@cacheable/utils" "^2.5.0"
|
||||
hookified "^1.15.0"
|
||||
keyv "^5.6.0"
|
||||
qified "^0.10.1"
|
||||
|
||||
cross-spawn@^7.0.6:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
dependencies:
|
||||
path-key "^3.1.0"
|
||||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
debug@^4.3.1, debug@^4.3.2:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||
|
||||
escape-string-regexp@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
||||
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||
|
||||
eslint-scope@^9.1.2:
|
||||
version "9.1.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
|
||||
integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
|
||||
dependencies:
|
||||
"@types/esrecurse" "^4.3.1"
|
||||
"@types/estree" "^1.0.8"
|
||||
esrecurse "^4.3.0"
|
||||
estraverse "^5.2.0"
|
||||
|
||||
eslint-visitor-keys@^3.4.3:
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
|
||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||
|
||||
eslint-visitor-keys@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||
|
||||
eslint@10.11.0:
|
||||
version "10.11.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.11.0.tgz#304d1591b7c6a327e3f64b4f550f99fda160ae20"
|
||||
integrity sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@eslint/config-array" "^0.23.5"
|
||||
"@eslint/config-helpers" "^0.7.0"
|
||||
"@eslint/core" "^1.2.1"
|
||||
"@eslint/plugin-kit" "^0.7.3"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
"@types/estree" "^1.0.6"
|
||||
ajv "^6.14.0"
|
||||
cross-spawn "^7.0.6"
|
||||
debug "^4.3.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
eslint-scope "^9.1.2"
|
||||
eslint-visitor-keys "^5.0.1"
|
||||
espree "^11.2.0"
|
||||
esquery "^1.7.0"
|
||||
esutils "^2.0.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
file-entry-cache "11.1.5 || >11.1.6 <12"
|
||||
find-up "^5.0.0"
|
||||
glob-parent "^6.0.2"
|
||||
ignore "^5.2.0"
|
||||
imurmurhash "^0.1.4"
|
||||
is-glob "^4.0.0"
|
||||
json-stable-stringify-without-jsonify "^1.0.1"
|
||||
minimatch "^10.2.5"
|
||||
natural-compare "^1.4.0"
|
||||
optionator "^0.9.3"
|
||||
|
||||
espree@^11.2.0:
|
||||
version "11.2.0"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
|
||||
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
|
||||
dependencies:
|
||||
acorn "^8.16.0"
|
||||
acorn-jsx "^5.3.2"
|
||||
eslint-visitor-keys "^5.0.1"
|
||||
|
||||
esquery@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
|
||||
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
|
||||
dependencies:
|
||||
estraverse "^5.1.0"
|
||||
|
||||
esrecurse@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
|
||||
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
|
||||
dependencies:
|
||||
estraverse "^5.2.0"
|
||||
|
||||
estraverse@^5.1.0, estraverse@^5.2.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
||||
|
||||
esutils@^2.0.2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-json-stable-stringify@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
fast-levenshtein@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||
|
||||
"file-entry-cache@11.1.5 || >11.1.6 <12":
|
||||
version "11.1.5"
|
||||
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz#c8210eb055de63e68685ccfb6a017e386d4577d0"
|
||||
integrity sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==
|
||||
dependencies:
|
||||
flat-cache "^6.1.23"
|
||||
|
||||
find-up@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
|
||||
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
|
||||
dependencies:
|
||||
locate-path "^6.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
flat-cache@^6.1.23:
|
||||
version "6.1.23"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-6.1.23.tgz#735dc888c271868d7301b3bbb8edba5028afb61d"
|
||||
integrity sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==
|
||||
dependencies:
|
||||
cacheable "^2.5.0"
|
||||
flatted "^3.4.2"
|
||||
hookified "^1.15.0"
|
||||
|
||||
flatted@^3.4.2:
|
||||
version "3.4.4"
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
|
||||
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
|
||||
|
||||
glob-parent@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
|
||||
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
|
||||
dependencies:
|
||||
is-glob "^4.0.3"
|
||||
|
||||
hashery@^1.4.0, hashery@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/hashery/-/hashery-1.5.1.tgz#4ba82ad54911ac617467870845d57a9fe508a400"
|
||||
integrity sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==
|
||||
dependencies:
|
||||
hookified "^1.15.0"
|
||||
|
||||
hookified@^1.15.0, hookified@^1.15.1:
|
||||
version "1.15.1"
|
||||
resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.15.1.tgz#b1fafeaa5489cdc29cb85546a8f837ed4ffbbcb6"
|
||||
integrity sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==
|
||||
|
||||
hookified@^2.1.1:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/hookified/-/hookified-2.2.0.tgz#1d024ac1668973dd5bcc4a96ab9ccdb7639ef8d4"
|
||||
integrity sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==
|
||||
|
||||
ignore@^5.2.0:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||
|
||||
imurmurhash@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
|
||||
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
|
||||
|
||||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
|
||||
|
||||
is-glob@^4.0.0, is-glob@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
json-schema-traverse@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
|
||||
|
||||
keyv@^5.6.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.6.0.tgz#03044074c6b4d072d0a62c7b9fa649537baf0105"
|
||||
integrity sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==
|
||||
dependencies:
|
||||
"@keyv/serialize" "^1.1.1"
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
|
||||
dependencies:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
minimatch@^10.2.4, minimatch@^10.2.5:
|
||||
version "10.2.6"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
|
||||
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
|
||||
dependencies:
|
||||
brace-expansion "^5.0.8"
|
||||
|
||||
ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
optionator@^0.9.3:
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
|
||||
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
|
||||
dependencies:
|
||||
deep-is "^0.1.3"
|
||||
fast-levenshtein "^2.0.6"
|
||||
levn "^0.4.1"
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "^0.4.0"
|
||||
word-wrap "^1.2.5"
|
||||
|
||||
p-limit@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||
dependencies:
|
||||
yocto-queue "^0.1.0"
|
||||
|
||||
p-locate@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
|
||||
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
|
||||
dependencies:
|
||||
p-limit "^3.0.2"
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
|
||||
|
||||
path-key@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
punycode@^2.1.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qified@^0.10.1:
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/qified/-/qified-0.10.1.tgz#0640bf21bbe6ca540db290ad8f5fde4d870b8bda"
|
||||
integrity sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==
|
||||
dependencies:
|
||||
hookified "^2.1.1"
|
||||
|
||||
shebang-command@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
|
||||
integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
|
||||
dependencies:
|
||||
shebang-regex "^3.0.0"
|
||||
|
||||
shebang-regex@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
|
||||
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
|
||||
dependencies:
|
||||
prelude-ls "^1.2.1"
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
|
||||
dependencies:
|
||||
isexe "^2.0.0"
|
||||
|
||||
word-wrap@^1.2.5:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
Reference in New Issue
Block a user