1 Commits
Author SHA1 Message Date
clawbot 36b6e5ac41 Add a read-only Settings page for the loaded configuration (closes #402)
check / check (push) Waiting to run
The page at /settings, behind the login and linked from the
navigation bar, lists every field of the configuration the server
started with: each by its environment variable name, with the
README's description and the value in effect. METRICS_PASSWORD and
SENTRY_DSN show only as set or not set; their values never reach the
template. The handlers now take the loaded Config from the dependency
graph.

Model: opus-5-5
2026-10-01 23:28:13 +00:00
77 changed files with 710 additions and 3234 deletions
+66 -112
View File
@@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets
with retry support, logging, and observability. Category: infrastructure with retry support, logging, and observability. Category: infrastructure
/ web service. License: MIT. / web service. License: MIT.
Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and
that UUID is the entrypoint's only credential. webhooker does not use that UUID is the entrypoint's only credential. webhooker does not use
shared secrets, HMAC signatures or token headers on the receiver, and shared secrets, HMAC signatures or token headers on the receiver, and
will not add them — read will not add them — read
@@ -145,6 +145,11 @@ TTY detection, and security headers are always applied.
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) | | `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) | | `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
The Settings page of the web UI (`/settings`, behind the login) lists
every one of these with the value the running server loaded. It is
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
set or not set, never their values.
#### Allowing egress to your own network #### Allowing egress to your own network
By default every delivery target must resolve to a public address. The By default every delivery target must resolve to a public address. The
@@ -1075,7 +1080,7 @@ unconditionally against whatever files it finds:
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`, - the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
`Entrypoint`, `Target` `Entrypoint`, `Target`
- each event database when it is lazily opened — `Event`, `Delivery`, - each event database when it is lazily opened — `Event`, `Delivery`,
`DeliveryResult`, `EventTotals`, `TargetTotals` `DeliveryResult`
- each archive database on every open and reopen - each archive database on every open and reopen
There is no schema version table, no migration ledger, and no down There is no schema version table, no migration ledger, and no down
@@ -1188,7 +1193,7 @@ backups at rest and restrict who can read them.
**The entrypoint UUID is the credential, and it is the only one.** **The entrypoint UUID is the credential, and it is the only one.**
webhooker mints a version 4 UUID per entrypoint and serves it at webhooker mints a version 4 UUID per entrypoint and serves it at
`/h/{uuid}`. Possession of that URL is the authentication: `/webhook/{uuid}`. Possession of that URL is the authentication:
anyone who holds it can submit events to the entrypoint, and the anyone who holds it can submit events to the entrypoint, and the
receiver verifies nothing else about the sender. receiver verifies nothing else about the sender.
@@ -1392,7 +1397,7 @@ The codebase uses consistent naming throughout (rename completed in
### Data Model ### Data Model
webhooker's data model has eleven entities organized into two tiers: the webhooker's data model has nine entities organized into two tiers: the
**application tier** (user and webhook configuration) and the **event **application tier** (user and webhook configuration) and the **event
tier** (event ingestion, delivery, and logging). tier** (event ingestion, delivery, and logging).
@@ -1421,13 +1426,6 @@ tier** (event ingestion, delivery, and logging).
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │ │ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
│ └──────────┘ └──────────┘ └─────────────────┘ │ │ └──────────┘ └──────────┘ └─────────────────┘ │
│ │
│ ┌──────────────┐ (one row: running counts of events) │
│ │ EventTotals │ │
│ └──────────────┘ │
│ ┌──────────────┐ (one row per target: running counts │
│ │ TargetTotals │ of its deliveries) │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
@@ -1530,7 +1528,7 @@ the full request and creates an Event.
| -------------- | ------- | ----------- | | -------------- | ------- | ----------- |
| `id` | UUID | Primary key | | `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook | | `webhook_id` | UUID | Foreign key → Webhook |
| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) | | `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
| `description` | string | Optional description | | `description` | string | Optional description |
| `active` | boolean | Whether this entrypoint accepts events (default: true) | | `active` | boolean | Whether this entrypoint accepts events (default: true) |
@@ -1681,7 +1679,6 @@ status across potentially multiple attempts.
| `event_id` | UUID | Foreign key → Event | | `event_id` | UUID | Foreign key → Event |
| `target_id`| UUID | Foreign key → Target | | `target_id`| UUID | Foreign key → Target |
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | | `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
**Relations:** Belongs to Event. Belongs to Target. Has many **Relations:** Belongs to Event. Belongs to Target. Has many
DeliveryResults. DeliveryResults.
@@ -1749,70 +1746,33 @@ retries) is individually logged for full observability.
**Relations:** Belongs to Delivery. **Relations:** Belongs to Delivery.
#### EventTotals and TargetTotals
Running counts in each event database, read by the statistics pane at the
top of the webhook page. `EventTotals` is one row:
| Field | Type | Description |
| ---------------- | --------- | ----------- |
| `events` | integer | Events ever stored, resubmitted copies included |
| `events_removed` | integer | Events retention has deleted |
| `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is |
`TargetTotals` is one row per target, created by the first delivery to it:
| Field | Type | Description |
| -------------------- | ------- | ----------- |
| `target_id` | UUID | The target (primary key) |
| `deliveries` | integer | Deliveries to it ever created, replays included |
| `delivered` | integer | Of those, how many became `delivered` |
| `failed` | integer | Of those, how many became `failed` |
| `deliveries_removed` | integer | Its deliveries retention has deleted |
| `failed_removed` | integer | Its failed deliveries retention has deleted |
Each count changes in the transaction that writes or deletes the rows it
counts. The pane's lifetime events are `events`, and its lifetime
deliveries and failures are `deliveries` and `failed` summed over the
targets; each figure within retention is the same less what retention
removed, so neither needs the rows themselves. Its last event is
`last_event_at`, written in the transaction that stores the event, so it
still shows once retention has removed every event. Its last-10-minutes and
last-24-hours figures are counted from the `events` and `deliveries`
indexes over just that window, the deliveries in one query grouped by
target. Its failure percentage for a window is the deliveries that became
`failed` in it out of all that became `delivered` or `failed` in it, and
a dash when none did.
#### Event-tier indexes #### Event-tier indexes
These indexes on the per-webhook event databases are declared in the model These indexes on the per-webhook event databases are declared in the model
tags, so `AutoMigrate` creates them on a fresh database: tags, so `AutoMigrate` creates them on a fresh and on an existing database:
| Table | Columns | Serves | | Table | Columns | Serves |
| ------------------ | --------------------------- | ------ | | ------------------ | --------------------------- | ------ |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished | | `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events | | `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events | | `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | | `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
| `events` | `created_at` | Retention, which selects expired events by age | | `events` | `created_at` | Retention's delete of the expired events themselves |
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
leaves it out. SQLite keeps no statistics on these tables, and without them it deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
rates the `deleted_at` index, which every live row matches, above an index on no statistics on these tables, and without them it rates the `deleted_at`
a column matched against several values or compared with a range. So every index, which every live row matches, above an index on a column matched
index but the last also covers `deleted_at`. It comes second, so that against several values or compared with `<`. So every index but the last also
retention can use the index without it, except in `events`, where the covers `deleted_at`. It comes second, so that retention's deletes can use the
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a index without it, except in `events`, where `created_at` is compared with `<`
range only on the last column it uses. and SQLite narrows by a `<` only on the last column it uses.
#### Common Fields #### Common Fields
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes Every entity except `Setting` includes these fields from `BaseModel`.
these fields from `BaseModel`. `Setting` is a bare key-value row with no `Setting` is a bare key-value row with no `id`, no timestamps and no
`id`, no timestamps and no soft delete, and the two totals tables hold soft delete:
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
and by `target_id`:
| Field | Type | Description | | Field | Type | Description |
| ------------ | --------- | ----------- | | ------------ | --------- | ----------- |
@@ -1854,8 +1814,6 @@ encryption key is generated and stored, and an `admin` user is created.
- **Events** — captured incoming webhook payloads - **Events** — captured incoming webhook payloads
- **Deliveries** — event-to-target pairings and their status - **Deliveries** — event-to-target pairings and their status
- **DeliveryResults** — individual delivery attempt logs - **DeliveryResults** — individual delivery attempt logs
- **EventTotals** and **TargetTotals** — running counts of the above,
the deliveries per target, kept through retention
Per-webhook databases are created automatically when a webhook is Per-webhook databases are created automatically when a webhook is
created (and lazily on first access for webhooks that predate this created (and lazily on first access for webhooks that predate this
@@ -1952,7 +1910,7 @@ runtime, though CGO is required at build time due to the transitive
``` ```
External Service External Service
│ │
│ POST /h/{uuid} │ POST /webhook/{uuid}
▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ chi Router │────►│ Middleware │────►│ Webhook │ │ chi Router │────►│ Middleware │────►│ Webhook │
@@ -2178,7 +2136,7 @@ The middleware records three more on the same registry:
Two of those labels are written once per request from bytes the client Two of those labels are written once per request from bytes the client
chose, so both are bounded to something this service registers: chose, so both are bounded to something this service registers:
- `handler` is the chi route pattern — `/h/{uuid}`, never the - `handler` is the chi route pattern — `/webhook/{uuid}`, never the
concrete path. A request matching no route carries `(unmatched)`, concrete path. A request matching no route carries `(unmatched)`,
and no entrypoint UUID ever reaches a label. and no entrypoint UUID ever reaches a label.
- `method` is the request method when the router can route it, and - `method` is the request method when the router can route it, and
@@ -2208,7 +2166,7 @@ unpredictable rates, and blanket limits shared with other routes would
cause legitimate deliveries to be dropped. cause legitimate deliveries to be dropped.
The receiver instead has its own dedicated abuse limit, scoped to the The receiver instead has its own dedicated abuse limit, scoped to the
`/h/{uuid}` route only and keyed per client IP per request path `/webhook/{uuid}` route only and keyed per client IP per request path
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without (`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
affecting other senders of the same entrypoint or the same sender's affecting other senders of the same entrypoint or the same sender's
other entrypoints. Keying on the path rather than on the entrypoint other entrypoints. Keying on the path rather than on the entrypoint
@@ -2245,7 +2203,7 @@ log spends. The access log is bounded by neither limit: every request
is recorded once at `INFO`, served or rejected alike. is recorded once at `INFO`, served or rejected alike.
What the access log does bound is the _content_ of those lines. A 3xx What the access log does bound is the _content_ of those lines. A 3xx
or 4xx response logs the chi route pattern — `/h/{uuid}`, or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
`/user/{username}//`, or the literal `(unmatched)` when the request hit `/user/{username}//`, or the literal `(unmatched)` when the request hit
no route at all — in place of the concrete URL. Those are the outcomes no route at all — in place of the concrete URL. Those are the outcomes
an unauthenticated client can drive for free: 404 and 429 on any an unauthenticated client can drive for free: 404 and 429 on any
@@ -2279,12 +2237,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
The same hook rewrites the request URL. The SDK builds it as The same hook rewrites the request URL. The SDK builds it as
`scheme://host/path` from the concrete path, which on the receiver `scheme://host/path` from the concrete path, which on the receiver
route is `/h/<uuid>` in full — and that UUID is a write route is `/webhook/<uuid>` in full — and that UUID is a write
capability, not an identifier: anyone holding it can post events this capability, not an identifier: anyone holding it can post events this
service accepts and its targets then deliver. A tracker has its own service accepts and its targets then deliver. A tracker has its own
retention, access control and deletion policy, so the rule the access retention, access control and deletion policy, so the rule the access
log follows above does not carry across that boundary. What is sent is log follows above does not carry across that boundary. What is sent is
the chi route pattern instead: `http://host/h/{uuid}`. the chi route pattern instead: `http://host/webhook/{uuid}`.
The scheme and the host are kept, and everything else in the URL is The scheme and the host are kept, and everything else in the URL is
discarded rather than edited, so a future SDK version that starts discarded rather than edited, so a future SDK version that starts
@@ -2328,8 +2286,8 @@ fallback is never the concrete path. The path becomes the literal
rewrite cannot parse into a scheme is withheld whole. A transaction rewrite cannot parse into a scheme is withheld whole. A transaction
event additionally carries the SDK's own `METHOD /path` name, built event additionally carries the SDK's own `METHOD /path` name, built
from the concrete path as well; it is rewritten on the same terms, to from the concrete path as well; it is rewritten on the same terms, to
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)` `POST /webhook/{uuid}` where the pattern is known and `POST
where it is not. /(redacted)` where it is not.
The headers are an allowlist for the same reason the rules above are The headers are an allowlist for the same reason the rules above are
unconditional: the SDK's own filter removes four names and passes unconditional: the SDK's own filter removes four names and passes
@@ -2464,7 +2422,7 @@ logger printed the fully interpolated SQL — parameters and all — to
standard output on every statement that returned an error, including a standard output on every statement that returned an error, including a
plain record-not-found, at a level no operator setting reached. Two of plain record-not-found, at a level no operator setting reached. Two of
this service's lookups miss by design on unauthenticated routes: the this service's lookups miss by design on unauthenticated routes: the
entrypoint lookup behind `/h/{uuid}` and the user lookup behind entrypoint lookup behind `/webhook/{uuid}` and the user lookup behind
the login form, whose path segment and submitted username the client the login form, whose path segment and submitted username the client
picks outright. Every picks outright. Every
`gorm.Open` in the service now installs the adapter in `gorm.Open` in the service now installs the adapter in
@@ -2741,48 +2699,45 @@ abuse limit later; they are tracked as future work.
| Method | Path | Description | | Method | Path | Description |
| ------ | --------------------------- | ----------- | | ------ | --------------------------- | ----------- |
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) | | `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) | | `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | | `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | | `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
#### Authentication Endpoints #### Authentication Endpoints
| Method | Path | Description | | Method | Path | Description |
| ------ | --------------- | ----------- | | ------ | --------------- | ----------- |
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` | | `GET` | `/pages/login` | Login page (not rate limited) |
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | | `POST` | `/pages/login` | Login form submission. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
| `POST` | `/pages/logout` | Logout (destroys session) | | `POST` | `/pages/logout` | Logout (destroys session) |
#### Authenticated Endpoints #### Authenticated Endpoints
A logged-out `GET` of any of these is redirected to `/pages/login` with
its path and query as `next` when they fit in 2048 bytes, so logging in
returns to the page that was asked for.
| Method | Path | Description | | Method | Path | Description |
| ------ | ------------------------ | ----------- | | ------ | ------------------------ | ----------- |
| `GET` | `/user/{username}` | User profile page | | `GET` | `/user/{username}` | User profile page |
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) | | `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
| `GET` | `/hooks` | List user's webhooks | | `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
| `GET` | `/hooks/new` | Create webhook form | | `GET` | `/sources` | List user's webhooks |
| `POST` | `/hooks/new` | Create webhook submission | | `GET` | `/sources/new` | Create webhook form |
| `GET` | `/hook/{id}` | Webhook detail view | | `POST` | `/sources/new` | Create webhook submission |
| `GET` | `/hook/{id}/edit` | Edit webhook form | | `GET` | `/source/{id}` | Webhook detail view |
| `POST` | `/hook/{id}/edit` | Edit webhook submission | | `GET` | `/source/{id}/edit` | Edit webhook form |
| `POST` | `/hook/{id}/delete` | Delete webhook | | `POST` | `/source/{id}/edit` | Edit webhook submission |
| `GET` | `/hook/{id}/events` | Full Event Log | | `POST` | `/source/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated | | `GET` | `/source/{id}/logs` | Webhook event logs |
| `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`) | | `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
| `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`) | | `POST` | `/source/{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}/entrypoints` | Add entrypoint to webhook | | `POST` | `/source/{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`) |
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | | `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | | `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
| `POST` | `/hook/{id}/targets` | Add target to webhook | | `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | | `POST` | `/source/{id}/targets` | Add target to webhook |
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission | | `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target | | `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target | | `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
#### Infrastructure Endpoints #### Infrastructure Endpoints
@@ -2839,7 +2794,6 @@ webhooker/
│ │ ├── model_event.go # Event entity (per-webhook DB) │ │ ├── model_event.go # Event entity (per-webhook DB)
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB) │ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB) │ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
│ │ ├── model_apikey.go # APIKey entity │ │ ├── model_apikey.go # APIKey entity
│ │ ├── password.go # Argon2id hashing and verification │ │ ├── password.go # Argon2id hashing and verification
│ │ ├── retention.go # Retention reaper (per-webhook event expiry) │ │ ├── retention.go # Retention reaper (per-webhook event expiry)
@@ -2877,6 +2831,7 @@ webhooker/
│ │ ├── healthcheck.go # Health check handler │ │ ├── healthcheck.go # Health check handler
│ │ ├── index.go # Index page handler │ │ ├── index.go # Index page handler
│ │ ├── profile.go # User profile handler │ │ ├── profile.go # User profile handler
│ │ ├── settings.go # Read-only Settings page handler
│ │ ├── source_management.go # Webhook CRUD handlers │ │ ├── source_management.go # Webhook CRUD handlers
│ │ └── webhook.go # Webhook receiver handler │ │ └── webhook.go # Webhook receiver handler
│ ├── healthcheck/ │ ├── healthcheck/
@@ -2984,8 +2939,8 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does recovery of a panic in the six entries above it, none of which does
more than set a header or start a timer. more than set a header or start a timer.
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`, Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
`/hook/*`) apply a **MaxBodySize** middleware that limits `/source/*`) apply a **MaxBodySize** middleware that limits
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
CSRF middleware in every one of those route groups, because CSRF middleware in every one of those route groups, because
gorilla/csrf parses the form; if the cap were installed after it, form gorilla/csrf parses the form; if the cap were installed after it, form
@@ -3009,7 +2964,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
`/pages` applies **RequireAuth**. The rate limiters are per-route `/pages` applies **RequireAuth**. The rate limiters are per-route
rather than global: **PasswordChangeRateLimit** on rather than global: **PasswordChangeRateLimit** on
`/user/{username}/password` and **ReceiverRateLimit** on `/user/{username}/password` and **ReceiverRateLimit** on
`/h/{uuid}`. There is deliberately none on `/pages/login` — that `/webhook/{uuid}`. There is deliberately none on `/pages/login` — that
endpoint counts failures inside the handler, after the credential endpoint counts failures inside the handler, after the credential
check, see [The login endpoint](#the-login-endpoint). check, see [The login endpoint](#the-login-endpoint).
@@ -3050,8 +3005,8 @@ check, see [The login endpoint](#the-login-endpoint).
by middleware that runs before CSRF parses the form by middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) - **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
on all state-changing forms (cookie-based double-submit tokens with on all state-changing forms (cookie-based double-submit tokens with
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and `/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
`/api` (stateless API). The middleware detects TLS per-request through `/api` (stateless API). The middleware detects TLS per-request through
`internal/reqtls.IsTLS` — the same predicate the session cookie uses — `internal/reqtls.IsTLS` — the same predicate the session cookie uses —
to set appropriate cookie security flags and Origin/Referer validation to set appropriate cookie security flags and Origin/Referer validation
@@ -3104,8 +3059,7 @@ check, see [The login endpoint](#the-login-endpoint).
before the app starts; the image's health check; and `docker exec`, before the app starts; the image's health check; and `docker exec`,
unless given `--user` unless given `--user`
- GORM soft deletes on every entity that carries `BaseModel`, which is - GORM soft deletes on every entity that carries `BaseModel`, which is
all of them but `Setting`, `EventTotals` and `TargetTotals` (data all of them but `Setting` (data preserved for audit)
preserved for audit)
### Shutdown ### Shutdown
+17 -72
View File
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
deliveries []database.Delivery deliveries []database.Delivery
results []database.DeliveryResult results []database.DeliveryResult
depths []struct{ Depth int } depths []struct{ Depth int }
removed []database.TargetTotals
) )
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
// The delivery engine: recovery and the retry sweep, the sweep for // The delivery engine: recovery and the retry sweep, the sweep for
// stranded pending deliveries, and the queue depth count. // stranded pending deliveries, and the queue depth count.
@@ -123,80 +123,25 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Order("attempt_num ASC").Find(&results), Order("attempt_num ASC").Find(&results),
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)") "idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
// Retention (reapExpired, deleteEvents): one batch of expired // Retention's three deletes (reapExpired), whose subqueries are built
// events, then their attempts, deliveries and the events. // afresh for each statement as it builds them.
var expired []string expiredEventIDs := func() *gorm.DB {
return dry.Model(&database.Event{}).Select("id").
Where("created_at < ?", cutoff)
}
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
Where("created_at < ?", cutoff).
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
"idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where( assertPlanUses(t, db, dry.Unscoped().Where(
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}). "delivery_id IN (?)", dry.Model(&database.Delivery{}).
Select("id").Where("event_id IN ?", ids), Select("id").Where("event_id IN (?)", expiredEventIDs()),
).Delete(&database.DeliveryResult{}), ).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)", "idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
"idx_deliveries_event_id (event_id=?)") assertPlanUses(t, db, dry.Unscoped().Where(
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}). "event_id IN (?)", expiredEventIDs(),
Select("target_id, count(*) AS deliveries_removed, "+ ).Delete(&database.Delivery{}),
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed", "idx_deliveries_event_id (event_id=?)", byAge)
database.DeliveryStatusFailed). assertPlanUses(t, db, dry.Unscoped().Where(
Where("event_id IN ?", ids).Group("target_id").Find(&removed), "created_at < ?", cutoff,
"idx_deliveries_event_id (event_id=?)") ).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must
// come from the index alone, and events received since a time.
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
db, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
since := time.Now()
var (
count int64
byTarget []struct{ TargetID string }
)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).Count(&count),
"idx_deliveries_status (status=? AND deleted_at=?)")
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Select("target_id, "+
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").Find(&byTarget),
"COVERING INDEX idx_deliveries_status "+
"(status=? AND deleted_at=? AND finished_at>?)")
assertPlanUses(t, db, dry.Model(&database.Event{}).
Where("created_at >= ?", since).Count(&count),
"idx_events_deleted_at_created_at "+
"(deleted_at=? AND created_at>?)")
} }
// assertPlanUses asserts that SQLite's plan for a statement GORM built // assertPlanUses asserts that SQLite's plan for a statement GORM built
-16
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"testing"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -29,10 +28,6 @@ func NewTestRetentionReaper(
} }
} }
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests. // ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) { func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx) r.sweep(ctx)
@@ -84,14 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
func DummyPasswordHashForTest() string { func DummyPasswordHashForTest() string {
return dummyPasswordHash() return dummyPasswordHash()
} }
// HashAtShippedCostForTest makes HashPassword hash at the shipped
// memory cost until t ends. t must not run in parallel with other
// tests, which would hash at that cost alongside it.
func HashAtShippedCostForTest(t *testing.T) {
t.Helper()
hashAtShippedCostInTest = true
t.Cleanup(func() { hashAtShippedCostInTest = false })
}
+2 -13
View File
@@ -1,10 +1,6 @@
package database package database
import ( import "gorm.io/gorm"
"time"
"gorm.io/gorm"
)
// DeliveryStatus represents the status of a delivery // DeliveryStatus represents the status of a delivery
type DeliveryStatus string type DeliveryStatus string
@@ -41,7 +37,7 @@ type Delivery struct {
BaseModel BaseModel
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"` EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"` TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"` Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column // DeletedAt repeats the BaseModel field only to be the second column
@@ -49,13 +45,6 @@ type Delivery struct {
// gives. // gives.
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"` DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
// FinishedAt is when the delivery became delivered or failed, and
// nil while it is pending or retrying. It and then TargetID end the
// status index, so the webhook page counts each target's deliveries
// that finished in a recent window by reading just that window from
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// Relations // Relations
Event Event `json:"event,omitzero"` Event Event `json:"event,omitzero"`
Target Target `json:"target,omitzero"` Target Target `json:"target,omitzero"`
-99
View File
@@ -1,99 +0,0 @@
package database
import (
"fmt"
"time"
"gorm.io/gorm"
)
// The running totals in a webhook's event database keep the webhook
// page's lifetime figures right after retention has removed the rows
// they count, and let the page show them without counting every row.
// Each total changes in the transaction that writes or deletes the
// rows it counts.
// EventTotals is the single row counting a webhook's events: every
// event ever stored, how many of them retention has deleted, and when
// the newest arrived, which retention leaves as it is.
type EventTotals struct {
ID int64 `gorm:"primaryKey"`
Events int64 `gorm:"not null"`
EventsRemoved int64 `gorm:"not null"`
// LastEventAt is when the newest event arrived, or nil before the
// first.
LastEventAt *time.Time
}
// TableName names the table AddEventTotals updates.
func (EventTotals) TableName() string {
return "event_totals"
}
// TargetTotals is one row per target counting its deliveries: every
// delivery ever created, how many became delivered and how many
// failed, and how many deliveries and failed deliveries retention has
// deleted. The webhook's delivery figures are these rows summed.
type TargetTotals struct {
TargetID string `gorm:"type:uuid;primaryKey"`
Deliveries int64 `gorm:"not null"`
Delivered int64 `gorm:"not null"`
Failed int64 `gorm:"not null"`
DeliveriesRemoved int64 `gorm:"not null"`
FailedRemoved int64 `gorm:"not null"`
}
// TableName names the table AddTargetTotals updates.
func (TargetTotals) TableName() string {
return "target_totals"
}
// AddEventTotals adds each count in add to the webhook's event totals,
// and records add.LastEventAt as when the newest event arrived if it is
// set. Call it on the transaction that writes or deletes the events it
// counts.
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
err := tx.Exec(
`UPDATE event_totals SET
events = events + ?,
events_removed = events_removed + ?,
last_event_at = coalesce(?, last_event_at)`,
add.Events, add.EventsRemoved, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf("adding to event totals: %w", err)
}
return nil
}
// AddTargetTotals adds each count in add to the totals of the target
// add.TargetID names, creating its row the first time. Call it on the
// transaction that writes or deletes the deliveries it counts.
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
err := tx.Exec(
`INSERT INTO target_totals (target_id, deliveries, delivered,
failed, deliveries_removed, failed_removed)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (target_id) DO UPDATE SET
deliveries = deliveries + excluded.deliveries,
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
deliveries_removed =
deliveries_removed + excluded.deliveries_removed,
failed_removed = failed_removed + excluded.failed_removed`,
add.TargetID, add.Deliveries, add.Delivered,
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of target %s: %w", add.TargetID, err,
)
}
return nil
}
+1 -2
View File
@@ -2,8 +2,7 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, // Event-tier models (Event, Delivery, DeliveryResult) live in
// TargetTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+1 -22
View File
@@ -9,7 +9,6 @@ import (
"math/big" "math/big"
"strings" "strings"
"sync" "sync"
"testing"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
@@ -64,30 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
} }
} }
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test // HashPassword generates an Argon2id hash of the password
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and under the race detector each 64 MB hash
// holds about 150 MB. VerifyPassword reads the cost from the hash it
// checks, so verification follows.
const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped
// memory cost. Only TestHashPassword_ShippedParameters sets it.
//
//nolint:gochecknoglobals // set by one test, see above
var hashAtShippedCostInTest bool
// HashPassword generates an Argon2id hash of the password. A binary
// built by go test hashes at testArgon2Memory; one built by go build
// always hashes at the defaults.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig() config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt // Generate a salt
salt := make([]byte, config.SaltLen) salt := make([]byte, config.SaltLen)
-33
View File
@@ -192,39 +192,6 @@ func TestHashPasswordUniqueness(t *testing.T) {
} }
} }
// TestHashPassword_ShippedParameters hashes and verifies through
// HashPassword at the shipped Argon2id parameters. Every other test
// hashes at the lower memory cost a test binary uses, so this is the
// one that keeps production hashing covered. One hash and one
// verification: each costs 64 MB.
//
//nolint:paralleltest // changes the hashing cost for the whole binary
func TestHashPassword_ShippedParameters(t *testing.T) {
database.HashAtShippedCostForTest(t)
password := "correct horse battery staple"
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("hashing with the shipped parameters: %v", err)
}
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
if !strings.HasPrefix(hash, shipped) {
t.Errorf("hash = %q, want prefix %q", hash, shipped)
}
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error("VerifyPassword() returned false for correct password")
}
}
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration // TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
// path. Login charges an unknown username a verification against a // path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in // dummy hash so that a nonexistent account is not answered in
+46 -111
View File
@@ -18,19 +18,6 @@ import (
// computation. // computation.
const hoursPerDay = 24 const hoursPerDay = 24
// reapBatchSize is how many expired events one retention transaction
// deletes. A transaction holds the event database's write lock, which
// the receiver and the delivery workers wait for, so a large prune is
// split into transactions each short enough to finish well inside the
// busy timeout.
const reapBatchSize = 1000
// reapBatchPause is how long retention waits after one batch before
// starting the next. A writer waiting for the write lock checks for it
// again after at most 100 ms, so a longer pause lets it in between two
// batches instead of only after the whole prune.
const reapBatchPause = 200 * time.Millisecond
// RetentionReaperParams holds the fx dependencies for the // RetentionReaperParams holds the fx dependencies for the
// RetentionReaper. // RetentionReaper.
type RetentionReaperParams struct { type RetentionReaperParams struct {
@@ -200,15 +187,13 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
continue continue
} }
r.reapWebhook(ctx, wh.ID, wh.RetentionDays) r.reapWebhook(wh.ID, wh.RetentionDays)
} }
} }
// reapWebhook removes every expired event (and its dependents) from a // reapWebhook removes every expired event (and its dependents) from a
// single webhook's database, or as many as it reaches before ctx is // single webhook's database.
// cancelled.
func (r *RetentionReaper) reapWebhook( func (r *RetentionReaper) reapWebhook(
ctx context.Context,
webhookID string, webhookID string,
retentionDays int, retentionDays int,
) { ) {
@@ -228,7 +213,7 @@ func (r *RetentionReaper) reapWebhook(
return return
} }
deleted, err := reapExpired(ctx, db, cutoff) deleted, err := reapExpired(db, cutoff)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
"retention sweep: failed to reap expired events", "retention sweep: failed to reap expired events",
@@ -280,107 +265,57 @@ func retentionCutoff(
), true ), true
} }
// reapExpired hard-deletes the events older than cutoff, with their // reapExpired hard-deletes, in foreign-key-safe order, the delivery
// deliveries and delivery results, reapBatchSize events per // results, deliveries, and events associated with events older than
// transaction with reapBatchPause between transactions, until none is // cutoff. Deletes are unscoped so rows are physically removed rather
// left. Once ctx is cancelled it returns after the batch in hand, // than soft-deleted, reclaiming disk. It returns the number of events
// leaving the rest to the next sweep, so stopping the app does not // deleted.
// wait for a long prune. It returns the number of events deleted. func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
func reapExpired( // Fresh subqueries are built per statement to avoid reusing a
ctx context.Context, db *gorm.DB, cutoff time.Time, // mutated builder across executions.
) (int64, error) { expiredEventIDs := func() *gorm.DB {
var total int64 return db.Model(&Event{}).
for {
var eventIDs []string
err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Unscoped().Model(&Event{}).
Where("created_at < ?", cutoff).
Limit(reapBatchSize).
Pluck("id", &eventIDs).Error
if err != nil {
return fmt.Errorf("selecting expired events: %w", err)
}
if len(eventIDs) == 0 {
return nil
}
return deleteEvents(tx, eventIDs)
})
if err != nil {
return total, err
}
total += int64(len(eventIDs))
if len(eventIDs) < reapBatchSize {
return total, nil
}
select {
case <-ctx.Done():
return total, nil
case <-time.After(reapBatchPause):
}
}
}
// deleteEvents hard-deletes the given events and, in foreign-key-safe
// order before them, their delivery results and deliveries, then adds
// what it deleted to the running totals. It runs on reapExpired's
// transaction, so the totals change exactly when the rows do. Deletes
// are unscoped so rows are physically removed rather than
// soft-deleted, reclaiming disk.
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
// 1. The delivery results of the events' deliveries.
err := tx.Unscoped().
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
Select("id"). Select("id").
Where("event_id IN ?", eventIDs)). Where("created_at < ?", cutoff)
Delete(&DeliveryResult{}).Error }
if err != nil { expiredDeliveryIDs := func() *gorm.DB {
return fmt.Errorf("deleting expired delivery results: %w", err) return db.Model(&Delivery{}).
Select("id").
Where("event_id IN (?)", expiredEventIDs())
} }
// 2. The events' deliveries, after counting them, and the failed // 1. Delivery results whose delivery belongs to an expired event.
// ones among them, per target. The status is tested in the select res := db.Unscoped().
// list rather than the WHERE clause: there, SQLite would read every Where("delivery_id IN (?)", expiredDeliveryIDs()).
// failed delivery the webhook has through the status index, Delete(&DeliveryResult{})
// instead of only these through the event_id index. if res.Error != nil {
var removed []TargetTotals return 0, fmt.Errorf(
"deleting expired delivery results: %w",
err = tx.Unscoped().Model(&Delivery{}). res.Error,
Select("target_id, count(*) AS deliveries_removed, "+ )
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
DeliveryStatusFailed).
Where("event_id IN ?", eventIDs).
Group("target_id").
Find(&removed).Error
if err != nil {
return fmt.Errorf("counting expired deliveries: %w", err)
} }
err = tx.Unscoped(). // 2. Deliveries belonging to an expired event.
Where("event_id IN ?", eventIDs). del := db.Unscoped().
Delete(&Delivery{}).Error Where("event_id IN (?)", expiredEventIDs()).
if err != nil { Delete(&Delivery{})
return fmt.Errorf("deleting expired deliveries: %w", err) if del.Error != nil {
return 0, fmt.Errorf(
"deleting expired deliveries: %w",
del.Error,
)
} }
// 3. The events themselves. // 3. The expired events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{}) ev := db.Unscoped().
Where("created_at < ?", cutoff).
Delete(&Event{})
if ev.Error != nil { if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error) return 0, fmt.Errorf(
"deleting expired events: %w",
ev.Error,
)
} }
for i := range removed { return ev.RowsAffected, nil
err = AddTargetTotals(tx, removed[i])
if err != nil {
return err
}
}
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
} }
-410
View File
@@ -1,410 +0,0 @@
package database_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// readEventTotals reads a webhook database's row of event totals,
// asserting that it has exactly one.
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
t.Helper()
var rows []database.EventTotals
require.NoError(t, db.Find(&rows).Error)
require.Len(t, rows, 1)
return rows[0]
}
// readTargetTotals reads a webhook database's target totals, keyed by
// target.
func readTargetTotals(
t *testing.T, db *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
// database starts with one row of zero event totals and no target
// totals, that adding to a target twice adds to the one row, and that
// opening the database again keeps everything added.
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
fresh := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
assert.Empty(t, readTargetTotals(t, db))
first, second := uuid.New().String(), uuid.New().String()
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 2,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Deliveries: 2, Delivered: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: first, Failed: 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: second, Deliveries: 1,
}))
// Drop the cached connection so the next open reopens the file,
// as a restart would.
require.NoError(t, mgr.CloseAll())
db, err = mgr.GetDB(webhookID)
require.NoError(t, err)
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
readEventTotals(t, db))
assert.Equal(t, map[string]database.TargetTotals{
first: {
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
},
second: {TargetID: second, Deliveries: 1},
}, readTargetTotals(t, db))
}
// seedExpiredEvents stores count events created at the given time,
// each with a delivered delivery to one target and a failed delivery
// to the other, and one attempt for each delivery.
func seedExpiredEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
delivered, failed string,
) {
t.Helper()
events := make([]database.Event, count)
deliveries := make([]database.Delivery, 0, 2*count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].ID = uuid.New().String()
events[i].CreatedAt = createdAt
deliveries = append(deliveries,
database.Delivery{
EventID: events[i].ID,
TargetID: delivered,
Status: database.DeliveryStatusDelivered,
},
database.Delivery{
EventID: events[i].ID,
TargetID: failed,
Status: database.DeliveryStatusFailed,
},
)
}
require.NoError(t, db.CreateInBatches(events, 500).Error)
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
results := make([]database.DeliveryResult, len(deliveries))
for i := range deliveries {
results[i] = database.DeliveryResult{
DeliveryID: deliveries[i].ID, AttemptNum: 1,
}
}
require.NoError(t, db.CreateInBatches(results, 500).Error)
}
// seedBareEvents stores count events created at the given time, with
// no deliveries.
func seedBareEvents(
t *testing.T,
db *gorm.DB,
webhookID string,
count int,
createdAt time.Time,
) {
t.Helper()
events := make([]database.Event, count)
for i := range events {
events[i] = database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
events[i].CreatedAt = createdAt
}
require.NoError(t, db.CreateInBatches(events, 500).Error)
}
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
// larger than one transaction's batch removes every expired event with
// its deliveries and delivery results, keeps the recent event, and
// adds what it removed to the event and target totals, so the totals
// within retention match the rows still stored.
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
expired := database.ExportReapBatchSize + 1
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour), delivered, failed)
// One recent event, delivered to the first target.
recent := seedEventChain(t, db, webhookID, time.Now())
require.NoError(t, db.Model(&database.Delivery{}).
Where("id = ?", recent.deliveryID).
Update("target_id", delivered).Error)
// The totals storing those rows would have left.
n := int64(expired)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: n, Failed: n,
}))
env.reaper.ExportSweep(context.Background())
// Only the recent event's rows are left.
for _, model := range []any{
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
} {
var count int64
require.NoError(t, db.Model(model).Count(&count).Error)
assert.Equal(t, int64(1), count, "%T rows left", model)
}
assertChainPresent(t, db, recent)
eventTotals := readEventTotals(t, db)
assert.Equal(t, database.EventTotals{
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
}, eventTotals)
targetTotals := readTargetTotals(t, db)
assert.Equal(t, map[string]database.TargetTotals{
delivered: {
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
DeliveriesRemoved: n,
},
failed: {
TargetID: failed, Deliveries: n, Failed: n,
DeliveriesRemoved: n, FailedRemoved: n,
},
}, targetTotals)
// A sweep with nothing left to remove changes nothing.
env.reaper.ExportSweep(context.Background())
assert.Equal(t, eventTotals, readEventTotals(t, db))
assert.Equal(t, targetTotals, readTargetTotals(t, db))
}
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
// of several batches lets other writers in between its batches: an
// event stored once the first batch is deleted is stored while expired
// events are still left, not only after the prune has finished.
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Three batches of expired events, with nothing else stored: only
// the number of batches matters here.
expired := 3 * database.ExportReapBatchSize
seedBareEvents(t, db, webhookID, expired,
time.Now().Add(-40*24*time.Hour))
cutoff := time.Now().Add(-30 * 24 * time.Hour)
countExpired := func() int64 {
var count int64
require.NoError(t, db.Model(&database.Event{}).
Where("created_at < ?", cutoff).
Count(&count).Error)
return count
}
pruned := make(chan struct{})
go func() {
defer close(pruned)
env.reaper.ExportSweep(context.Background())
}()
t.Cleanup(func() { <-pruned })
// Every stored event is expired until the write below.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < int64(expired)
}, 10*time.Second, 10*time.Millisecond)
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
}
require.NoError(t, db.Create(event).Error)
assert.Positive(t, countExpired(),
"the event was stored only after the whole prune")
<-pruned
assert.Zero(t, countExpired())
var stored database.Event
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
}
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
// stopping the reaper during a prune of several batches returns
// between two batches, well inside the stop timeout, leaving the
// remaining expired events for the next sweep, and that the totals
// match the rows left.
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), 30)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
// Two batches and one more of expired events, a few of them with a
// delivered and a failed delivery for the target totals to count.
// Most carry nothing else, to keep the test quick.
const withDeliveries = 10
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
delivered, failed := uuid.New().String(), uuid.New().String()
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
delivered, failed)
seedBareEvents(t, db, webhookID,
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
n := int64(2*database.ExportReapBatchSize + 1)
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: n,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: delivered, Deliveries: withDeliveries,
Delivered: withDeliveries,
}))
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
TargetID: failed, Deliveries: withDeliveries,
Failed: withDeliveries,
}))
env.reaper.ExportSetInterval(time.Millisecond)
env.reaper.ExportStart()
// Stop once the first batch is deleted. The stop lands in the pause
// after it, or at worst during the second batch, so at least the
// last event is left.
require.Eventually(t, func() bool {
var count int64
err := db.Model(&database.Event{}).Count(&count).Error
return err == nil && count < n
}, 10*time.Second, 10*time.Millisecond)
// The app's stop timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, env.reaper.ExportStop(ctx))
var events int64
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
assert.Positive(t, events, "the stop waited for the whole prune")
eventTotals := readEventTotals(t, db)
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
targetTotals := readTargetTotals(t, db)
require.Len(t, targetTotals, 2)
for target, totals := range targetTotals {
var deliveries, failures int64
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ?", target).
Count(&deliveries).Error)
require.NoError(t, db.Model(&database.Delivery{}).
Where("target_id = ? AND status = ?",
target, database.DeliveryStatusFailed).
Count(&failures).Error)
assert.Equal(t, deliveries,
totals.Deliveries-totals.DeliveriesRemoved, target)
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
target)
}
}
+1 -15
View File
@@ -35,8 +35,7 @@ var errInvalidCachedDBType = errors.New(
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, and DeliveryResults.
// running totals of them (EventTotals, TargetTotals).
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
@@ -296,7 +295,6 @@ func (m *WebhookDBManager) openDB(
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -307,18 +305,6 @@ func (m *WebhookDBManager) openDB(
) )
} }
// A new database gets its row of event totals, all zero. Target
// totals rows are created by the first delivery to each target.
err = db.FirstOrCreate(&EventTotals{}).Error
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"creating event totals for webhook database %s: %w",
webhookID, err,
)
}
m.log.Info( m.log.Info(
"opened per-webhook database", "opened per-webhook database",
"webhook_id", webhookID, "webhook_id", webhookID,
-177
View File
@@ -1,177 +0,0 @@
package delivery_test
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// targetTotals reads one target's totals from a webhook database, all
// zero when it has no row.
func targetTotals(
t *testing.T, db *gorm.DB, targetID string,
) database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Where("target_id = ?", targetID).
Find(&rows).Error)
if len(rows) == 0 {
return database.TargetTotals{TargetID: targetID}
}
return rows[0]
}
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed, and one more
// on its target's delivered or failed total.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel()
tests := []struct {
status database.DeliveryStatus
finished bool
delivered int64
failed int64
}{
{database.DeliveryStatusRetrying, false, 0, 0},
{database.DeliveryStatusDelivered, true, 1, 0},
{database.DeliveryStatusFailed, true, 0, 1},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
before := time.Now()
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, tt.status,
))
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, tt.status, stored.Status)
if tt.finished {
require.NotNil(t, stored.FinishedAt)
assert.False(t, stored.FinishedAt.Before(before))
} else {
assert.Nil(t, stored.FinishedAt)
}
assert.Equal(t, database.TargetTotals{
TargetID: targetID,
Delivered: tt.delivered,
Failed: tt.failed,
}, targetTotals(t, db, targetID))
})
}
}
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
// delivery retention deleted while the engine still held it. Failing
// it afterwards writes no row, so it adds no failure either: retention
// has already counted what it removed.
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
require.NoError(t, db.Unscoped().
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, database.DeliveryStatusFailed,
))
assert.Equal(t, database.TargetTotals{TargetID: targetID},
targetTotals(t, db, targetID))
}
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
// delivery settled a second time, as recovery can do when a worker has
// settled it since recovery read it. Neither status writes over the
// first, and the totals do not move.
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
t *testing.T,
) {
t.Parallel()
finished := []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}
for _, first := range finished {
t.Run(string(first), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
// The delivery as recovery read it, before the worker
// settled it.
readBefore := d
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, first,
))
var settled database.Delivery
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
require.NotNil(t, settled.FinishedAt)
totals := targetTotals(t, db, targetID)
for _, again := range finished {
stale := readBefore
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &stale, again,
))
}
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, first, stored.Status)
require.NotNil(t, stored.FinishedAt)
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
assert.Equal(t, totals, targetTotals(t, db, targetID))
})
}
}
+4 -45
View File
@@ -531,11 +531,6 @@ func (e *Engine) processRetryTask(
return return
} }
// Set before anything below can fail the delivery: the failure is
// added to this target's totals.
d.EventID = task.EventID
d.TargetID = task.TargetID
if d.Status != database.DeliveryStatusRetrying { if d.Status != database.DeliveryStatusRetrying {
e.log.Debug( e.log.Debug(
"skipping retry for delivery "+ "skipping retry for delivery "+
@@ -567,6 +562,8 @@ func (e *Engine) processRetryTask(
} }
target := buildTargetFromTask(task) target := buildTargetFromTask(task)
d.EventID = task.EventID
d.TargetID = task.TargetID
d.Event = event d.Event = event
d.Target = target d.Target = target
@@ -1557,9 +1554,8 @@ func (e *Engine) updateDeliveryStatus(
targetType database.TargetType, targetType database.TargetType,
status database.DeliveryStatus, status database.DeliveryStatus,
) error { ) error {
err := webhookDB.Transaction(func(tx *gorm.DB) error { err := webhookDB.Model(d).
return writeDeliveryStatus(tx, d, status) Update("status", status).Error
})
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"updating delivery %s to status %s: %w", "updating delivery %s to status %s: %w",
@@ -1578,43 +1574,6 @@ func (e *Engine) updateDeliveryStatus(
return nil return nil
} }
// writeDeliveryStatus writes a delivery's new status. A delivery that
// becomes delivered or failed also gets the time it finished, and is
// added to its target's delivered or failed total. That write changes
// only a delivery not yet delivered or failed, and the total moves
// only when it changed a row: retention may have deleted the delivery
// while the engine was working on it, and a recovery path may settle
// a delivery that a worker has already settled.
func writeDeliveryStatus(
tx *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
if !status.Terminal() {
return tx.Model(d).Update("status", status).Error
}
res := tx.Model(d).
Where("status NOT IN ?", []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}).
Updates(map[string]any{
"status": status,
"finished_at": time.Now(),
})
if res.Error != nil || res.RowsAffected == 0 {
return res.Error
}
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
return database.AddTargetTotals(tx, add)
}
// settleStatus moves a delivery to its outcome status and reports a // settleStatus moves a delivery to its outcome status and reports a
// failed write through bookkeepingFailed, which leaves the row // failed write through bookkeepingFailed, which leaves the row
// recoverable. It exists so the target call sites read as one // recoverable. It exists so the target call sites read as one
-3
View File
@@ -57,10 +57,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
&database.Event{}, &database.Event{},
&database.Delivery{}, &database.Delivery{},
&database.DeliveryResult{}, &database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
)) ))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db return db
} }
-10
View File
@@ -150,16 +150,6 @@ func (e *Engine) ExportDeliverSlack(
) )
} }
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
// target type, so no metric moves.
func (e *Engine) ExportUpdateDeliveryStatus(
webhookDB *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
return e.updateDeliveryStatus(webhookDB, d, "", status)
}
// ExportProcessNewTask exposes processNewTask. // ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask( func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task, ctx context.Context, task *Task,
-32
View File
@@ -418,38 +418,6 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
assert.Zero(t, s.Engine.ExportInflightHeld()) assert.Zero(t, s.Engine.ExportInflightHeld())
} }
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
// that the failure of a retry abandoned because its target is gone is
// added to that target's own totals, not to a row with no target.
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
var hits atomic.Int64
task, targetID := tRetryChainSetup(
t, s, "gone-counted", &hits,
)
require.NoError(t, s.MainDB.Delete(
&database.Target{}, "id = ?", targetID,
).Error)
s.Engine.ExportProcessRetryTask(
context.Background(), &task,
)
var rows []database.TargetTotals
require.NoError(t, s.WebhookDB.Find(&rows).Error)
assert.Equal(t, []database.TargetTotals{
{TargetID: targetID, Failed: 1},
}, rows)
}
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's // TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
// mutation check: a liveness check that refused every retry would pass // mutation check: a liveness check that refused every retry would pass
// the test above and break every retry there is. // the test above and break every retry there is.
+1 -1
View File
@@ -7,7 +7,7 @@
// SQL — parameters and all — for every statement that returns an // SQL — parameters and all — for every statement that returns an
// error, including gorm.ErrRecordNotFound. Two of this service's // error, including gorm.ErrRecordNotFound. Two of this service's
// lookups miss by design on unauthenticated routes: the entrypoint // lookups miss by design on unauthenticated routes: the entrypoint
// lookup on /h/{uuid}, whose path segment the client picks // lookup on /webhook/{uuid}, whose path segment the client picks
// outright, and the user lookup behind the login form, whose username // outright, and the user lookup behind the login form, whose username
// the client picks outright. Under the default logger each of those // the client picks outright. Under the default logger each of those
// misses printed an unbounded, attacker-chosen string, at no level the // misses printed an unbounded, attacker-chosen string, at no level the
+3 -49
View File
@@ -2,56 +2,19 @@ package handlers
import ( import (
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings"
"unicode"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/logfield"
"sneak.berlin/go/webhooker/internal/middleware"
) )
// loginDestination returns where a successful login sends the
// browser: next when it is a path on this site, otherwise "/", which
// leads to the webhook list.
//
// A browser reads "//host" as another site, reads "\" as "/", and
// drops tabs and newlines before reading at all. So the value must
// start with exactly one "/" and hold no "\" or control character
// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It
// is checked after percent-decoding, so an encoded form of any of
// these is refused too.
func loginDestination(next string) string {
if len(next) > middleware.MaxNextBytes {
return "/"
}
decoded, err := url.PathUnescape(next)
if err != nil ||
!strings.HasPrefix(decoded, "/") ||
strings.HasPrefix(decoded, "//") ||
strings.Contains(decoded, `\`) ||
strings.ContainsFunc(decoded, unicode.IsControl) {
return "/"
}
return next
}
// HandleLoginPage returns a handler for the login page (GET) // HandleLoginPage returns a handler for the login page (GET)
func (h *Handlers) HandleLoginPage() http.HandlerFunc { func (h *Handlers) HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
next := loginDestination(
r.URL.Query().Get(middleware.NextParam),
)
// Check if already logged in // Check if already logged in
sess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) { if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect( //nolint:gosec // checked by loginDestination http.Redirect(w, r, "/", http.StatusSeeOther)
w, r, next, http.StatusSeeOther,
)
return return
} }
@@ -59,7 +22,6 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
// Render login page // Render login page
data := map[string]any{ data := map[string]any{
tmplKeyError: "", tmplKeyError: "",
tmplKeyNext: next,
} }
h.renderTemplate(w, r, "login.html", data) h.renderTemplate(w, r, "login.html", data)
@@ -115,13 +77,8 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"user_id", user.ID, "user_id", user.ID,
) )
// The form value is the client's to set, so it is checked // Redirect to home page
// again here rather than trusted from the rendered page. http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect( //nolint:gosec // checked by loginDestination
w, r,
loginDestination(r.PostFormValue(middleware.NextParam)),
http.StatusSeeOther,
)
} }
} }
@@ -134,9 +91,6 @@ func (h *Handlers) renderLoginError(
) { ) {
data := map[string]any{ data := map[string]any{
tmplKeyError: msg, tmplKeyError: msg,
tmplKeyNext: loginDestination(
r.PostFormValue(middleware.NextParam),
),
} }
w.WriteHeader(status) w.WriteHeader(status)
-196
View File
@@ -454,202 +454,6 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
) )
} }
// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of
// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login
// returns to is client-chosen, so anything that is not a path on this
// site, plain or percent-encoded, must land on "/", the webhook list.
func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
cases := []struct{ next, want string }{
{"/hook/abc/events?page=2", "/hook/abc/events?page=2"},
{"", "/"},
{"https://evil.example/", "/"},
{"https%3A%2F%2Fevil.example%2F", "/"},
{"//evil.example/", "/"},
{"%2F%2Fevil.example/", "/"},
{"/%2Fevil.example/", "/"},
{`/\evil.example/`, "/"},
{"%2F%5Cevil.example/", "/"},
{"/%5Cevil.example/", "/"},
{`/a/../\evil.example/`, "/"},
{"/\t/evil.example/", "/"},
{"/%09/evil.example/", "/"},
{"/\n/evil.example/", "/"},
{"/%0A/evil.example/", "/"},
{"/\r/evil.example/", "/"},
{"/%0D/evil.example/", "/"},
{"/%00/evil.example/", "/"},
{"/%7F/evil.example/", "/"},
{"%252F%252Fevil.example/", "/"},
{"https%253A%252F%252Fevil.example%252F", "/"},
{"/" + strings.Repeat("a", 4096), "/"},
}
for _, c := range cases {
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", operatorPassword)
form.Set("next", c.next)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
assert.Equal(
t, c.want, w.Header().Get("Location"), "next %q", c.next,
)
}
}
// TestLogin_WrongPasswordKeepsTheRequestedPage: after a wrong
// password the login page is shown again with the same next, so the
// next attempt still returns to the page that was asked for.
func TestLogin_WrongPasswordKeepsTheRequestedPage(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
form := url.Values{}
form.Set("username", operatorUser)
form.Set("password", "wrong")
form.Set("next", "/hook/abc")
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = sharedProxyPeer
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(
t, w.Body.String(), `name="next" value="/hook/abc"`,
)
}
// loginPageGet renders the login page as a GET with the given next
// value and cookies.
func loginPageGet(
h *handlers.Handlers, next string, cookies []*http.Cookie,
) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet,
"/pages/login?"+url.Values{"next": {next}}.Encode(), nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleLoginPage().ServeHTTP(w, req)
return w
}
// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page
// itself: its form carries the requested page only when it is a path
// on this site, and a browser already logged in goes straight there,
// or to "/" when it is not.
func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
)
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
assert.Contains(
t, loginPageGet(h, "/hook/abc", nil).Body.String(),
`name="next" value="/hook/abc"`,
)
assert.Contains(
t, loginPageGet(h, "//evil.example/", nil).Body.String(),
`name="next" value="/"`,
)
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
cases := []struct{ next, want string }{
{"/hook/abc", "/hook/abc"},
{"//evil.example/", "/"},
{`/\evil.example/`, "/"},
}
for _, c := range cases {
w := loginPageGet(h, c.next, cookies)
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
assert.Equal(
t, c.want, w.Header().Get("Location"), "next %q", c.next,
)
}
}
// TestLoginPage_HasNoLinkToItself: the navigation bar on the login
// page offers no link to the login page.
func TestLoginPage_HasNoLinkToItself(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
w := loginPageGet(h, "", nil)
require.Equal(t, http.StatusOK, w.Code)
assert.NotContains(t, w.Body.String(), `href="/pages/login"`)
}
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly // TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
// database.MaxUsernameBytes still fits in the session cookie. Past // database.MaxUsernameBytes still fits in the session cookie. Past
// what the cookie can carry, a correct login answers 500. // what the cookie can carry, a correct login answers 500.
+4 -14
View File
@@ -299,9 +299,8 @@ func countInFlightDeliveries(
return count, err return count, err
} }
// createReplayDelivery writes the new pending delivery row, adds it to // createReplayDelivery writes the new pending delivery row and returns
// its target's totals in the same transaction, and returns the task // the task that carries it to the delivery engine.
// that carries it to the delivery engine.
// //
// The row is written with associations omitted, and neither Event nor // The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would // Target is populated on it: GORM's SaveBeforeAssociations would
@@ -320,16 +319,7 @@ func createReplayDelivery(
Status: database.DeliveryStatusPending, Status: database.DeliveryStatusPending,
} }
err := webhookDB.Transaction(func(tx *gorm.DB) error { err := webhookDB.Omit(clause.Associations).Create(dlv).Error
err := tx.Omit(clause.Associations).Create(dlv).Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, database.TargetTotals{
TargetID: dlv.TargetID, Deliveries: 1,
})
})
if err != nil { if err != nil {
return delivery.Task{}, err return delivery.Task{}, err
} }
@@ -372,7 +362,7 @@ func (h *Handlers) finishReplay(
webhook database.Webhook, webhook database.Webhook,
code replayOutcomeCode, code replayOutcomeCode,
) { ) {
dest := "/hook/" + webhook.ID + "/events?" + dest := "/source/" + webhook.ID + "/logs?" +
replayOutcomeParam + "=" + string(code) replayOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string: // The page is read from the form rather than the query string:
+8 -8
View File
@@ -138,7 +138,7 @@ func postReplay(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/hook/"+webhookID+"/deliveries/"+ "/source/"+webhookID+"/deliveries/"+
deliveryID+"/replay", deliveryID+"/replay",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=queued", "/source/"+wh.ID+"/logs?replay=queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=target-deleted", "/source/"+wh.ID+"/logs?replay=target-deleted",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code) require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=target-missing", "/source/"+wh.ID+"/logs?replay=target-missing",
missing.Header().Get("Location"), missing.Header().Get("Location"),
) )
} }
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code) require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal( require.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=queued", "/source/"+wh.ID+"/logs?replay=queued",
first.Header().Get("Location"), first.Header().Get("Location"),
) )
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code) require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=in-flight", "/source/"+wh.ID+"/logs?replay=in-flight",
second.Header().Get("Location"), second.Header().Get("Location"),
) )
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code) require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?replay=not-terminal", "/source/"+wh.ID+"/logs?replay=not-terminal",
pending.Header().Get("Location"), pending.Header().Get("Location"),
) )
} }
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains( assert.Contains(
t, body, t, body,
`action="/hook/`+wh.ID+`/deliveries/`+ `action="/source/`+wh.ID+`/deliveries/`+
original.ID+`/replay"`, original.ID+`/replay"`,
) )
assert.Contains(t, body, `method="POST"`) assert.Contains(t, body, `method="POST"`)
+4 -4
View File
@@ -64,8 +64,8 @@ func fetchEventBody(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/hook/"+url.PathEscape(sourceID)+ "/source/"+url.PathEscape(sourceID)+
"/events/"+url.PathEscape(eventID)+"/body", "/logs/"+url.PathEscape(eventID)+"/body",
nil, nil,
) )
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page := renderSourceLogsPage(t, h, sess, big.ID) page := renderSourceLogsPage(t, h, sess, big.ID)
assert.Contains( assert.Contains(
t, page, t, page,
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body", "/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
) )
small := seedWebhook(t, db) small := seedWebhook(t, db)
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
page = renderSourceLogsPage(t, h, sess, small.ID) page = renderSourceLogsPage(t, h, sess, small.ID)
assert.NotContains( assert.NotContains(
t, page, t, page,
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body", "/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
) )
} }
+1 -1
View File
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
webhook database.Webhook, webhook database.Webhook,
code resubmitOutcomeCode, code resubmitOutcomeCode,
) { ) {
dest := "/hook/" + webhook.ID + "/events?" + dest := "/source/" + webhook.ID + "/logs?" +
resubmitOutcomeParam + "=" + string(code) resubmitOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string: // The page is read from the form rather than the query string:
+6 -6
View File
@@ -65,7 +65,7 @@ func postResubmit(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/hook/"+webhookID+"/events/"+eventID+"/resubmit", "/source/"+webhookID+"/events/"+eventID+"/resubmit",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
), ),
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=queued", "/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=queued", "/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"), w.Header().Get("Location"),
"a resubmit must not be refused while an earlier "+ "a resubmit must not be refused while an earlier "+
"one is in flight", "one is in flight",
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=queued", "/source/"+wh.ID+"/logs?resubmit=queued",
w.Header().Get("Location"), w.Header().Get("Location"),
"an inactive target is skipped, not an error", "an inactive target is skipped, not an error",
) )
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(
t, t,
"/hook/"+wh.ID+"/events?resubmit=no-targets", "/source/"+wh.ID+"/logs?resubmit=no-targets",
w.Header().Get("Location"), w.Header().Get("Location"),
) )
@@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
) )
assert.Contains( assert.Contains(
t, body, t, body,
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit", "/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
"the log must offer the resubmit action per event", "the log must offer the resubmit action per event",
) )
} }
-25
View File
@@ -4,9 +4,7 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -71,29 +69,6 @@ func (s *Handlers) LoadEventLogViewsForTest(
return views return views
} }
// WebhookStatsForTest returns the figures the statistics pane on a
// webhook's page shows, from the webhook's entrypoints and targets
// loaded as that page loads them.
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
var entrypoints []database.Entrypoint
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
var targets []database.Target
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
return s.loadWebhookStats(webhookID, entrypoints, targets)
}
// FinishedByTargetForTest exposes finishedByTarget for use in the
// handlers_test package.
func FinishedByTargetForTest(
webhookDB *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
return finishedByTarget(webhookDB, since)
}
// AddTemplateForTest registers a template under a page name so that // AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a // the handlers_test package can drive the render path with a
// template of its own. // template of its own.
+1 -1
View File
@@ -306,7 +306,7 @@ func postWebhook(
t.Helper() t.Helper()
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x", context.Background(), http.MethodPost, "/webhook/x",
strings.NewReader("{}"), strings.NewReader("{}"),
) )
+15 -19
View File
@@ -13,6 +13,7 @@ import (
"sync/atomic" "sync/atomic"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
@@ -36,9 +37,6 @@ const (
tmplKeyError = "Error" tmplKeyError = "Error"
// tmplKeyWebhook is the template data key for a webhook. // tmplKeyWebhook is the template data key for a webhook.
tmplKeyWebhook = "Webhook" tmplKeyWebhook = "Webhook"
// tmplKeyNext is the template data key for the page to return
// to after login.
tmplKeyNext = "Next"
) )
// errInvalidPassword is returned when a password does not match. // errInvalidPassword is returned when a password does not match.
@@ -56,6 +54,7 @@ type HandlersParams struct {
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config
Database *database.Database Database *database.Database
WebhookDBMgr *database.WebhookDBManager WebhookDBMgr *database.WebhookDBManager
Healthcheck *healthcheck.Healthcheck Healthcheck *healthcheck.Healthcheck
@@ -94,22 +93,18 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the // parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared // embedded FS. Each page template is combined with the shared
// base, htmlheader, and navbar templates, and with any further files // base, htmlheader, and navbar templates. The page file must be
// the page includes. The page file must be listed first so that its // listed first so that its root action ({{template "base" .}})
// root action ({{template "base" .}}) becomes the template set's entry // becomes the template set's entry point.
// point. func parsePageTemplate(pageFile string) *template.Template {
func parsePageTemplate(
pageFile string, included ...string,
) *template.Template {
files := append([]string{
pageFile,
"base.html",
"htmlheader.html",
"navbar.html",
}, included...)
return template.Must( return template.Must(
template.ParseFS(templates.Templates, files...), template.ParseFS(
templates.Templates,
pageFile,
"base.html",
"htmlheader.html",
"navbar.html",
),
) )
} }
@@ -136,9 +131,10 @@ func New(
s.templates = map[string]*template.Template{ s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"), "login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"), "profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"), "sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"), "sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"), "source_detail.html": parsePageTemplate("source_detail.html"),
"source_edit.html": parsePageTemplate("source_edit.html"), "source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"), "source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"), "target_edit.html": parsePageTemplate("target_edit.html"),
+15 -6
View File
@@ -83,16 +83,25 @@ func newTestApp(
) *fxtest.App { ) *fxtest.App {
t.Helper() t.Helper()
return newTestAppWithConfig(
t, &config.Config{DataDir: t.TempDir()}, targets...,
)
}
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
func newTestAppWithConfig(
t *testing.T,
cfg *config.Config,
targets ...any,
) *fxtest.App {
t.Helper()
return fxtest.New( return fxtest.New(
t, t,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
func() *config.Config { func() *config.Config { return cfg },
return &config.Config{
DataDir: t.TempDir(),
}
},
database.New, database.New,
database.NewWebhookDBManager, database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
@@ -176,7 +185,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
assert.Equal(t, http.StatusSeeOther, w2.Code) assert.Equal(t, http.StatusSeeOther, w2.Code)
assert.Equal( assert.Equal(
t, "/hooks", w2.Header().Get("Location"), t, "/sources", w2.Header().Get("Location"),
) )
} }
+2 -2
View File
@@ -5,13 +5,13 @@ import (
) )
// HandleIndex returns a handler for the root path that redirects // HandleIndex returns a handler for the root path that redirects
// based on authentication state: authenticated users go to /hooks // based on authentication state: authenticated users go to /sources
// (the dashboard), unauthenticated users go to the login page. // (the dashboard), unauthenticated users go to the login page.
func (s *Handlers) HandleIndex() http.HandlerFunc { func (s *Handlers) HandleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.Get(r) sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) { if err == nil && s.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/hooks", http.StatusSeeOther) http.Redirect(w, r, "/sources", http.StatusSeeOther)
return return
} }
+5 -5
View File
@@ -4,7 +4,7 @@ package handlers_test
// this package reach a value an UNAUTHENTICATED client picks outright // this package reach a value an UNAUTHENTICATED client picks outright
// and of a length it picks outright: // and of a length it picks outright:
// //
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose // - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
// path segment matched no stored entrypoint and so is bounded by // path segment matched no stored entrypoint and so is bounded by
// nothing; // nothing;
// - the failed-login DEBUG lines, whose username is a form field. // - the failed-login DEBUG lines, whose username is a form field.
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
// route pattern. // route pattern.
func receiverRouter(h *handlers.Handlers) *chi.Mux { func receiverRouter(h *handlers.Handlers) *chi.Mux {
router := chi.NewRouter() router := chi.NewRouter()
router.Post("/h/{uuid}", h.HandleWebhook()) router.Post("/webhook/{uuid}", h.HandleWebhook())
return router return router
} }
// postReceiver sends one POST at /h/<segment>. // postReceiver sends one POST at /webhook/<segment>.
// //
// RawPath is cleared after parsing so chi routes on the decoded path // RawPath is cleared after parsing so chi routes on the decoded path
// and the handler sees the raw bytes rather than their percent-escaped // and the handler sees the raw bytes rather than their percent-escaped
@@ -210,7 +210,7 @@ func postReceiver(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodPost, http.MethodPost,
"/h/"+url.PathEscape(segment), "/webhook/"+url.PathEscape(segment),
strings.NewReader(""), strings.NewReader(""),
) )
req.URL.RawPath = "" req.URL.RawPath = ""
@@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
http.StatusServiceUnavailable, http.StatusServiceUnavailable,
postLoginAtPath( postLoginAtPath(
t, h, t, h,
"/hook/"+url.PathEscape( "/source/"+url.PathEscape(
oversizedFill(fill), oversizedFill(fill),
)+"/login", )+"/login",
), ),
+1 -4
View File
@@ -160,10 +160,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
"handler must not be reached for unauthenticated request", "handler must not be reached for unauthenticated request",
) )
assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(t, "/pages/login", w.Header().Get("Location"))
t, "/pages/login?next=%2Fuser%2Ftestuser",
w.Header().Get("Location"),
)
} }
// passwordChangeRequest builds a POST request to the password-change // passwordChangeRequest builds a POST request to the password-change
+1 -1
View File
@@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) {
body := strings.Repeat("é", 1024) body := strings.Repeat("é", 1024)
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/h/x", context.Background(), http.MethodPost, "/webhook/x",
strings.NewReader(body), strings.NewReader(body),
) )
+130
View File
@@ -0,0 +1,130 @@
package handlers
import (
"net/http"
"net/netip"
"strconv"
"strings"
"sneak.berlin/go/webhooker/internal/config"
)
// notSet is what the Settings page shows for a value that is empty.
const notSet = "not set"
// settingRow is one line of the Settings page: an environment
// variable, what it controls, and the value the server loaded for it.
type settingRow struct {
Name string
Description string
Value string
}
// HandleSettings returns a handler for the read-only Settings page,
// which lists the configuration the server started with.
func (h *Handlers) HandleSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.renderTemplate(w, r, "settings.html", map[string]any{
"Settings": settingRows(h.params.Config),
})
}
}
// settingRows lists every field of cfg under the environment variable
// it is read from, in the order of the README's configuration table.
// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values
// never reach the page: only whether they are set.
func settingRows(cfg *config.Config) []settingRow {
metricsUsername := cfg.MetricsUsername
if metricsUsername == "" {
metricsUsername = notSet
}
return []settingRow{
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
{
"BIND_ADDRESS",
"IP address the HTTP listener binds",
cfg.BindAddress,
},
{
"DATA_DIR",
"Directory for all SQLite databases",
cfg.DataDir,
},
{
"DEBUG",
"Enable debug logging",
strconv.FormatBool(cfg.Debug),
},
{
"MAINTENANCE_MODE",
"Report maintenanceMode: true in the healthcheck JSON. " +
"It does not change how any request is served",
strconv.FormatBool(cfg.MaintenanceMode),
},
{
"METRICS_USERNAME",
"Basic auth username for /metrics",
metricsUsername,
},
{
"METRICS_PASSWORD",
"Basic auth password for /metrics",
setOrNotSet(cfg.MetricsPassword),
},
{
"SENTRY_DSN",
"Error reporting DSN. Unset leaves error reporting off",
setOrNotSet(cfg.SentryDSN),
},
{
"RETENTION_SWEEP_INTERVAL",
"How often the retention reaper and archive sweeper run",
cfg.RetentionSweepInterval.String(),
},
{
"SESSION_IDLE_TIMEOUT",
"Idle session timeout. Zero or negative disables idle " +
"expiry",
cfg.SessionIdleTimeout.String(),
},
{
"RECEIVER_RATE_LIMIT",
"Receiver requests per minute per IP per entrypoint " +
"(10x that per IP across the route)",
strconv.Itoa(cfg.ReceiverRateLimit),
},
{
"TRUSTED_PROXIES",
"CIDRs whose forwarded headers are trusted",
cidrList(cfg.TrustedProxies),
},
{
"ALLOWED_EGRESS_CIDRS",
"CIDRs that delivery targets may reach despite the " +
"SSRF blocklist",
cidrList(cfg.AllowedEgressCIDRs),
},
}
}
// setOrNotSet is how the Settings page shows a credential: whether it
// has a value, never the value itself.
func setOrNotSet(value string) string {
if value == "" {
return notSet
}
return "set"
}
// cidrList renders a CIDR list setting for the Settings page.
func cidrList(prefixes []netip.Prefix) string {
if len(prefixes) == 0 {
return "none"
}
return strings.Join(config.PrefixStrings(prefixes), ", ")
}
+139
View File
@@ -0,0 +1,139 @@
package handlers_test
import (
"context"
"html"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// settingsShown renders the Settings page over cfg as a logged-in user
// and returns the value it shows for each variable name, plus the
// whole page.
func settingsShown(
t *testing.T, cfg *config.Config,
) (map[string]string, string) {
t.Helper()
var h *handlers.Handlers
var sess *session.Session
app := newTestAppWithConfig(t, cfg, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/settings", nil,
)
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
req.AddCookie(c)
}
w := httptest.NewRecorder()
h.HandleSettings().ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
row := regexp.MustCompile(
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
)
shown := map[string]string{}
for _, match := range row.FindAllStringSubmatch(body, -1) {
shown[match[1]] = html.UnescapeString(match[2])
}
return shown, body
}
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
t.Parallel()
const metricsPassword = "metrics-password-1f9a"
// No two rows show the same value: DEBUG and MAINTENANCE_MODE, and
// METRICS_PASSWORD and SENTRY_DSN, get opposite values, so each row
// is checked against its own field.
cfg := &config.Config{
DataDir: t.TempDir(),
Debug: true,
MaintenanceMode: false,
Environment: config.EnvironmentDev,
MetricsUsername: "scraper",
MetricsPassword: metricsPassword,
Port: 9123,
SentryDSN: "",
BindAddress: "192.0.2.10",
RetentionSweepInterval: 17 * time.Minute,
SessionIdleTimeout: 3 * time.Hour,
ReceiverRateLimit: 77,
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("10.1.0.0/16"),
},
AllowedEgressCIDRs: []netip.Prefix{
netip.MustParsePrefix("192.168.5.0/24"),
netip.MustParsePrefix("fd00::/8"),
},
}
shown, body := settingsShown(t, cfg)
assert.Equal(t, map[string]string{
"WEBHOOKER_ENVIRONMENT": "dev",
"PORT": "9123",
"BIND_ADDRESS": "192.0.2.10",
"DATA_DIR": cfg.DataDir,
"DEBUG": "true",
"MAINTENANCE_MODE": "false",
"METRICS_USERNAME": "scraper",
"METRICS_PASSWORD": "set",
"SENTRY_DSN": "not set",
"RETENTION_SWEEP_INTERVAL": "17m0s",
"SESSION_IDLE_TIMEOUT": "3h0m0s",
"RECEIVER_RATE_LIMIT": "77",
"TRUSTED_PROXIES": "10.1.0.0/16",
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
}, shown)
assert.NotContains(t, body, metricsPassword)
assert.Contains(
t, body, `href="/settings"`,
"the navigation bar links to the page",
)
}
func TestSettingsPageShowsUnsetValues(t *testing.T) {
t.Parallel()
const (
sentryKey = "dsnkey7c2e"
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
)
// SENTRY_DSN is set here and empty in the test above, the opposite
// of METRICS_PASSWORD, so each secret is seen both set and not set.
shown, body := settingsShown(t, &config.Config{
DataDir: t.TempDir(),
SentryDSN: sentryDSN,
})
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
assert.Equal(t, "set", shown["SENTRY_DSN"])
assert.NotContains(t, body, sentryKey)
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
}
+9 -9
View File
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/delete", "/source/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/delete", "/source/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/delete", "/source/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
) )
assert.Empty( assert.Empty(
t, w.Header().Get("Location"), t, w.Header().Get("Location"),
"a failed deletion must not redirect to /hooks", "a failed deletion must not redirect to /sources",
) )
assert.Equal( assert.Equal(
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/delete", "/source/"+wh.ID+"/delete",
cookies, cookies,
map[string]string{paramSourceID: wh.ID}, map[string]string{paramSourceID: wh.ID},
) )
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req) h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code) require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location")) assert.Equal(t, "/sources", w.Header().Get("Location"))
assert.Equal( assert.Equal(
t, int64(0), t, int64(0),
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete", "/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
cookies, cookies,
map[string]string{ map[string]string{
paramSourceID: wh.ID, paramSourceID: wh.ID,
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete", "/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
cookies, cookies,
map[string]string{ map[string]string{
paramSourceID: wh.ID, paramSourceID: wh.ID,
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
) )
req := postRequest( req := postRequest(
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete", "/source/"+wh.ID+"/targets/"+other.ID+"/delete",
cookies, cookies,
map[string]string{ map[string]string{
paramSourceID: wh.ID, paramSourceID: wh.ID,
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/hook/"+f.webhook, "/source/"+f.webhook,
nil, nil,
) )
req.Host = host req.Host = host
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
tc.scheme+"://"+host+"/h/"+fixture.path, tc.scheme+"://"+host+"/webhook/"+fixture.path,
fixture.entrypointURL( fixture.entrypointURL(
t, host, forwardedProto(tc.header), t, host, forwardedProto(tc.header),
), ),
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
assert.Equal( assert.Equal(
t, t,
"https://"+host+"/h/"+fixture.path, "https://"+host+"/webhook/"+fixture.path,
got, got,
"a connection this process terminated with TLS "+ "a connection this process terminated with TLS "+
"outranks a header claiming plaintext", "outranks a header claiming plaintext",
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
"https://"+host+"/h/"+fixture.path, "https://"+host+"/webhook/"+fixture.path,
fixture.entrypointURL( fixture.entrypointURL(
t, host, forwardedProto("HTTPS"), t, host, forwardedProto("HTTPS"),
), ),
+1 -1
View File
@@ -82,7 +82,7 @@ func serveSourceDetailPage(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/hook/"+webhookID, "/source/"+webhookID,
nil, nil,
) )
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
t.Helper() t.Helper()
req := postRequest( req := postRequest(
"/hook/"+webhookID+"/targets/"+targetID+"/delete", "/source/"+webhookID+"/targets/"+targetID+"/delete",
authenticatedCookies( authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername, t, sess, deleteTestUserID, deleteTestUsername,
), ),
+1 -1
View File
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, http.MethodGet,
"/hook/"+webhookID+"/events"+query, "/source/"+webhookID+"/logs"+query,
nil, nil,
) )
+9 -10
View File
@@ -330,7 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
) )
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, "/source/"+webhook.ID, http.StatusSeeOther,
) )
} }
@@ -457,7 +457,6 @@ func (h *Handlers) renderSourceDetail(
"Targets": delivery.NewTargetViews(targets), "Targets": delivery.NewTargetViews(targets),
"Events": events, "Events": events,
"BaseURL": baseURL, "BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
} }
h.renderTemplate(w, r, "source_detail.html", data) h.renderTemplate(w, r, "source_detail.html", data)
@@ -589,7 +588,7 @@ func (h *Handlers) applyWebhookEdit(
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, "/source/"+webhook.ID, http.StatusSeeOther,
) )
} }
@@ -672,7 +671,7 @@ func (h *Handlers) deleteWebhookResources(
return return
} }
http.Redirect(w, r, "/hooks", http.StatusSeeOther) http.Redirect(w, r, "/sources", http.StatusSeeOther)
} }
// commitWebhookDeletion soft-deletes a webhook's entrypoints, // commitWebhookDeletion soft-deletes a webhook's entrypoints,
@@ -1265,7 +1264,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, "/source/"+webhook.ID, http.StatusSeeOther,
) )
} }
} }
@@ -1321,7 +1320,7 @@ func (h *Handlers) processTargetCreate(
// //
// Every field here is read with PostFormValue, not FormValue. // Every field here is read with PostFormValue, not FormValue.
// FormValue falls back to the query string, which would let // FormValue falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...` // `POST /source/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and // configure a target from a value the request line carries — and
// the request line, unlike the body, is what logs, proxies, // the request line, unlike the body, is what logs, proxies,
// Referer headers and error trackers record. // Referer headers and error trackers record.
@@ -1378,7 +1377,7 @@ func (h *Handlers) processTargetCreate(
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, "/source/"+webhook.ID, http.StatusSeeOther,
) )
} }
@@ -1436,7 +1435,7 @@ type targetFormInput struct {
// //
// Every field is read with PostFormValue, not FormValue. FormValue // Every field is read with PostFormValue, not FormValue. FormValue
// falls back to the query string, which would let // falls back to the query string, which would let
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...` // `POST /source/{id}/targets?url=https://hooks.slack.com/...`
// configure a target from a value the request line carries — and the // configure a target from a value the request line carries — and the
// request line, unlike the body, is what logs, proxies, Referer // request line, unlike the body, is what logs, proxies, Referer
// headers and error trackers record. The headers field is under the // headers and error trackers record. The headers field is under the
@@ -1715,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
http.Redirect( http.Redirect(
w, r, w, r,
"/hook/"+webhook.ID, "/source/"+webhook.ID,
http.StatusSeeOther, http.StatusSeeOther,
) )
} }
@@ -1812,7 +1811,7 @@ func (h *Handlers) toggleChildResource(
http.Redirect( http.Redirect(
w, r, w, r,
"/hook/"+webhook.ID, "/source/"+webhook.ID,
http.StatusSeeOther, http.StatusSeeOther,
) )
} }
+7 -7
View File
@@ -105,7 +105,7 @@ func submitCreate(
form.Set("retention_days", *retention) form.Set("retention_days", *retention)
} }
req := formRequest("/hooks/new", cookies, form, nil) req := formRequest("/sources/new", cookies, form, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.HandleSourceCreateSubmit().ServeHTTP(w, req) h.HandleSourceCreateSubmit().ServeHTTP(w, req)
@@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.handlers.HandleSourceCreate().ServeHTTP( env.handlers.HandleSourceCreate().ServeHTTP(
w, getRequest(t, "/hooks/new", env.cookies, nil), w, getRequest(t, "/sources/new", env.cookies, nil),
) )
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
@@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
form.Set("description", description) form.Set("description", description)
form.Set("retention_days", "nonsense") form.Set("retention_days", "nonsense")
req := formRequest("/hooks/new", env.cookies, form, nil) req := formRequest("/sources/new", env.cookies, form, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req) env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
@@ -430,7 +430,7 @@ func submitEdit(
form.Set("retention_days", retention) form.Set("retention_days", retention)
req := formRequest( req := formRequest(
"/hook/"+wh.ID+"/edit", "/source/"+wh.ID+"/edit",
env.cookies, env.cookies,
form, form,
map[string]string{sourceIDParam: wh.ID}, map[string]string{sourceIDParam: wh.ID},
@@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
) )
req := getRequest( req := getRequest(
t, "/hook/"+wh.ID+"/edit", env.cookies, t, "/source/"+wh.ID+"/edit", env.cookies,
map[string]string{sourceIDParam: wh.ID}, map[string]string{sourceIDParam: wh.ID},
) )
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
listW := httptest.NewRecorder() listW := httptest.NewRecorder()
env.handlers.HandleSourceList().ServeHTTP( env.handlers.HandleSourceList().ServeHTTP(
listW, getRequest(t, "/hooks", env.cookies, nil), listW, getRequest(t, "/sources", env.cookies, nil),
) )
require.Equal(t, http.StatusOK, listW.Code) require.Equal(t, http.StatusOK, listW.Code)
@@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
env.handlers.HandleSourceDetail().ServeHTTP( env.handlers.HandleSourceDetail().ServeHTTP(
detailW, detailW,
getRequest( getRequest(
t, "/hook/"+wh.ID, env.cookies, t, "/source/"+wh.ID, env.cookies,
map[string]string{sourceIDParam: wh.ID}, map[string]string{sourceIDParam: wh.ID},
), ),
) )
@@ -76,11 +76,11 @@ func postTargetCreate(
router := chi.NewRouter() router := chi.NewRouter()
router.Use(mw.Logging()) router.Use(mw.Logging())
router.Post( router.Post(
"/hook/{sourceID}/targets", "/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(), env.handlers.HandleTargetCreate(),
) )
target := "/hook/" + webhookID + "/targets" target := "/source/" + webhookID + "/targets"
if query != "" { if query != "" {
target += "?" + query target += "?" + query
} }
@@ -114,7 +114,7 @@ func postTargetCreate(
// regression test for the ingress leak. r.FormValue falls back to the // regression test for the ingress leak. r.FormValue falls back to the
// query string when a field is absent from the POST body, so // query string when a field is absent from the POST body, so
// //
// POST /hook/{id}/targets?url=https://hooks.slack.com/services/... // POST /source/{id}/targets?url=https://hooks.slack.com/services/...
// //
// with an empty url field used to create a working target from a value // with an empty url field used to create a working target from a value
// carried on the request line — where logs, proxies, Referer headers // carried on the request line — where logs, proxies, Referer headers
+2 -2
View File
@@ -47,7 +47,7 @@ type targetEditView struct {
// //
// This page is the one place the full destination URL and header // This page is the one place the full destination URL and header
// values are shown. It is reachable only through the // values are shown. It is reachable only through the
// /hook/{sourceID} route group, which supplies RequireAuth and // /source/{sourceID} route group, which supplies RequireAuth and
// NoCache, and only for a target of a webhook the session's user // NoCache, and only for a target of a webhook the session's user
// owns; masking (delivery.TargetView) is unchanged everywhere else. // owns; masking (delivery.TargetView) is unchanged everywhere else.
func (h *Handlers) HandleTargetEdit() http.HandlerFunc { func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
@@ -163,7 +163,7 @@ func (h *Handlers) applyTargetEdit(
} }
http.Redirect( http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther, w, r, "/source/"+webhook.ID, http.StatusSeeOther,
) )
} }
+9 -9
View File
@@ -42,15 +42,15 @@ const (
func targetRouter(env *sourceTestEnv) *chi.Mux { func targetRouter(env *sourceTestEnv) *chi.Mux {
router := chi.NewRouter() router := chi.NewRouter()
router.Post( router.Post(
"/hook/{sourceID}/targets", "/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(), env.handlers.HandleTargetCreate(),
) )
router.Get( router.Get(
"/hook/{sourceID}/targets/{targetID}/edit", "/source/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEdit(), env.handlers.HandleTargetEdit(),
) )
router.Post( router.Post(
"/hook/{sourceID}/targets/{targetID}/edit", "/source/{sourceID}/targets/{targetID}/edit",
env.handlers.HandleTargetEditSubmit(), env.handlers.HandleTargetEditSubmit(),
) )
@@ -117,7 +117,7 @@ func seedHTTPTarget(
w := serveTarget( w := serveTarget(
env, http.MethodPost, env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", form, "/source/"+webhook.ID+"/targets", form,
) )
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
@@ -188,7 +188,7 @@ func submitTargetEdit(
) *httptest.ResponseRecorder { ) *httptest.ResponseRecorder {
return serveTarget( return serveTarget(
env, http.MethodPost, env, http.MethodPost,
"/hook/"+webhookID+"/targets/"+targetID+"/edit", "/source/"+webhookID+"/targets/"+targetID+"/edit",
form, form,
) )
} }
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
w := serveTarget( w := serveTarget(
env, http.MethodGet, env, http.MethodGet,
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit", "/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
nil, nil,
) )
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
w := serveTarget( w := serveTarget(
env, http.MethodPost, env, http.MethodPost,
"/hook/"+webhook.ID+"/targets/"+target.ID+ "/source/"+webhook.ID+"/targets/"+target.ID+
"/edit?url="+url.QueryEscape(editReplacedURL)+ "/edit?url="+url.QueryEscape(editReplacedURL)+
"&headers="+url.QueryEscape(editAuthHeader), "&headers="+url.QueryEscape(editAuthHeader),
form, form,
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
get := serveTarget( get := serveTarget(
env, http.MethodGet, env, http.MethodGet,
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil, "/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
) )
assert.Equal(t, http.StatusNotFound, get.Code) assert.Equal(t, http.StatusNotFound, get.Code)
@@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s(
w := serveTarget( w := serveTarget(
env, http.MethodGet, env, http.MethodGet,
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil, "/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
) )
assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, http.StatusNotFound, w.Code)
+1 -1
View File
@@ -102,7 +102,7 @@ func createWithRetries(
w := serveTarget( w := serveTarget(
env, http.MethodPost, env, http.MethodPost,
"/hook/"+webhook.ID+"/targets", "/source/"+webhook.ID+"/targets",
createRetriesForm(retries), createRetriesForm(retries),
) )
+10 -55
View File
@@ -54,7 +54,8 @@ func renderPage(
} }
// TestNavbarUsesWebhookTerminology pins the user-visible navigation // TestNavbarUsesWebhookTerminology pins the user-visible navigation
// label to "Webhooks" and its link to the webhook list at /hooks. // label to "Webhooks". The /sources route is deliberately unchanged, so
// the assertion targets the link text rather than the href.
func TestNavbarUsesWebhookTerminology(t *testing.T) { func TestNavbarUsesWebhookTerminology(t *testing.T) {
t.Parallel() t.Parallel()
@@ -94,11 +95,15 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
t, body, ">Sources<", t, body, ">Sources<",
"no user-visible element may still be labelled Sources", "no user-visible element may still be labelled Sources",
) )
assert.Contains(t, body, `href="/hooks"`) assert.Contains(
t, body, `href="/sources"`,
"the /sources route itself must not change",
)
} }
// TestEditPageUsesWebhookTerminology pins the edit page's heading and // TestEditPageUsesWebhookTerminology pins the edit page's heading and
// its back link to the webhook page at /hook/{id}. // its back link. The link's href still points at /source/{id}, which is
// intentional: only user-visible copy changes.
func TestEditPageUsesWebhookTerminology(t *testing.T) { func TestEditPageUsesWebhookTerminology(t *testing.T) {
t.Parallel() t.Parallel()
@@ -125,57 +130,7 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
assert.Contains(t, body, "Edit Webhook") assert.Contains(t, body, "Edit Webhook")
assert.NotContains(t, body, ">Sources<") assert.NotContains(t, body, ">Sources<")
assert.Contains(t, body, `href="/hook/wh-1"`) assert.Contains(t, body, `href="/source/wh-1"`)
}
// TestEventLogPageIsCalledFullEventLog pins the one name the event log
// page at /hook/{id}/events goes by: both links to it on the webhook
// page, and its own heading, read "Full Event Log".
func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
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 := &database.Webhook{Name: "wh", RetentionDays: 14}
webhook.ID = testWebhookID
detailBody := renderPage(
t, h, sess, "source_detail.html", map[string]any{
dataKeyWebhook: webhook,
},
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-secondary">Full Event Log</a>`,
"the button at the top of the webhook page",
)
assert.Contains(
t, detailBody,
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
"the link under recent events",
)
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
dataKeyWebhook: webhook,
"TotalEvents": int64(0),
})
assert.Contains(
t, logBody,
`<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>`,
)
} }
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's // TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
@@ -328,7 +283,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
t, body, t, body,
`<code id="entrypoint-url-ep-1"`, `<code id="entrypoint-url-ep-1"`,
) )
assert.Contains(t, body, "https://hooks.example.com/h/abc123") assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
assert.Contains( assert.Contains(
t, body, t, body,
`hidden data-copy-target="entrypoint-url-ep-1"`, `hidden data-copy-target="entrypoint-url-ep-1"`,
+8 -26
View File
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
"path = ?", entrypointUUID, "path = ?", entrypointUUID,
).First(&entrypoint) ).First(&entrypoint)
if result.Error != nil { if result.Error != nil {
// The receiver is unauthenticated and /h/{uuid} // The receiver is unauthenticated and /webhook/{uuid}
// matches any single segment, so this value is entirely // matches any single segment, so this value is entirely
// client-chosen on exactly the branch where the lookup // client-chosen on exactly the branch where the lookup
// failed. DEBUG is off by default; the cap is what keeps // failed. DEBUG is off by default; the cap is what keeps
@@ -253,12 +253,11 @@ func requestEventSource(
} }
} }
// createAndFanOut writes the event and one pending delivery per target, // createAndFanOut writes the event and one pending delivery per target
// and adds them to the webhook's running totals, in a single // in a single transaction, then hands the tasks to the delivery
// transaction, then hands the tasks to the delivery engine. It is the // engine. It is the only path by which an event and its deliveries are
// only path by which an event and its deliveries are created, so a // created, so a resubmitted event is retried, SSRF-guarded and
// resubmitted event is retried, SSRF-guarded and circuit-broken // circuit-broken exactly as a received one is.
// exactly as a received one is.
// //
// The tasks are returned as well as queued, so a caller can report how // The tasks are returned as well as queued, so a caller can report how
// many targets the event went to. // many targets the event went to.
@@ -298,15 +297,6 @@ func (h *Handlers) createAndFanOut(
return nil, nil, err return nil, nil, err
} }
err = database.AddEventTotals(tx, database.EventTotals{
Events: 1, LastEventAt: &event.CreatedAt,
})
if err != nil {
tx.Rollback()
return nil, nil, err
}
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return nil, nil, fmt.Errorf( return nil, nil, fmt.Errorf(
@@ -365,9 +355,8 @@ func (h *Handlers) finishWebhookResponse(
} }
// buildDeliveryTasks creates one pending delivery per target in the // buildDeliveryTasks creates one pending delivery per target in the
// transaction, adds each to its target's totals, and returns the tasks // transaction and returns the tasks for the delivery engine. The
// for the delivery engine. The caller owns the transaction and rolls // caller owns the transaction and rolls it back on error.
// it back on error.
func buildDeliveryTasks( func buildDeliveryTasks(
tx *gorm.DB, tx *gorm.DB,
event *database.Event, event *database.Event,
@@ -391,13 +380,6 @@ func buildDeliveryTasks(
) )
} }
err = database.AddTargetTotals(tx, database.TargetTotals{
TargetID: targets[i].ID, Deliveries: 1,
})
if err != nil {
return nil, err
}
tasks = append(tasks, delivery.Task{ tasks = append(tasks, delivery.Task{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
EventID: event.ID, EventID: event.ID,
-259
View File
@@ -1,259 +0,0 @@
package handlers
import (
"fmt"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// The spans of the two recent windows the statistics pane reports on:
// the last 10 minutes and the last 24 hours.
const (
shortWindow = 10 * time.Minute
longWindow = 24 * time.Hour
)
// percent turns a fraction into a percentage.
const percent = 100
// WebhookStats holds the figures in the statistics pane at the top of
// the webhook page.
type WebhookStats struct {
Entrypoints int
ActiveEntrypoints int
Targets int
ActiveTargets int
// Lifetime counts every event, delivery and failure the webhook
// has had, and WithinRetention those still stored.
Lifetime Counts
WithinRetention Counts
// InProgress counts the deliveries still pending or retrying.
InProgress int64
// LastEventAt is when the newest event arrived, or nil when none
// has. Retention does not change it.
LastEventAt *time.Time
Last10Minutes RecentWindow
Last24Hours RecentWindow
}
// Counts holds a number of events, of deliveries and of failed
// deliveries.
type Counts struct {
Events int64
Deliveries int64
Failures int64
}
// RecentWindow holds what happened in one recent window: the events
// received in it, and the deliveries that became delivered or failed in
// it.
type RecentWindow struct {
Events int64
Delivered int64
Failed int64
}
// TargetFinished is how many of one target's deliveries became
// delivered, and how many failed, in a recent window.
type TargetFinished struct {
TargetID string
Delivered int64
Failed int64
}
// FailurePercent is the share of the deliveries finished in the window
// that failed, or a dash when none finished. Deliveries still pending
// or retrying are not counted either way.
func (w RecentWindow) FailurePercent() string {
finished := w.Delivered + w.Failed
if finished == 0 {
return "—"
}
return fmt.Sprintf(
"%.1f%%", percent*float64(w.Failed)/float64(finished),
)
}
// loadWebhookStats gathers the figures for the statistics pane from the
// webhook's entrypoints and targets, as the page has already loaded
// them, and from its event database. It returns nil, and logs why, when
// the event database cannot be read.
func (h *Handlers) loadWebhookStats(
webhookID string,
entrypoints []database.Entrypoint,
targets []database.Target,
) *WebhookStats {
stats := &WebhookStats{
Entrypoints: len(entrypoints),
Targets: len(targets),
}
for i := range entrypoints {
if entrypoints[i].Active {
stats.ActiveEntrypoints++
}
}
for i := range targets {
if targets[i].Active {
stats.ActiveTargets++
}
}
// Opening an event database that does not exist would create it,
// and it would hold nothing to count.
if !h.dbMgr.DBExists(webhookID) {
return stats
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err == nil {
err = readEventStats(webhookDB, time.Now(), stats)
}
if err != nil {
h.log.Error(
"failed to read webhook statistics",
"webhook_id", webhookID,
"error", err,
)
return nil
}
return stats
}
// readEventStats fills in the figures that come from the webhook's
// event database. None of them reads every stored row: the totals are
// one row for the events and one per target for the deliveries, and
// every other figure is read from an index, over only the rows it
// counts.
func readEventStats(
db *gorm.DB, now time.Time, stats *WebhookStats,
) error {
err := readTotals(db, stats)
if err != nil {
return err
}
err = db.Model(&database.Delivery{}).
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).
Count(&stats.InProgress).Error
if err != nil {
return fmt.Errorf("counting deliveries in progress: %w", err)
}
stats.Last10Minutes, err = readRecentWindow(
db, now.Add(-shortWindow),
)
if err != nil {
return err
}
stats.Last24Hours, err = readRecentWindow(
db, now.Add(-longWindow),
)
return err
}
// readTotals fills in the lifetime and within-retention figures, and
// when the last event arrived, from the running totals: the events'
// row, and the targets' rows summed.
func readTotals(db *gorm.DB, stats *WebhookStats) error {
var events database.EventTotals
err := db.Take(&events).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
var targets []database.TargetTotals
err = db.Find(&targets).Error
if err != nil {
return fmt.Errorf("reading target totals: %w", err)
}
stats.Lifetime.Events = events.Events
stats.WithinRetention.Events = events.Events - events.EventsRemoved
stats.LastEventAt = events.LastEventAt
for _, t := range targets {
stats.Lifetime.Deliveries += t.Deliveries
stats.Lifetime.Failures += t.Failed
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
}
return nil
}
// readRecentWindow counts the events received, and the deliveries that
// became delivered or failed, since the given time.
func readRecentWindow(
db *gorm.DB, since time.Time,
) (RecentWindow, error) {
var w RecentWindow
err := db.Model(&database.Event{}).
Where("created_at >= ?", since).
Count(&w.Events).Error
if err != nil {
return w, fmt.Errorf("counting recent events: %w", err)
}
byTarget, err := finishedByTarget(db, since)
if err != nil {
return w, err
}
for _, f := range byTarget {
w.Delivered += f.Delivered
w.Failed += f.Failed
}
return w, nil
}
// finishedByTarget counts, for each target, the deliveries that became
// delivered and those that failed since the given time, in one query
// over just that window of the deliveries' status index. A target with
// neither is left out.
func finishedByTarget(
db *gorm.DB, since time.Time,
) ([]TargetFinished, error) {
var byTarget []TargetFinished
err := db.Model(&database.Delivery{}).
Select("target_id, "+
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed",
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed).
Where("status IN ? AND finished_at >= ?",
[]database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}, since).
Group("target_id").
Find(&byTarget).Error
if err != nil {
return nil, fmt.Errorf(
"counting deliveries finished by target: %w", err,
)
}
return byTarget, nil
}
-569
View File
@@ -1,569 +0,0 @@
package handlers_test
import (
"net/http"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
func statsEntrypoint(
t *testing.T, db *database.Database, webhookID string, active bool,
) string {
t.Helper()
ep := &database.Entrypoint{
WebhookID: webhookID,
Path: uuid.New().String(),
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
return ep.Path
}
// statsDelivery returns an event's delivery to a target.
func statsDelivery(
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
) database.Delivery {
t.Helper()
var d database.Delivery
require.NoError(t, webhookDB.Where(
"event_id = ? AND target_id = ?", eventID, targetID,
).First(&d).Error)
return d
}
// statsFinish settles a delivery as the delivery engine does: its
// final status and the time it finished, and one more on its target's
// delivered or failed total, in one transaction.
func statsFinish(
t *testing.T,
webhookDB *gorm.DB,
d database.Delivery,
status database.DeliveryStatus,
at time.Time,
) {
t.Helper()
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
if status == database.DeliveryStatusFailed {
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
}
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Model(&database.Delivery{}).
Where("id = ?", d.ID).
Updates(map[string]any{"status": status, "finished_at": at}).
Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, add)
}))
}
// statsAge moves an event's arrival back to the given time.
func statsAge(
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
) {
t.Helper()
require.NoError(t, webhookDB.Model(&database.Event{}).
Where("id = ?", eventID).
Update("created_at", at).Error)
}
// statsTargetTotals reads a webhook database's target totals, keyed by
// target.
func statsTargetTotals(
t *testing.T, webhookDB *gorm.DB,
) map[string]database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, webhookDB.Find(&rows).Error)
byTarget := make(map[string]database.TargetTotals, len(rows))
for _, row := range rows {
byTarget[row.TargetID] = row
}
return byTarget
}
// statsHistory is the webhook seedStatsHistory builds: its event
// database, its newest event, and its two active targets.
type statsHistory struct {
webhook *database.Webhook
webhookDB *gorm.DB
newest database.Event
first, second string
}
// seedStatsHistory builds the webhook the statistics test checks: 14
// days of retention, twelve entrypoints (one inactive) and six targets
// (four inactive). Ten events arrive through the receiver, and so each
// has a delivery to the two active targets. The oldest event is past
// retention, the next 30 hours old, the next six hours old, the other
// seven just in. Six deliveries are settled as the delivery engine
// would, two of them inside a recent window though their event arrived
// before it. The newest event's delivery to the second target is
// retrying, the rest are left pending, and a replay adds a pending
// delivery to the oldest event. Once retention has removed the oldest
// event, every figure in the pane differs from every other.
func seedStatsHistory(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
db *database.Database,
dbMgr *database.WebhookDBManager,
) statsHistory {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: "stats", RetentionDays: 14}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
for range 10 {
statsEntrypoint(t, db, wh.ID, true)
}
statsEntrypoint(t, db, wh.ID, false)
first := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
for range 4 {
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(inactive).
Update("active", false).Error)
}
router := receiverRouter(h)
for range 10 {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, 10)
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[9]
now := time.Now()
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*time.Hour))
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
statsFinish(t, webhookDB, oldestFailure,
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
database.DeliveryStatusFailed, now.Add(-23*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, middle.ID, second.ID),
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, middle.ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, newest.ID, first.ID),
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
retrying := statsDelivery(t, webhookDB, newest.ID, second.ID)
require.NoError(t, webhookDB.Model(&retrying).
Update("status", database.DeliveryStatusRetrying).Error)
require.Equal(t, http.StatusSeeOther,
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
return statsHistory{
webhook: wh, webhookDB: webhookDB, newest: newest,
first: first.ID, second: second.ID,
}
}
// statsPrune runs the real retention reaper until it has removed one
// event from the webhook's database, then stops it.
func statsPrune(
t *testing.T,
db *database.Database,
dbMgr *database.WebhookDBManager,
log *logger.Logger,
webhookDB *gorm.DB,
) {
t.Helper()
lc := fxtest.NewLifecycle(t)
database.NewRetentionReaper(lc, database.RetentionReaperParams{
Config: &config.Config{
RetentionSweepInterval: 10 * time.Millisecond,
},
Database: db,
DBManager: dbMgr,
Logger: log,
})
lc.RequireStart()
require.Eventually(t, func() bool {
var totals database.EventTotals
err := webhookDB.Take(&totals).Error
return err == nil && totals.EventsRemoved == 1
}, 10*time.Second, 10*time.Millisecond)
lc.RequireStop()
}
// statsPane returns the text of the statistics pane in a rendered
// webhook page, everything from its heading to the next heading on the
// page, with the markup taken out and each run of space made one
// space. A table then reads header by header and row by row, each
// row's label followed by its figures in column order.
func statsPane(t *testing.T, page string) string {
t.Helper()
_, pane, found := strings.Cut(page, ">Statistics</h2>")
require.True(t, found, "the page has no statistics pane")
pane, _, _ = strings.Cut(pane, "<h2")
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
return strings.Join(strings.Fields(pane), " ")
}
// assertStatsTargets checks, for the history seedStatsHistory builds,
// each target's totals and its deliveries finished in the last 24
// hours. The first target has ten deliveries and the replay, the
// second ten; the inactive targets have none and so no row.
func assertStatsTargets(t *testing.T, hist statsHistory) {
t.Helper()
first, second := hist.first, hist.second
assert.Equal(t, map[string]database.TargetTotals{
first: {TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3},
second: {TargetID: second, Deliveries: 10, Failed: 2},
}, statsTargetTotals(t, hist.webhookDB))
lastDay, err := handlers.FinishedByTargetForTest(
hist.webhookDB, time.Now().Add(-24*time.Hour),
)
require.NoError(t, err)
assert.ElementsMatch(t, []handlers.TargetFinished{
{TargetID: first, Delivered: 1, Failed: 1},
{TargetID: second, Failed: 2},
}, lastDay)
}
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
// history seedStatsHistory builds, once retention has removed the
// oldest event: each figure after its label, in its column.
func assertStatsPaneAfterPrune(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
hist statsHistory,
) {
t.Helper()
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
"Targets 6 (2 active) "+
"Deliveries in progress 13 "+
"Last event "+lastEvent+" "+
"Retention 14 days")
assert.Contains(t, pane, "Lifetime Within retention "+
"Events 10 9 "+
"Deliveries 21 18 "+
"Failures 5 4")
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
"Events 7 8 "+
"Failures 1 3 "+
"Failure percentage 50.0% 75.0%")
}
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
// the statistics pane shows for the history seedStatsHistory builds,
// and each target's totals and recent figures, before and after the
// real retention reaper removes the oldest event.
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
hist := seedStatsHistory(t, h, sess, db, dbMgr)
first, second := hist.first, hist.second
stats := h.WebhookStatsForTest(hist.webhook.ID)
require.NotNil(t, stats)
assert.Equal(t, 12, stats.Entrypoints)
assert.Equal(t, 11, stats.ActiveEntrypoints)
assert.Equal(t, 6, stats.Targets)
assert.Equal(t, 2, stats.ActiveTargets)
assert.Equal(t, handlers.Counts{Events: 10, Deliveries: 21, Failures: 5},
stats.Lifetime)
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
assert.Equal(t, int64(15), stats.InProgress)
require.NotNil(t, stats.LastEventAt)
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
assert.Equal(t, handlers.RecentWindow{
Events: 7, Delivered: 1, Failed: 1,
}, stats.Last10Minutes)
assert.Equal(t, handlers.RecentWindow{
Events: 8, Delivered: 1, Failed: 3,
}, stats.Last24Hours)
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
assert.Equal(t, "75.0%", stats.Last24Hours.FailurePercent())
assertStatsTargets(t, hist)
// Retention removes the oldest event with its three deliveries:
// the first target's failed one and the pending replay, and the
// second target's pending one.
statsPrune(t, db, dbMgr, log, hist.webhookDB)
after := h.WebhookStatsForTest(hist.webhook.ID)
require.NotNil(t, after)
assert.Equal(t, stats.Lifetime, after.Lifetime)
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 18, Failures: 4},
after.WithinRetention)
assert.Equal(t, int64(13), after.InProgress)
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
assert.Equal(t, map[string]database.TargetTotals{
first: {
TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3,
DeliveriesRemoved: 2, FailedRemoved: 1,
},
second: {
TargetID: second, Deliveries: 10, Failed: 2,
DeliveriesRemoved: 1,
},
}, statsTargetTotals(t, hist.webhookDB))
assertStatsPaneAfterPrune(t, h, sess, hist)
}
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
// retention has removed every event, the pane still shows when the last
// one arrived rather than "none".
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
require.Equal(t, http.StatusOK,
postReceiver(t, receiverRouter(h), path))
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, 1)
arrived := events[0].CreatedAt
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
stats := h.WebhookStatsForTest(wh.ID)
require.NotNil(t, stats)
require.NotNil(t, stats.LastEventAt)
assert.True(t, arrived.Equal(*stats.LastEventAt))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane,
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
}
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
// last event arrived in UTC, as the event list does, when the time was
// stored in another zone, as it is on a host whose local time is not
// UTC.
func TestWebhookStats_LastEventInUTC(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)
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
time.FixedZone("EST", -5*60*60))
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{Events: 1, LastEventAt: &arrived}))
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
}
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
// pane itself, not only the line at the foot of the page, shows the
// webhook's retention period, for a finite one and for forever.
func TestWebhookStats_PaneShowsRetentionPeriod(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)
tests := []struct {
retentionDays int
want string
}{
{30, "30 days"},
{database.RetentionForeverDays, "forever"},
}
for _, tt := range tests {
wh := &database.Webhook{
UserID: deleteTestUserID,
Name: "retention",
RetentionDays: tt.retentionDays,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(wh).Error)
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Retention "+tt.want)
}
}
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
// database has never been opened: every count is zero, the
// percentages are a dash, and showing the page does not create the
// database.
func TestWebhookStats_WebhookWithNoEvents(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)
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, pane, "Last event none")
assert.Contains(t, pane, "Failure percentage — —")
assert.False(t, dbMgr.DBExists(wh.ID))
}
// TestRecentWindow_FailurePercent pins the percentage: failed
// deliveries out of all that finished in the window.
func TestRecentWindow_FailurePercent(t *testing.T) {
t.Parallel()
tests := []struct {
window handlers.RecentWindow
want string
}{
{handlers.RecentWindow{}, "—"},
{handlers.RecentWindow{Events: 4}, "—"},
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
{handlers.RecentWindow{Failed: 2}, "100.0%"},
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
}
}
+1 -1
View File
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
t.Parallel() t.Parallel()
for _, s := range []string{ for _, s := range []string{
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)", "", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
} { } {
assert.Equal(t, s, logfield.Truncate(s, budget)) assert.Equal(t, s, logfield.Truncate(s, budget))
} }
+8 -8
View File
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
) )
router.HandleFunc( router.HandleFunc(
"/h/{uuid}", "/webhook/{uuid}",
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
// Stands in for the real handler: an unknown entrypoint // Stands in for the real handler: an unknown entrypoint
// UUID 404s, a known one succeeds. // UUID 404s, a known one succeeds.
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
assertFloodIsBounded( assertFloodIsBounded(
t, t,
func(i int) string { func(i int) string {
return "/h/" + attackerMarker + return "/webhook/" + attackerMarker +
strings.Repeat("x", i) + "?q=" + attackerMarker strings.Repeat("x", i) + "?q=" + attackerMarker
}, },
http.StatusNotFound, http.StatusNotFound,
"/h/{uuid}", "/webhook/{uuid}",
) )
} }
@@ -346,10 +346,10 @@ type sizeCase struct {
func lineSizeCases() map[string]sizeCase { func lineSizeCases() map[string]sizeCase {
cases := map[string]sizeCase{ cases := map[string]sizeCase{
"oversized path segment": { "oversized path segment": {
target: "/h/" + attackerMarker + target: "/webhook/" + attackerMarker +
strings.Repeat("x", oversizedSegmentBytes), strings.Repeat("x", oversizedSegmentBytes),
wantStatus: http.StatusNotFound, wantStatus: http.StatusNotFound,
wantURL: "/h/{uuid}", wantURL: "/webhook/{uuid}",
bound: maxLineBytes, bound: maxLineBytes,
}, },
// /.well-known/healthcheck answers 200 to anyone and has no // /.well-known/healthcheck answers 200 to anyone and has no
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
router := accessLogRouter(m) router := accessLogRouter(m)
assert.Equal( assert.Equal(
t, http.StatusOK, get(t, router, "/h/known?src=ci"), t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
) )
// The path resolved against a stored entrypoint, so it stays. The // The path resolved against a stored entrypoint, so it stays. The
// query never does: see TestAccessLog_UnauthenticatedSuccess... // query never does: see TestAccessLog_UnauthenticatedSuccess...
entries := accessLogEntries(t, buf) entries := accessLogEntries(t, buf)
require.Len(t, entries, 1) require.Len(t, entries, 1)
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"]) assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
assert.NotContains(t, buf.String(), "src=ci") assert.NotContains(t, buf.String(), "src=ci")
} }
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
http.StatusNotFound, http.StatusNotFound,
get(t, router, "/h/"+attackerMarker), get(t, router, "/webhook/"+attackerMarker),
) )
entries := accessLogEntries(t, buf) entries := accessLogEntries(t, buf)
+1 -1
View File
@@ -41,7 +41,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
// CSRF is registered ahead of RequireAuth on every route // CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an // group that uses it, so this WARN is reachable by an
// unauthenticated client: a POST with no token to // unauthenticated client: a POST with no token to
// /hook/<any length of any text>/edit lands here. The // /source/<any length of any text>/edit lands here. The
// method and path are capped against the same budgets as // method and path are capped against the same budgets as
// the access log. remote_addr is set by net/http from the // the access log. remote_addr is set by net/http from the
// accepted connection rather than by the client, and // accepted connection rather than by the client, and
+3 -3
View File
@@ -383,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
t, newHandler, t, newHandler,
) )
path := "/hook/" + path := "/source/" +
oversizedPathSegment(fill) + "/edit" oversizedPathSegment(fill) + "/edit"
assert.Equal( assert.Equal(
@@ -434,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodPost, http.MethodPost,
"/hook/"+ "/source/"+
oversizedPathSegment(fill)+"/login", oversizedPathSegment(fill)+"/login",
nil, nil,
) )
@@ -499,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
http.StatusRequestEntityTooLarge, http.StatusRequestEntityTooLarge,
postOversize( postOversize(
h, h,
"/hook/"+segment(i)+"/edit", "/source/"+segment(i)+"/edit",
), ),
) )
} }
+1 -1
View File
@@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
// //
// The pattern is what bounds the label's domain to the routes the // The pattern is what bounds the label's domain to the routes the
// service registers. The path does not bound it at all — every byte // service registers. The path does not bound it at all — every byte
// after /h/ is client-chosen, so labelling by path lets any // after /webhook/ is client-chosen, so labelling by path lets any
// unauthenticated client mint permanent series at will, and publishes // unauthenticated client mint permanent series at will, and publishes
// the entrypoint UUID (the receiver's only credential) in the scrape // the entrypoint UUID (the receiver's only credential) in the scrape
// while doing it. // while doing it.
+1 -1
View File
@@ -50,7 +50,7 @@ func realMethods() []string {
// dimension varying, so any series growth a probe produces is the // dimension varying, so any series growth a probe produces is the
// method label's and nothing else's. // method label's and nothing else's.
func methodProbePath() string { func methodProbePath() string {
return "/h/" + uuid.NewString() return "/webhook/" + uuid.NewString()
} }
// inventedMethods returns n distinct RFC 9110 method tokens that no // inventedMethods returns n distinct RFC 9110 method tokens that no
+6 -6
View File
@@ -28,7 +28,7 @@ const (
// receiverRoutePattern is the one handler label every receiver // receiverRoutePattern is the one handler label every receiver
// request must produce, however the client varies the path. // request must produce, however the client varies the path.
receiverRoutePattern = "/h/{uuid}" receiverRoutePattern = "/webhook/{uuid}"
// okRoute is a static route used to pin that the response-writer // okRoute is a static route used to pin that the response-writer
// interceptor still reports status and size after the handler id // interceptor still reports status and size after the handler id
@@ -143,13 +143,13 @@ func drivePaths(
return drive(t, h, probes) return drive(t, h, probes)
} }
// receiverPaths returns n distinct /h/ paths, each naming a // receiverPaths returns n distinct /webhook/ paths, each naming a
// fresh UUID exactly as an unauthenticated flood would. // fresh UUID exactly as an unauthenticated flood would.
func receiverPaths(n int) []string { func receiverPaths(n int) []string {
paths := make([]string, 0, n) paths := make([]string, 0, n)
for range n { for range n {
paths = append(paths, "/h/"+uuid.NewString()) paths = append(paths, "/webhook/"+uuid.NewString())
} }
return paths return paths
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct // TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
// assertion the issue asks for: N requests to N distinct // assertion the issue asks for: N requests to N distinct
// /h/<uuid> paths must produce exactly ONE handler label, the // /webhook/<uuid> paths must produce exactly ONE handler label, the
// route pattern. Before the fix this produced N of them. // route pattern. Before the fix this produced N of them.
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) { func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
t.Parallel() t.Parallel()
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
// The scrape must not republish the UUIDs it was driven with. // The scrape must not republish the UUIDs it was driven with.
// They are the receiver's only credential. // They are the receiver's only credential.
for _, p := range paths { for _, p := range paths {
id := strings.TrimPrefix(p, "/h/") id := strings.TrimPrefix(p, "/webhook/")
for label := range labels { for label := range labels {
assert.NotContains( assert.NotContains(
t, label, id, t, label, id,
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
if i%2 == 0 { if i%2 == 0 {
paths = append(paths, "/"+id) paths = append(paths, "/"+id)
} else { } else {
paths = append(paths, "/h/"+id+"/"+id) paths = append(paths, "/webhook/"+id+"/"+id)
} }
} }
+4 -29
View File
@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/url"
"sync" "sync"
"time" "time"
@@ -258,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
// //
// 3xx and 4xx responses get the chi route pattern instead. Those are // 3xx and 4xx responses get the chi route pattern instead. Those are
// the outcomes an unauthenticated client drives for free: 404 or 429 // the outcomes an unauthenticated client drives for free: 404 or 429
// on any invented /h/ path, 303 to the login page on any // on any invented /webhook/ path, 303 to the login page on any
// invented /user/ path. Logging the concrete URL there lets a flood // invented /user/ path. Logging the concrete URL there lets a flood
// write attacker-chosen text, of attacker-chosen length, into the // write attacker-chosen text, of attacker-chosen length, into the
// operator's log at one line per request. The pattern comes from the // operator's log at one line per request. The pattern comes from the
@@ -367,30 +366,6 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
} }
} }
// NextParam is the query parameter on the login redirect, and the
// login form field, that holds the page to return to after login.
const NextParam = "next"
// MaxNextBytes bounds the NextParam value. The login page writes it
// into its form, and every page is rendered into a buffer first, so
// without a bound a request would choose the size of that buffer.
const MaxNextBytes = 2048
// loginURL is the login page RequireAuth redirects to. A GET carries
// its own path and query in NextParam so that logging in returns to
// it, unless they are longer than MaxNextBytes; loginDestination in
// the handlers package checks whether that value is safe to follow.
// Other methods carry nothing, since a redirect cannot repeat them.
func loginURL(r *http.Request) string {
next := r.URL.RequestURI()
if r.Method != http.MethodGet || len(next) > MaxNextBytes {
return "/pages/login"
}
return "/pages/login?" + url.Values{NextParam: {next}}.Encode()
}
// RequireAuth returns middleware that checks for a valid session. // RequireAuth returns middleware that checks for a valid session.
// Unauthenticated users are redirected to the login page. // Unauthenticated users are redirected to the login page.
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler { func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
@@ -406,7 +381,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
"error", err, "error", err,
) )
http.Redirect( http.Redirect(
w, r, loginURL(r), http.StatusSeeOther, w, r, "/pages/login", http.StatusSeeOther,
) )
return return
@@ -434,7 +409,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
), ),
) )
http.Redirect( http.Redirect(
w, r, loginURL(r), http.StatusSeeOther, w, r, "/pages/login", http.StatusSeeOther,
) )
return return
@@ -585,7 +560,7 @@ func (s *Middleware) MaxBodySize(
// internal/server/routes.go), so an // internal/server/routes.go), so an
// unauthenticated client reaches it with a path // unauthenticated client reaches it with a path
// of its own choosing and its own length — // of its own choosing and its own length —
// POST /hook/<8 KB>/edit with an oversize // POST /source/<8 KB>/edit with an oversize
// declared Content-Length costs nothing to // declared Content-Length costs nothing to
// send. At WARN, on by default, that is a // send. At WARN, on by default, that is a
// write into the operator's log sized by the // write into the operator's log sized by the
+3 -77
View File
@@ -338,76 +338,6 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
"unauthenticated request", "unauthenticated request",
) )
assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
}
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
// redirect carries: a GET's path and query, so logging in can return
// there, and nothing for a POST, which a redirect cannot repeat.
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
get := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/hook/abc/events?page=2", nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(
t, "/pages/login?next=%2Fhook%2Fabc%2Fevents%3Fpage%3D2",
w.Header().Get("Location"),
)
post := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/hook/abc/delete", nil,
)
w = httptest.NewRecorder()
handler.ServeHTTP(w, post)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
// TestRequireAuth_LoginRedirectLeavesOutALongURL: a GET whose path
// and query are longer than the login page accepts goes to the plain
// login page, so a long URL does not make the redirect long.
func TestRequireAuth_LoginRedirectLeavesOutALongURL(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
atLimit := "/" + strings.Repeat("a", middleware.MaxNextBytes-1)
get := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, atLimit, nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(
t, "/pages/login?next=%2F"+atLimit[1:],
w.Header().Get("Location"),
)
get = httptest.NewRequestWithContext(
context.Background(), http.MethodGet, atLimit+"a", nil,
)
w = httptest.NewRecorder()
handler.ServeHTTP(w, get)
assert.Equal(t, "/pages/login", w.Header().Get("Location")) assert.Equal(t, "/pages/login", w.Header().Get("Location"))
} }
@@ -513,9 +443,7 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
"unauthenticated session", "unauthenticated session",
) )
assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(t, "/pages/login", w.Header().Get("Location"))
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
} }
// --- RequireAuth Session Expiry Tests --- // --- RequireAuth Session Expiry Tests ---
@@ -613,9 +541,7 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
"handler should not run for an idle-expired session", "handler should not run for an idle-expired session",
) )
assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal( assert.Equal(t, "/pages/login", w.Header().Get("Location"))
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
)
assert.Empty( assert.Empty(
t, sessionCookies(w), t, sessionCookies(w),
"an expired session must not be refreshed", "an expired session must not be refreshed",
@@ -714,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
req := httptest.NewRequestWithContext( req := httptest.NewRequestWithContext(
context.Background(), context.Background(),
http.MethodGet, "/hooks", nil, http.MethodGet, "/sources", nil,
) )
w := httptest.NewRecorder() w := httptest.NewRecorder()
+2 -2
View File
@@ -63,7 +63,7 @@ const (
// receiverAggregateMultiplier scales the configured // receiverAggregateMultiplier scales the configured
// per-entrypoint receiver limit into the aggregate limit one // per-entrypoint receiver limit into the aggregate limit one
// client IP may spend across the whole /h/* route. Ten // client IP may spend across the whole /webhook/* route. Ten
// entrypoints' worth lets a single sender address drive several // entrypoints' worth lets a single sender address drive several
// entrypoints at their full rate, while still capping what one // entrypoints at their full rate, while still capping what one
// address costs the unauthenticated receiver. // address costs the unauthenticated receiver.
@@ -389,7 +389,7 @@ func (m *Middleware) postRateLimit(
// It is Config.ReceiverRateLimit requests per minute. // It is Config.ReceiverRateLimit requests per minute.
// //
// That limit alone bounds nothing in aggregate. The route pattern // That limit alone bounds nothing in aggregate. The route pattern
// /h/{uuid} matches any single segment, so a client that // /webhook/{uuid} matches any single segment, so a client that
// invents a fresh path per request mints a fresh bucket per request // invents a fresh path per request mints a fresh bucket per request
// and never refills one — and every such request still reaches the // and never refills one — and every such request still reaches the
// handler's entrypoint lookup before it 404s. The outer limit is // handler's entrypoint lookup before it 404s. The outer limit is
+12 -12
View File
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// pass. // pass.
for i := range limit { for i := range limit {
w := receiverPost( w := receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-a", handler, "9.9.9.9:1234", "/webhook/uuid-a",
) )
assert.Equal( assert.Equal(
t, http.StatusOK, w.Code, t, http.StatusOK, w.Code,
@@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// The next request over the limit is rejected with a 429 // The next request over the limit is rejected with a 429
// carrying a Retry-After header. // carrying a Retry-After header.
w := receiverPost( w := receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-a", handler, "9.9.9.9:1234", "/webhook/uuid-a",
) )
assert.Equal(t, http.StatusTooManyRequests, w.Code) assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.NotEmpty( assert.NotEmpty(
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// The same IP is not limited on a different entrypoint. // The same IP is not limited on a different entrypoint.
w = receiverPost( w = receiverPost(
handler, "9.9.9.9:1234", "/h/uuid-b", handler, "9.9.9.9:1234", "/webhook/uuid-b",
) )
assert.Equal( assert.Equal(
t, http.StatusOK, w.Code, t, http.StatusOK, w.Code,
@@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
// A different IP is not limited on the same entrypoint. // A different IP is not limited on the same entrypoint.
w = receiverPost( w = receiverPost(
handler, "8.8.8.8:1234", "/h/uuid-a", handler, "8.8.8.8:1234", "/webhook/uuid-a",
) )
assert.Equal( assert.Equal(
t, http.StatusOK, w.Code, t, http.StatusOK, w.Code,
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
const ( const (
limit = 2 limit = 2
ip = "7.7.7.7:1234" ip = "7.7.7.7:1234"
path = "/h/uuid-c" path = "/webhook/uuid-c"
) )
handler := receiverLimitedHandler(t, limit) handler := receiverLimitedHandler(t, limit)
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
// none of them shares a per-entrypoint bucket with another. // none of them shares a per-entrypoint bucket with another.
for i := range aggregate { for i := range aggregate {
w := receiverPost( w := receiverPost(
handler, ip, fmt.Sprintf("/h/invented-%d", i), handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
) )
assert.Equal( assert.Equal(
t, http.StatusOK, w.Code, t, http.StatusOK, w.Code,
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
} }
w := receiverPost( w := receiverPost(
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate), handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
) )
assert.Equal( assert.Equal(
t, http.StatusTooManyRequests, w.Code, t, http.StatusTooManyRequests, w.Code,
"a client must not be able to raise its aggregate rate "+ "a client must not be able to raise its aggregate rate "+
"against /h/* by varying the path", "against /webhook/* by varying the path",
) )
// The aggregate limit is still per client IP: exhausting one // The aggregate limit is still per client IP: exhausting one
// address must not throttle another. // address must not throttle another.
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0") w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
assert.Equal( assert.Equal(
t, http.StatusOK, w.Code, t, http.StatusOK, w.Code,
"a different client IP must not be affected", "a different client IP must not be affected",
@@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
// limit requests are served; the rest are rejected by the // limit requests are served; the rest are rejected by the
// per-entrypoint limiter but still count against the aggregate. // per-entrypoint limiter but still count against the aggregate.
for i := range aggregate { for i := range aggregate {
w := receiverPost(handler, ip, "/h/exhausted") w := receiverPost(handler, ip, "/webhook/exhausted")
want := http.StatusTooManyRequests want := http.StatusTooManyRequests
if i < limit { if i < limit {
@@ -784,7 +784,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
) )
} }
w := receiverPost(handler, ip, "/h/never-used") w := receiverPost(handler, ip, "/webhook/never-used")
assert.Equal( assert.Equal(
t, http.StatusTooManyRequests, w.Code, t, http.StatusTooManyRequests, w.Code,
"requests rejected per entrypoint must still count "+ "requests rejected per entrypoint must still count "+
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
const ( const (
limit = 3 limit = 3
peer = "203.0.113.10:44444" peer = "203.0.113.10:44444"
path = "/h/uuid-d" path = "/webhook/uuid-d"
) )
handler := receiverLimitedHandler(t, limit) handler := receiverLimitedHandler(t, limit)
+1 -1
View File
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
// and the database, exactly as internal/handlers builds them. // and the database, exactly as internal/handlers builds them.
// //
// One application per test function, not per case: every start that // One application per test function, not per case: every start that
// finds no account seeds one with an Argon2id hash, and this package's // finds no account seeds one at 64 MB of Argon2id, and this package's
// budget is not the place to spend that repeatedly. // budget is not the place to spend that repeatedly.
func newServerApp( func newServerApp(
t *testing.T, dir string, t *testing.T, dir string,
+21 -5
View File
@@ -141,6 +141,7 @@ func (s *Server) setupRoutes() {
s.setupPageRoutes() s.setupPageRoutes()
s.setupUserRoutes() s.setupUserRoutes()
s.setupSettingsRoutes()
s.setupSourceRoutes() s.setupSourceRoutes()
s.setupWebhookRoutes() s.setupWebhookRoutes()
} }
@@ -182,8 +183,23 @@ func (s *Server) setupUserRoutes() {
}) })
} }
// setupSettingsRoutes serves the Settings page. It is GET only:
// configuration comes from the environment and nothing here changes
// it.
func (s *Server) setupSettingsRoutes() {
s.router.Route("/settings", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSettings())
})
}
func (s *Server) setupSourceRoutes() { func (s *Server) setupSourceRoutes() {
s.router.Route("/hooks", func(r chi.Router) { s.router.Route("/sources", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
@@ -195,7 +211,7 @@ func (s *Server) setupSourceRoutes() {
r.Post("/new", s.h.HandleSourceCreateSubmit()) r.Post("/new", s.h.HandleSourceCreateSubmit())
}) })
s.router.Route("/hook/{sourceID}", func(r chi.Router) { s.router.Route("/source/{sourceID}", func(r chi.Router) {
// MaxBodySize precedes CSRF and RequireAuth deliberately; // MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs. // see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Use(s.mw.MaxBodySize(maxFormBodySize))
@@ -206,14 +222,14 @@ func (s *Server) setupSourceRoutes() {
r.Get("/edit", s.h.HandleSourceEdit()) r.Get("/edit", s.h.HandleSourceEdit())
r.Post("/edit", s.h.HandleSourceEditSubmit()) r.Post("/edit", s.h.HandleSourceEditSubmit())
r.Post("/delete", s.h.HandleSourceDelete()) r.Post("/delete", s.h.HandleSourceDelete())
r.Get("/events", s.h.HandleSourceLogs()) r.Get("/logs", s.h.HandleSourceLogs())
// The log page renders each body only up to its cap, so // The log page renders each body only up to its cap, so
// this is the only route that serves a whole one. It // this is the only route that serves a whole one. It
// belongs to this group for its RequireAuth and // belongs to this group for its RequireAuth and
// NoCache; see HandleEventBodyDownload for the headers // NoCache; see HandleEventBodyDownload for the headers
// that keep the bytes it returns inert. // that keep the bytes it returns inert.
r.Get( r.Get(
"/events/{eventID}/body", "/logs/{eventID}/body",
s.h.HandleEventBodyDownload(), s.h.HandleEventBodyDownload(),
) )
// Replay is the one page action that queues outbound work: // Replay is the one page action that queues outbound work:
@@ -280,7 +296,7 @@ func (s *Server) setupSourceRoutes() {
func (s *Server) setupWebhookRoutes() { func (s *Server) setupWebhookRoutes() {
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc( s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
"/h/{uuid}", "/webhook/{uuid}",
s.h.HandleWebhook(), s.h.HandleWebhook(),
) )
} }
+11 -462
View File
@@ -47,8 +47,8 @@ type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.Task) {} func (n *noopNotifier) Notify([]delivery.Task) {}
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor // noopEvictor satisfies handlers.New's delivery.WebhookEvictor
// dependency. No test here checks what gets evicted, so it records // dependency. These tests never delete a webhook, so there is
// nothing. // nothing to record.
type noopEvictor struct{} type noopEvictor struct{}
func (e *noopEvictor) EvictWebhook(string) {} func (e *noopEvictor) EvictWebhook(string) {}
@@ -240,26 +240,6 @@ func (e *testEnv) csrfFrom(
return token, combined return token, combined
} }
// urlFrom renders the page at path and returns the link or form
// action that pattern's one group captures, so a test requests the
// URL the template emitted rather than one it wrote itself.
func (e *testEnv) urlFrom(
t *testing.T,
path, pattern string,
cookies []*http.Cookie,
) string {
t.Helper()
w := e.get(path, cookies)
require.Equal(t, http.StatusOK, w.Code)
match := regexp.MustCompile(pattern).
FindStringSubmatch(w.Body.String())
require.Len(t, match, 2, "%s should render %s", path, pattern)
return html.UnescapeString(match[1])
}
// authCookies forges an authenticated session for the given user. // authCookies forges an authenticated session for the given user.
func (e *testEnv) authCookies( func (e *testEnv) authCookies(
t *testing.T, t *testing.T,
@@ -694,50 +674,12 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
require.NotNil(t, fresh, "login must set a session cookie") require.NotNil(t, fresh, "login must set a session cookie")
assert.Equal( assert.Equal(
t, "/hooks", t, "/sources",
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"), env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
"the new session cookie must authenticate", "the new session cookie must authenticate",
) )
} }
// TestPagesLogin_ReturnsToTheRequestedPage is
// https://git.eeqj.de/sneak/webhooker/issues/384: a page opened while
// logged out leads to the login page, and logging in from there lands
// on that page, query included.
func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
t.Parallel()
const (
username = "operator"
password = "correct-horse-battery-staple"
)
env := newTestEnv(t)
userID, _ := env.seedUser(t, username, password)
asked := "/hook/" + env.seedWebhook(t, userID).ID + "/events?page=2"
bounced := env.get(asked, nil)
require.Equal(t, http.StatusSeeOther, bounced.Code)
loginPage := bounced.Header().Get("Location")
match := regexp.MustCompile(`name="next" value="([^"]*)"`).
FindStringSubmatch(env.get(loginPage, nil).Body.String())
require.Len(t, match, 2, "the login form must carry the page")
token, cookies := env.csrfFrom(t, loginPage, nil)
form := url.Values{}
form.Set("csrf_token", token)
form.Set("username", username)
form.Set("password", password)
form.Set("next", html.UnescapeString(match[1]))
w := env.post("/pages/login", form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, asked, w.Header().Get("Location"))
}
// --- /user/{username} group --- // --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -799,344 +741,7 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
) )
} }
// --- /hooks group --- // --- /source/{sourceID} group ---
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
// production router, follows both of its links to the new-webhook
// form, then submits the form to the action and with the token the
// page rendered. A mistyped route, link or form action fails here;
// the handler tests cannot catch any of them, because they never
// route a request.
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "lister", "somepassword")
cookies := env.authCookies(t, userID, "lister")
// The list shows its "Create Webhook" link only while it is empty.
createLink := env.urlFrom(
t, "/hooks", `href="([^"]+)"[^>]*>Create Webhook<`, cookies,
)
existing := env.seedWebhook(t, userID)
list := env.get("/hooks", cookies)
require.Equal(t, http.StatusOK, list.Code)
assert.Contains(
t, list.Body.String(), `href="/hook/`+existing.ID+`"`,
"the list should link the user's webhook",
)
// The "New Webhook" link has an icon between its href and its text.
newLink := env.urlFrom(
t, "/hooks", `href="([^"]+)"[^>]*>(?:\s*<[^>]*>)*\s*New Webhook`,
cookies,
)
token, cookies := env.csrfFrom(t, newLink, cookies)
action := env.urlFrom(t, newLink, `action="(/hooks[^"]*)"`, cookies)
assert.Equal(
t, action,
env.urlFrom(t, createLink, `action="(/hooks[^"]*)"`, cookies),
"both links should open the new-webhook form",
)
form := url.Values{}
form.Set("csrf_token", token)
form.Set("name", "created")
w := env.post(action, form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
var created database.Webhook
require.NoError(t,
env.db.DB().Where("name = ?", "created").First(&created).Error,
)
assert.Equal(
t, "/hook/"+created.ID, w.Header().Get("Location"),
"creating a webhook should redirect to its page",
)
}
// --- /hook/{sourceID} group ---
// TestHook_EditFormAndDelete follows the webhook page's Edit link to
// the edit form and submits it, then deletes the webhook with the
// form on its page, every URL and token taken from the rendered
// pages.
func TestHook_EditFormAndDelete(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "editor", "somepassword")
cookies := env.authCookies(t, userID, "editor")
wh := env.seedWebhook(t, userID)
page := "/hook/" + wh.ID
editPage := env.urlFrom(t, page, `href="(/hook/[^/"]+/edit)"`, cookies)
token, cookies := env.csrfFrom(t, editPage, cookies)
form := url.Values{}
form.Set("csrf_token", token)
form.Set("name", "renamed")
w := env.post(
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, page, w.Header().Get("Location"))
var edited database.Webhook
require.NoError(t, env.db.DB().First(&edited, "id = ?", wh.ID).Error)
assert.Equal(t, "renamed", edited.Name)
form = url.Values{}
form.Set("csrf_token", token)
w = env.post(
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location"))
assert.Equal(
t, http.StatusNotFound, env.get(page, cookies).Code,
"a deleted webhook's page should be gone",
)
}
// TestHook_EntrypointActions adds, deactivates and deletes an
// entrypoint with the forms on the webhook page, each submitted to
// the action and with the token the page rendered.
func TestHook_EntrypointActions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "epuser", "somepassword")
cookies := env.authCookies(t, userID, "epuser")
wh := env.seedWebhook(t, userID)
page := "/hook/" + wh.ID
token, cookies := env.csrfFrom(t, page, cookies)
form := url.Values{}
form.Set("csrf_token", token)
// submit posts the webhook page's form whose action pattern
// captures, and requires the redirect back to that page.
submit := func(pattern string) {
t.Helper()
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, page, w.Header().Get("Location"))
}
submit(`action="(/hook/[^/"]+/entrypoints)"`)
var added database.Entrypoint
require.NoError(t,
env.db.DB().First(&added, "webhook_id = ?", wh.ID).Error,
)
require.True(t, added.Active)
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
var toggled database.Entrypoint
require.NoError(t,
env.db.DB().First(&toggled, "id = ?", added.ID).Error,
)
assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
var left int64
require.NoError(t, env.db.DB().Model(&database.Entrypoint{}).
Where("webhook_id = ?", wh.ID).Count(&left).Error)
assert.Zero(t, left, "the delete should remove the entrypoint")
}
// TestHook_TargetActions adds a target with the form on the webhook
// page, follows its Edit link to the target edit form and submits
// it, then deactivates and deletes it, every URL and token taken from
// the rendered pages.
func TestHook_TargetActions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "tgtuser", "somepassword")
cookies := env.authCookies(t, userID, "tgtuser")
wh := env.seedWebhook(t, userID)
page := "/hook/" + wh.ID
token, cookies := env.csrfFrom(t, page, cookies)
// submit posts form, with the token, to the action pattern
// captures on the page at from, and requires the redirect back to
// the webhook page.
submit := func(from, pattern string, form url.Values) {
t.Helper()
form.Set("csrf_token", token)
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, page, w.Header().Get("Location"))
}
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
"name": {"added"},
"type": {string(database.TargetTypeLog)},
})
editPage := env.urlFrom(
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
)
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
url.Values{"name": {"renamed"}})
var edited database.Target
require.NoError(t,
env.db.DB().First(&edited, "webhook_id = ?", wh.ID).Error,
)
assert.Equal(t, "renamed", edited.Name)
require.True(t, edited.Active)
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
url.Values{})
var toggled database.Target
require.NoError(t,
env.db.DB().First(&toggled, "id = ?", edited.ID).Error,
)
assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
url.Values{})
var left int64
require.NoError(t, env.db.DB().Model(&database.Target{}).
Where("webhook_id = ?", wh.ID).Count(&left).Error)
assert.Zero(t, left, "the delete should remove the target")
}
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
// Event Log" link, then resubmits a stored event with the form on
// that page, submitted to the action and with the token the page
// rendered.
func TestHook_ResubmitFromEventLog(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
cookies := env.authCookies(t, userID, "resubmitter")
wh := env.seedWebhook(t, userID)
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
logsPath := env.urlFrom(
t, "/hook/"+wh.ID, `href="([^"]+)"[^>]*>Full Event Log<`, cookies,
)
token, cookies := env.csrfFrom(t, logsPath, cookies)
form := url.Values{}
form.Set("csrf_token", token)
w := env.post(
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
)
webhookDB, err := env.dbMgr.GetDB(wh.ID)
require.NoError(t, err)
var events int64
require.NoError(t,
webhookDB.Model(&database.Event{}).Count(&events).Error,
)
assert.Equal(t, int64(2), events, "the resubmit stores a new event")
}
// 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.
func TestHook_LinksBetweenPages(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "navigator", "somepassword")
cookies := env.authCookies(t, userID, "navigator")
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
targetEdit := page + "/targets/" + tgt.ID + "/edit"
events := page + "/events"
back := `href="([^"]+)"[^>]*>&larr; Back to `
cancel := `href="([^"]+)"[^>]*>Cancel<`
for _, link := range []struct{ from, pattern, want string }{
// The navbar on the profile page: its desktop link, then its
// mobile menu link.
{
"/user/navigator/",
`href="([^"]+)" class="btn-text">Webhooks<`,
list,
},
{
"/user/navigator/",
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`,
list,
},
{list, `href="(/hook/[^"]+)"`, page},
{newForm, back, list},
{newForm, cancel, list},
{page, back, list},
{page, `Recent Events</h2>\s*<a href="([^"]+)"`, events},
{page + "/edit", back, page},
{page + "/edit", cancel, page},
{targetEdit, back, page},
{targetEdit, cancel, page},
{events, back, page},
{events, `href="([^"]+)"[^>]*>Next &rarr;<`, events + "?page=2"},
{events + "?page=2", `href="([^"]+)"[^>]*>&larr; 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)
assert.Equal(t, http.StatusOK, env.get(got, cookies).Code, got)
}
}
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole // TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
// feature the way a user does: render the event log page through // feature the way a user does: render the event log page through
@@ -1164,11 +769,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
wh := env.seedWebhook(t, userID) wh := env.seedWebhook(t, userID)
env.seedEvent(t, wh.ID, stored) env.seedEvent(t, wh.ID, stored)
page := env.get("/hook/"+wh.ID+"/events", cookies) page := env.get("/source/"+wh.ID+"/logs", cookies)
require.Equal(t, http.StatusOK, page.Code) require.Equal(t, http.StatusOK, page.Code)
link := regexp.MustCompile( link := regexp.MustCompile(
`href="(/hook/[^"]+/body)"`, `href="(/source/[^"]+/body)"`,
).FindStringSubmatch(page.Body.String()) ).FindStringSubmatch(page.Body.String())
require.Len( require.Len(
t, link, 2, t, link, 2,
@@ -1214,7 +819,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
const payload = "OWNERS-PAYLOAD-77c1" const payload = "OWNERS-PAYLOAD-77c1"
evt := env.seedEvent(t, wh.ID, payload) evt := env.seedEvent(t, wh.ID, payload)
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body" path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
intruderID, _ := env.seedUser(t, "intruder", "somepassword") intruderID, _ := env.seedUser(t, "intruder", "somepassword")
intruder := env.authCookies(t, intruderID, "intruder") intruder := env.authCookies(t, intruderID, "intruder")
@@ -1225,10 +830,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
anon := env.get(path, nil) anon := env.get(path, nil)
assert.Equal(t, http.StatusSeeOther, anon.Code) assert.Equal(t, http.StatusSeeOther, anon.Code)
assert.Equal( assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
t, "/pages/login?next="+url.QueryEscape(path),
anon.Header().Get("Location"),
)
} }
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action // TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
@@ -1251,7 +853,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`) evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID) dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID + path := "/source/" + wh.ID + "/deliveries/" + dlv.ID +
"/replay" "/replay"
assert.Equal( assert.Equal(
@@ -1277,7 +879,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
// The token and the action URL both come out of the rendered // The token and the action URL both come out of the rendered
// page, so a typo in either the route pattern or the template // page, so a typo in either the route pattern or the template
// fails here. // fails here.
logsPath := "/hook/" + wh.ID + "/events" logsPath := "/source/" + wh.ID + "/logs"
token, cookies := env.csrfFrom(t, logsPath, cookies) token, cookies := env.csrfFrom(t, logsPath, cookies)
@@ -1285,7 +887,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
require.Equal(t, http.StatusOK, page.Code) require.Equal(t, http.StatusOK, page.Code)
action := regexp.MustCompile( action := regexp.MustCompile(
`action="(/hook/[^"]+/replay)"`, `action="(/source/[^"]+/replay)"`,
).FindStringSubmatch(page.Body.String()) ).FindStringSubmatch(page.Body.String())
require.Len( require.Len(
t, action, 2, t, action, 2,
@@ -1310,59 +912,6 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
) )
} }
// --- /h/{uuid} receiver ---
// TestReceiver_EntrypointURLIsRateLimited takes the entrypoint URL
// the webhook page shows and posts to it through the production
// router until the receiver rate limit refuses it. The URL has to
// reach the receiver, and the limit has to apply to it.
func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
t.Parallel()
const limit = 2
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: limit,
})
userID, _ := env.seedUser(t, "receiver", "somepassword")
cookies := env.authCookies(t, userID, "receiver")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: wh.ID,
Path: "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60",
Active: true,
},
).Error)
page := env.get("/hook/"+wh.ID, cookies)
require.Equal(t, http.StatusOK, page.Code)
shown := regexp.MustCompile(`(/h/[^<]+)</code>`).
FindStringSubmatch(page.Body.String())
require.Len(
t, shown, 2, "the webhook page should show the entrypoint URL",
)
for i := range limit {
assert.Equal(
t, http.StatusOK,
env.post(shown[1], url.Values{}, nil).Code,
"request %d should reach the receiver", i,
)
}
assert.Equal(
t, http.StatusTooManyRequests,
env.post(shown[1], url.Values{}, nil).Code,
"the receiver rate limit must apply to the entrypoint URL",
)
}
// metricsConfig is a Config differing from the routing default only // metricsConfig is a Config differing from the routing default only
// in the two /metrics credentials. // in the two /metrics credentials.
func metricsConfig( func metricsConfig(
+1 -1
View File
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
// //
// URL is the third such field. NewRequest builds it as // URL is the third such field. NewRequest builds it as
// scheme://host/path (interfaces.go:183), and on the receiver route // scheme://host/path (interfaces.go:183), and on the receiver route
// that path is /h/<uuid> in full — a write capability, not an // that path is /webhook/<uuid> in full — a write capability, not an
// identifier. It is rebuilt here from the chi route pattern, on every // identifier. It is rebuilt here from the chi route pattern, on every
// route, keeping the scheme and the host. // route, keeping the scheme and the host.
// //
+7 -7
View File
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle, sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
) )
router.HandleFunc("/pages/login", handler) router.HandleFunc("/pages/login", handler)
router.HandleFunc("/h/{uuid}", handler) router.HandleFunc("/webhook/{uuid}", handler)
return router return router
} }
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
// concrete path carries the entrypoint capability. // concrete path carries the entrypoint capability.
func sentryReceiverRequest(client *sentry.Client) *http.Request { func sentryReceiverRequest(client *sentry.Client) *http.Request {
return sentryRequest( return sentryRequest(
client, "/h/"+sentryReceiverUUID, "payload=hello", client, "/webhook/"+sentryReceiverUUID, "payload=hello",
) )
} }
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
t, marshalEvent(t, event), sentryReceiverUUID, t, marshalEvent(t, event), sentryReceiverUUID,
) )
assert.Equal( assert.Equal(
t, "http://example.com/h/{uuid}", event.Request.URL, t, "http://example.com/webhook/{uuid}", event.Request.URL,
) )
} }
@@ -401,7 +401,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) { func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
t.Parallel() t.Parallel()
concrete := "https://example.com/h/" + sentryReceiverUUID concrete := "https://example.com/webhook/" + sentryReceiverUUID
// A request with no chi routing context on it at all, which is // A request with no chi routing context on it at all, which is
// what an event captured outside the router would carry. // what an event captured outside the router would carry.
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
event := sentry.NewEvent() event := sentry.NewEvent()
event.Request = &sentry.Request{URL: concrete} event.Request = &sentry.Request{URL: concrete}
event.Transaction = "POST /h/" + event.Transaction = "POST /webhook/" +
sentryReceiverUUID sentryReceiverUUID
scrubbed := server.ScrubSentryRequestForTest( scrubbed := server.ScrubSentryRequestForTest(
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
event := sentry.NewEvent() event := sentry.NewEvent()
event.Request = &sentry.Request{ event.Request = &sentry.Request{
URL: "/h/" + sentryReceiverUUID, URL: "/webhook/" + sentryReceiverUUID,
} }
event.Transaction = "/h/" + sentryReceiverUUID event.Transaction = "/webhook/" + sentryReceiverUUID
scrubbed := server.ScrubSentryRequestForTest(event, nil) scrubbed := server.ScrubSentryRequestForTest(event, nil)
require.NotNil(t, scrubbed) require.NotNil(t, scrubbed)
+22
View File
@@ -0,0 +1,22 @@
package server_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSettingsPageIsBehindLogin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
w := env.get("/settings", nil)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
w = env.get("/settings", env.authCookies(t, "id", "admin"))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
}
+1 -11
View File
@@ -22,16 +22,6 @@
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than # The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
# a condition CI runs under. If a CPU-limited runner ever puts a real run near # a condition CI runs under. If a CPU-limited runner ever puts a real run near
# 67s, that is the datum to revisit the org figure with. # 67s, that is the datum to revisit the org figure with.
#
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
# binaries build or run at once, each with at most eight parallel tests. Under
# -race every test binary and every link costs a few hundred MB, so the
# defaults (one per core) add up to several GB on a many-core host.
#
# No -v: the Docker build cuts each step's log off at 2 MiB, and verbose output
# from the whole suite passes that before a failure is printed. Without it, go
# test prints one result line per package and, for a package that fails,
# everything its tests wrote, application log lines included.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -39,7 +29,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/assets" "$ROOT/script/assets"
go test -race -p 4 -parallel 8 -timeout 90s ./... go test -v -race -timeout 90s ./...
} }
main "$@" main "$@"
-1
View File
@@ -24,7 +24,6 @@
<form method="POST" action="/pages/login" class="space-y-6"> <form method="POST" action="/pages/login" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="next" value="{{.Next}}">
<div class="form-group"> <div class="form-group">
<label for="username" class="label">Username</label> <label for="username" class="label">Username</label>
<input <input
+8 -4
View File
@@ -6,19 +6,18 @@
</div> </div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
{{if .User}}
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"> <button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/> <path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg> </svg>
</button> </button>
{{end}}
<!-- Desktop navigation --> <!-- Desktop navigation -->
<div class="hidden md:flex items-center gap-4"> <div class="hidden md:flex items-center gap-4">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text">Webhooks</a> <a href="/sources" class="btn-text">Webhooks</a>
<a href="/settings" class="btn-text">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text"> <a href="/user/{{.User.Username}}" class="btn-text">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16"> <svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
@@ -30,6 +29,8 @@
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-text">Logout</button>
</form> </form>
{{else}}
<a href="/pages/login" class="btn-primary">Login</a>
{{end}} {{end}}
</div> </div>
</div> </div>
@@ -38,12 +39,15 @@
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200"> <div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a> <a href="/sources" class="btn-text w-full text-left">Webhooks</a>
<a href="/settings" class="btn-text w-full text-left">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> <a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button> <button type="submit" class="btn-text w-full text-left">Logout</button>
</form> </form>
{{else}}
<a href="/pages/login" class="btn-primary w-full">Login</a>
{{end}} {{end}}
</div> </div>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
{{template "base" .}}
{{define "title"}}Settings - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Settings}}
<div class="p-4">
<div class="flex justify-between items-start gap-4">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
</div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
+13 -15
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }"> <div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2"> <div class="flex justify-between items-center mt-2">
<div> <div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
@@ -14,9 +14,9 @@
{{end}} {{end}}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a> <a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a> <a href="/source/{{.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?')"> <form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button> <button type="submit" class="btn-danger">Delete</button>
</form> </form>
@@ -24,8 +24,6 @@
</div> </div>
</div> </div>
{{template "webhook_stats" .}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints --> <!-- Entrypoints -->
<div class="card"> <div class="card">
@@ -41,7 +39,7 @@
<!-- Add entrypoint form --> <!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> <div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2"> <form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1"> <input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
<button type="submit" class="btn-primary text-sm">Add</button> <button type="submit" class="btn-primary text-sm">Add</button>
@@ -59,20 +57,20 @@
{{else}} {{else}}
<span class="badge-error">Inactive</span> <span class="badge-error">Inactive</span>
{{end}} {{end}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline"> <form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}"> <button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline"> <form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button> <button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form> </form>
</div> </div>
</div> </div>
<div class="flex items-start gap-2 mt-1"> <div class="flex items-start gap-2 mt-1">
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code> <code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
<!-- Hidden until app.js reveals it; without the <!-- Hidden until app.js reveals it; without the
script the URL above stays selectable. --> script the URL above stays selectable. -->
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button> <button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
@@ -100,7 +98,7 @@
<!-- Add target form --> <!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200"> <div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3"> <form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1"> <input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
@@ -153,14 +151,14 @@
{{else}} {{else}}
<span class="badge-error">Inactive</span> <span class="badge-error">Inactive</span>
{{end}} {{end}}
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a> <a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline"> <form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}"> <button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline"> <form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button> <button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form> </form>
@@ -184,7 +182,7 @@
<div class="card mt-6"> <div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center"> <div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2> <h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a> <a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
+3 -3
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
</div> </div>
@@ -14,7 +14,7 @@
<div class="alert-error">{{.Error}}</div> <div class="alert-error">{{.Error}}</div>
{{end}} {{end}}
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6"> <form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group"> <div class="form-group">
<label for="name" class="label">Name</label> <label for="name" class="label">Name</label>
@@ -34,7 +34,7 @@
<div class="flex gap-3"> <div class="flex gap-3">
<button type="submit" class="btn-primary">Save Changes</button> <button type="submit" class="btn-primary">Save Changes</button>
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a> <a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
+8 -8
View File
@@ -1,13 +1,13 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}} {{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}} {{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8"> <div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2"> <div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1> <h1 class="text-2xl font-medium text-gray-900">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">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
</div> </div>
</div> </div>
@@ -55,7 +55,7 @@
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}} {{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}} {{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
</div> </div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline"> <form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}"> <input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button> <button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
@@ -63,7 +63,7 @@
</div> </div>
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre> <pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
{{if .BodyTruncated}} {{if .BodyTruncated}}
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p> <p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
{{end}} {{end}}
{{if .Deliveries}} {{if .Deliveries}}
@@ -79,7 +79,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{{if .Status.Terminal}} {{if .Status.Terminal}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop> <form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}"> <input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button> <button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
@@ -139,11 +139,11 @@
{{if or .HasPrev .HasNext}} {{if or .HasPrev .HasNext}}
<div class="flex justify-center gap-2 mt-6"> <div class="flex justify-center gap-2 mt-6">
{{if .HasPrev}} {{if .HasPrev}}
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a> <a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a>
{{end}} {{end}}
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span> <span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
{{if .HasNext}} {{if .HasNext}}
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a> <a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+3 -3
View File
@@ -6,7 +6,7 @@
<div class="max-w-6xl mx-auto px-6 py-8"> <div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1> <h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
<a href="/hooks/new" class="btn-primary"> <a href="/sources/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg> </svg>
@@ -17,7 +17,7 @@
{{if .Webhooks}} {{if .Webhooks}}
<div class="grid gap-4"> <div class="grid gap-4">
{{range .Webhooks}} {{range .Webhooks}}
<a href="/hook/{{.ID}}" class="card-elevated p-6 block"> <a href="/source/{{.ID}}" class="card-elevated p-6 block">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2> <h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
@@ -42,7 +42,7 @@
</svg> </svg>
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2> <h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p> <p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
<a href="/hooks/new" class="btn-primary">Create Webhook</a> <a href="/sources/new" class="btn-primary">Create Webhook</a>
</div> </div>
{{end}} {{end}}
</div> </div>
+3 -3
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
</div> </div>
@@ -14,7 +14,7 @@
<div class="alert-error">{{.Error}}</div> <div class="alert-error">{{.Error}}</div>
{{end}} {{end}}
<form method="POST" action="/hooks/new" class="space-y-6"> <form method="POST" action="/sources/new" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group"> <div class="form-group">
<label for="name" class="label">Name</label> <label for="name" class="label">Name</label>
@@ -34,7 +34,7 @@
<div class="flex gap-3"> <div class="flex gap-3">
<button type="submit" class="btn-primary">Create Webhook</button> <button type="submit" class="btn-primary">Create Webhook</button>
<a href="/hooks" class="btn-secondary">Cancel</a> <a href="/sources" class="btn-secondary">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
+3 -3
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p> <p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
</div> </div>
@@ -21,7 +21,7 @@
</div> </div>
{{end}} {{end}}
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6"> <form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group"> <div class="form-group">
@@ -75,7 +75,7 @@
<div class="flex gap-3"> <div class="flex gap-3">
<button type="submit" class="btn-primary">Save Changes</button> <button type="submit" class="btn-primary">Save Changes</button>
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a> <a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
-95
View File
@@ -1,95 +0,0 @@
{{define "webhook_stats"}}
<!-- Statistics pane at the top of the webhook page. -->
<div class="card mb-6">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
</div>
{{with .Stats}}
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
<div>
<span class="text-gray-500">Entrypoints</span>
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
</div>
<div>
<span class="text-gray-500">Targets</span>
<span class="font-medium text-gray-900">{{.Targets}}</span>
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
</div>
<div>
<span class="text-gray-500">Deliveries in progress</span>
<span class="font-medium text-gray-900">{{.InProgress}}</span>
</div>
<div>
<span class="text-gray-500">Last event</span>
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
</div>
<div>
<span class="text-gray-500">Retention</span>
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
</div>
</div>
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
<div>
<table class="w-full text-center text-gray-900">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
<th></th>
<th class="py-2 font-medium">Lifetime</th>
<th class="py-2 font-medium">Within retention</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-2 text-left text-gray-600">Events</td>
<td class="py-2">{{.Lifetime.Events}}</td>
<td class="py-2">{{.WithinRetention.Events}}</td>
</tr>
<tr>
<td class="py-2 text-left text-gray-600">Deliveries</td>
<td class="py-2">{{.Lifetime.Deliveries}}</td>
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
</tr>
<tr>
<td class="py-2 text-left text-gray-600">Failures</td>
<td class="py-2">{{.Lifetime.Failures}}</td>
<td class="py-2">{{.WithinRetention.Failures}}</td>
</tr>
</tbody>
</table>
</div>
<div>
<table class="w-full text-center text-gray-900">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
<th></th>
<th class="py-2 font-medium">Last 10 minutes</th>
<th class="py-2 font-medium">Last 24 hours</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-2 text-left text-gray-600">Events</td>
<td class="py-2">{{.Last10Minutes.Events}}</td>
<td class="py-2">{{.Last24Hours.Events}}</td>
</tr>
<tr>
<td class="py-2 text-left text-gray-600">Failures</td>
<td class="py-2">{{.Last10Minutes.Failed}}</td>
<td class="py-2">{{.Last24Hours.Failed}}</td>
</tr>
<tr>
<td class="py-2 text-left text-gray-600">Failure percentage</td>
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
</tr>
</tbody>
</table>
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
</div>
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
{{end}}
</div>
{{end}}