Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a93e6b4ed |
Binary file not shown.
Binary file not shown.
+3
-2
@@ -38,8 +38,9 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||
# suite executes.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
||||
# suite executes. chromium runs the browser test in internal/server, which
|
||||
# skips where it is not installed.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq chromium && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets
|
||||
with retry support, logging, and observability. Category: infrastructure
|
||||
/ 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
|
||||
shared secrets, HMAC signatures or token headers on the receiver, and
|
||||
will not add them — read
|
||||
@@ -1193,7 +1193,7 @@ backups at rest and restrict who can read them.
|
||||
|
||||
**The entrypoint UUID is the credential, and it is the only one.**
|
||||
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
|
||||
receiver verifies nothing else about the sender.
|
||||
|
||||
@@ -1270,9 +1270,20 @@ We provide:
|
||||
|
||||
## Third-party browser assets
|
||||
|
||||
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||
publishes it. It is a dependency, not this repo's build output, so
|
||||
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
||||
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
||||
the standard `alpinejs` build needs to run the expressions written in the
|
||||
markup. The CSP build runs no expressions, so every Alpine directive in
|
||||
`templates/` only names a property or method of a component registered in
|
||||
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
||||
`x-data="{ open: false }"` or `@click="open = !open"`. A browser test in
|
||||
`internal/server` loads the pages under the real policy and fails on any
|
||||
directive that does not work. It needs `chromium` on `PATH` and skips without
|
||||
it; the Dockerfile installs it, so the image build always runs it.
|
||||
|
||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||
byte as the npm registry publishes it. It is a dependency, not this repo's
|
||||
build output, so
|
||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
`vendor/` directory as its module vendor directory.
|
||||
@@ -1285,10 +1296,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
|
||||
`.dockerignore` keeps any host copy out of the build context.
|
||||
|
||||
To move to a new version: download
|
||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
|
||||
against the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||
with it, update its file name in `script/assets`, and run `make check`.
|
||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
||||
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
||||
`script/assets`, and run `make check`.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -1527,7 +1539,7 @@ the full request and creates an Event.
|
||||
| -------------- | ------- | ----------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `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 |
|
||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||
|
||||
@@ -1909,7 +1921,7 @@ runtime, though CGO is required at build time due to the transitive
|
||||
```
|
||||
External Service
|
||||
│
|
||||
│ POST /h/{uuid}
|
||||
│ POST /webhook/{uuid}
|
||||
▼
|
||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ chi Router │────►│ Middleware │────►│ Webhook │
|
||||
@@ -2135,7 +2147,7 @@ The middleware records three more on the same registry:
|
||||
Two of those labels are written once per request from bytes the client
|
||||
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)`,
|
||||
and no entrypoint UUID ever reaches a label.
|
||||
- `method` is the request method when the router can route it, and
|
||||
@@ -2165,7 +2177,7 @@ unpredictable rates, and blanket limits shared with other routes would
|
||||
cause legitimate deliveries to be dropped.
|
||||
|
||||
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
|
||||
affecting other senders of the same entrypoint or the same sender's
|
||||
other entrypoints. Keying on the path rather than on the entrypoint
|
||||
@@ -2202,7 +2214,7 @@ log spends. The access log is bounded by neither limit: every request
|
||||
is recorded once at `INFO`, served or rejected alike.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -2236,12 +2248,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
||||
|
||||
The same hook rewrites the request URL. The SDK builds it as
|
||||
`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
|
||||
service accepts and its targets then deliver. A tracker has its own
|
||||
retention, access control and deletion policy, so the rule the access
|
||||
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
|
||||
discarded rather than edited, so a future SDK version that starts
|
||||
@@ -2285,8 +2297,8 @@ fallback is never the concrete path. The path becomes the literal
|
||||
rewrite cannot parse into a scheme is withheld whole. A transaction
|
||||
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
|
||||
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)`
|
||||
where it is not.
|
||||
`POST /webhook/{uuid}` where the pattern is known and `POST
|
||||
/(redacted)` where it is not.
|
||||
|
||||
The headers are an allowlist for the same reason the rules above are
|
||||
unconditional: the SDK's own filter removes four names and passes
|
||||
@@ -2421,7 +2433,7 @@ logger printed the fully interpolated SQL — parameters and all — to
|
||||
standard output on every statement that returned an error, including a
|
||||
plain record-not-found, at a level no operator setting reached. Two of
|
||||
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
|
||||
picks outright. Every
|
||||
`gorm.Open` in the service now installs the adapter in
|
||||
@@ -2699,10 +2711,10 @@ abuse limit later; they are tracked as future work.
|
||||
|
||||
| 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`, `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
|
||||
|
||||
@@ -2718,25 +2730,25 @@ abuse limit later; they are tracked as future work.
|
||||
| ------ | ------------------------ | ----------- |
|
||||
| `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) |
|
||||
| `GET` | `/hooks` | List user's webhooks |
|
||||
| `GET` | `/hooks/new` | Create webhook form |
|
||||
| `POST` | `/hooks/new` | Create webhook submission |
|
||||
| `GET` | `/hook/{id}` | Webhook detail view |
|
||||
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||
| `GET` | `/hook/{id}/events/{eventID}/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}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||
| `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` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
||||
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||
| `GET` | `/sources` | List user's webhooks |
|
||||
| `GET` | `/sources/new` | Create webhook form |
|
||||
| `POST` | `/sources/new` | Create webhook submission |
|
||||
| `GET` | `/source/{id}` | Webhook detail view |
|
||||
| `GET` | `/source/{id}/edit` | Edit webhook form |
|
||||
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
||||
| `POST` | `/source/{id}/delete` | Delete webhook |
|
||||
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
||||
| `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` | `/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` | `/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` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
||||
| `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` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
|
||||
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||
|
||||
#### Infrastructure Endpoints
|
||||
|
||||
@@ -2769,7 +2781,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
||||
```
|
||||
webhooker/
|
||||
├── 3p/
|
||||
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
||||
├── cmd/webhooker/
|
||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||
├── internal/
|
||||
@@ -2862,8 +2874,8 @@ webhooker/
|
||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
||||
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
@@ -2937,8 +2949,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
|
||||
more than set a header or start a timer.
|
||||
|
||||
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||
CSRF middleware in every one of those route groups, because
|
||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||
@@ -2962,7 +2974,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
|
||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||
rather than global: **PasswordChangeRateLimit** 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
|
||||
check, see [The login endpoint](#the-login-endpoint).
|
||||
|
||||
@@ -3003,8 +3015,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
||||
by middleware that runs before CSRF parses the form
|
||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||
on all state-changing forms (cookie-based double-submit tokens with
|
||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
|
||||
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
|
||||
`/api` (stateless API). The middleware detects TLS per-request through
|
||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||
to set appropriate cookie security flags and Origin/Referer validation
|
||||
|
||||
@@ -4,6 +4,8 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/getsentry/sentry-go v0.25.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
@@ -29,7 +31,12 @@ require (
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -50,7 +57,7 @@ require (
|
||||
go.uber.org/zap v1.23.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
|
||||
@@ -6,6 +6,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
||||
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
||||
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -23,6 +29,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
@@ -55,12 +69,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -111,8 +129,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// SQL — parameters and all — for every statement that returns an
|
||||
// error, including gorm.ErrRecordNotFound. Two of this service's
|
||||
// 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
|
||||
// the client picks outright. Under the default logger each of those
|
||||
// misses printed an unbounded, attacker-chosen string, at no level the
|
||||
|
||||
@@ -362,7 +362,7 @@ func (h *Handlers) finishReplay(
|
||||
webhook database.Webhook,
|
||||
code replayOutcomeCode,
|
||||
) {
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
dest := "/source/" + webhook.ID + "/logs?" +
|
||||
replayOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
|
||||
@@ -138,7 +138,7 @@ func postReplay(
|
||||
t.Helper()
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+webhookID+"/deliveries/"+
|
||||
"/source/"+webhookID+"/deliveries/"+
|
||||
deliveryID+"/replay",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
"/source/"+wh.ID+"/logs?replay=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||
"/source/"+wh.ID+"/logs?replay=target-deleted",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||
"/source/"+wh.ID+"/logs?replay=target-missing",
|
||||
missing.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||
require.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=queued",
|
||||
"/source/"+wh.ID+"/logs?replay=queued",
|
||||
first.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||
"/source/"+wh.ID+"/logs?replay=in-flight",
|
||||
second.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||
"/source/"+wh.ID+"/logs?replay=not-terminal",
|
||||
pending.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
||||
|
||||
assert.Contains(
|
||||
t, body,
|
||||
`action="/hook/`+wh.ID+`/deliveries/`+
|
||||
`action="/source/`+wh.ID+`/deliveries/`+
|
||||
original.ID+`/replay"`,
|
||||
)
|
||||
assert.Contains(t, body, `method="POST"`)
|
||||
|
||||
@@ -64,8 +64,8 @@ func fetchEventBody(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/hook/"+url.PathEscape(sourceID)+
|
||||
"/events/"+url.PathEscape(eventID)+"/body",
|
||||
"/source/"+url.PathEscape(sourceID)+
|
||||
"/logs/"+url.PathEscape(eventID)+"/body",
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
||||
page := renderSourceLogsPage(t, h, sess, big.ID)
|
||||
assert.Contains(
|
||||
t, page,
|
||||
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
|
||||
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
|
||||
)
|
||||
|
||||
small := seedWebhook(t, db)
|
||||
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
||||
page = renderSourceLogsPage(t, h, sess, small.ID)
|
||||
assert.NotContains(
|
||||
t, page,
|
||||
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
|
||||
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
|
||||
webhook database.Webhook,
|
||||
code resubmitOutcomeCode,
|
||||
) {
|
||||
dest := "/hook/" + webhook.ID + "/events?" +
|
||||
dest := "/source/" + webhook.ID + "/logs?" +
|
||||
resubmitOutcomeParam + "=" + string(code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
|
||||
@@ -65,7 +65,7 @@ func postResubmit(
|
||||
t.Helper()
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
),
|
||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
"a resubmit must not be refused while an earlier "+
|
||||
"one is in flight",
|
||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
||||
w.Header().Get("Location"),
|
||||
"an inactive target is skipped, not an error",
|
||||
)
|
||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t,
|
||||
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
@@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
|
||||
)
|
||||
assert.Contains(
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func postWebhook(
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/h/x",
|
||||
context.Background(), http.MethodPost, "/webhook/x",
|
||||
strings.NewReader("{}"),
|
||||
)
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||
assert.Equal(
|
||||
t, "/hooks", w2.Header().Get("Location"),
|
||||
t, "/sources", w2.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.session.Get(r)
|
||||
if err == nil && s.session.IsAuthenticated(sess) {
|
||||
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ package handlers_test
|
||||
// this package reach a value an UNAUTHENTICATED client 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
|
||||
// nothing;
|
||||
// - 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.
|
||||
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
||||
router := chi.NewRouter()
|
||||
router.Post("/h/{uuid}", h.HandleWebhook())
|
||||
router.Post("/webhook/{uuid}", h.HandleWebhook())
|
||||
|
||||
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
|
||||
// and the handler sees the raw bytes rather than their percent-escaped
|
||||
@@ -210,7 +210,7 @@ func postReceiver(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/h/"+url.PathEscape(segment),
|
||||
"/webhook/"+url.PathEscape(segment),
|
||||
strings.NewReader(""),
|
||||
)
|
||||
req.URL.RawPath = ""
|
||||
@@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
|
||||
http.StatusServiceUnavailable,
|
||||
postLoginAtPath(
|
||||
t, h,
|
||||
"/hook/"+url.PathEscape(
|
||||
"/source/"+url.PathEscape(
|
||||
oversizedFill(fill),
|
||||
)+"/login",
|
||||
),
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||
body := strings.Repeat("é", 1024)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, "/h/x",
|
||||
context.Background(), http.MethodPost, "/webhook/x",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
"/source/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
"/source/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
"/source/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
||||
)
|
||||
assert.Empty(
|
||||
t, w.Header().Get("Location"),
|
||||
"a failed deletion must not redirect to /hooks",
|
||||
"a failed deletion must not redirect to /sources",
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/delete",
|
||||
"/source/"+wh.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{paramSourceID: wh.ID},
|
||||
)
|
||||
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||
|
||||
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(
|
||||
t, int64(0),
|
||||
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
||||
)
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||
cookies,
|
||||
map[string]string{
|
||||
paramSourceID: wh.ID,
|
||||
|
||||
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/hook/"+f.webhook,
|
||||
"/source/"+f.webhook,
|
||||
nil,
|
||||
)
|
||||
req.Host = host
|
||||
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
tc.scheme+"://"+host+"/h/"+fixture.path,
|
||||
tc.scheme+"://"+host+"/webhook/"+fixture.path,
|
||||
fixture.entrypointURL(
|
||||
t, host, forwardedProto(tc.header),
|
||||
),
|
||||
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"https://"+host+"/h/"+fixture.path,
|
||||
"https://"+host+"/webhook/"+fixture.path,
|
||||
got,
|
||||
"a connection this process terminated with TLS "+
|
||||
"outranks a header claiming plaintext",
|
||||
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"https://"+host+"/h/"+fixture.path,
|
||||
"https://"+host+"/webhook/"+fixture.path,
|
||||
fixture.entrypointURL(
|
||||
t, host, forwardedProto("HTTPS"),
|
||||
),
|
||||
|
||||
@@ -82,7 +82,7 @@ func serveSourceDetailPage(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/hook/"+webhookID,
|
||||
"/source/"+webhookID,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
|
||||
t.Helper()
|
||||
|
||||
req := postRequest(
|
||||
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
|
||||
"/source/"+webhookID+"/targets/"+targetID+"/delete",
|
||||
authenticatedCookies(
|
||||
t, sess, deleteTestUserID, deleteTestUsername,
|
||||
),
|
||||
|
||||
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"/hook/"+webhookID+"/events"+query,
|
||||
"/source/"+webhookID+"/logs"+query,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
||||
)
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -588,7 +588,7 @@ func (h *Handlers) applyWebhookEdit(
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -671,7 +671,7 @@ func (h *Handlers) deleteWebhookResources(
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||
@@ -1264,7 +1264,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1320,7 +1320,7 @@ func (h *Handlers) processTargetCreate(
|
||||
//
|
||||
// Every field here is read with PostFormValue, not FormValue.
|
||||
// FormValue falls back to the query string, which would let
|
||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// configure a target from a value the request line carries — and
|
||||
// the request line, unlike the body, is what logs, proxies,
|
||||
// Referer headers and error trackers record.
|
||||
@@ -1377,7 +1377,7 @@ func (h *Handlers) processTargetCreate(
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1435,7 +1435,7 @@ type targetFormInput struct {
|
||||
//
|
||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||
// falls back to the query string, which would let
|
||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
||||
// configure a target from a value the request line carries — and the
|
||||
// request line, unlike the body, is what logs, proxies, Referer
|
||||
// headers and error trackers record. The headers field is under the
|
||||
@@ -1714,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/hook/"+webhook.ID,
|
||||
"/source/"+webhook.ID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
@@ -1811,7 +1811,7 @@ func (h *Handlers) toggleChildResource(
|
||||
|
||||
http.Redirect(
|
||||
w, r,
|
||||
"/hook/"+webhook.ID,
|
||||
"/source/"+webhook.ID,
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func submitCreate(
|
||||
form.Set("retention_days", *retention)
|
||||
}
|
||||
|
||||
req := formRequest("/hooks/new", cookies, form, nil)
|
||||
req := formRequest("/sources/new", cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
@@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
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)
|
||||
@@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
||||
form.Set("description", description)
|
||||
form.Set("retention_days", "nonsense")
|
||||
|
||||
req := formRequest("/hooks/new", env.cookies, form, nil)
|
||||
req := formRequest("/sources/new", env.cookies, form, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||
@@ -430,7 +430,7 @@ func submitEdit(
|
||||
form.Set("retention_days", retention)
|
||||
|
||||
req := formRequest(
|
||||
"/hook/"+wh.ID+"/edit",
|
||||
"/source/"+wh.ID+"/edit",
|
||||
env.cookies,
|
||||
form,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
@@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
||||
)
|
||||
|
||||
req := getRequest(
|
||||
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
||||
t, "/source/"+wh.ID+"/edit", env.cookies,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
|
||||
listW := httptest.NewRecorder()
|
||||
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)
|
||||
@@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||
detailW,
|
||||
getRequest(
|
||||
t, "/hook/"+wh.ID, env.cookies,
|
||||
t, "/source/"+wh.ID, env.cookies,
|
||||
map[string]string{sourceIDParam: wh.ID},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -76,11 +76,11 @@ func postTargetCreate(
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.Logging())
|
||||
router.Post(
|
||||
"/hook/{sourceID}/targets",
|
||||
"/source/{sourceID}/targets",
|
||||
env.handlers.HandleTargetCreate(),
|
||||
)
|
||||
|
||||
target := "/hook/" + webhookID + "/targets"
|
||||
target := "/source/" + webhookID + "/targets"
|
||||
if query != "" {
|
||||
target += "?" + query
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func postTargetCreate(
|
||||
// regression test for the ingress leak. r.FormValue falls back to the
|
||||
// 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
|
||||
// carried on the request line — where logs, proxies, Referer headers
|
||||
|
||||
@@ -47,7 +47,7 @@ type targetEditView struct {
|
||||
//
|
||||
// This page is the one place the full destination URL and header
|
||||
// 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
|
||||
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
||||
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||
@@ -163,7 +163,7 @@ func (h *Handlers) applyTargetEdit(
|
||||
}
|
||||
|
||||
http.Redirect(
|
||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,15 +42,15 @@ const (
|
||||
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
||||
router := chi.NewRouter()
|
||||
router.Post(
|
||||
"/hook/{sourceID}/targets",
|
||||
"/source/{sourceID}/targets",
|
||||
env.handlers.HandleTargetCreate(),
|
||||
)
|
||||
router.Get(
|
||||
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||
"/source/{sourceID}/targets/{targetID}/edit",
|
||||
env.handlers.HandleTargetEdit(),
|
||||
)
|
||||
router.Post(
|
||||
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||
"/source/{sourceID}/targets/{targetID}/edit",
|
||||
env.handlers.HandleTargetEditSubmit(),
|
||||
)
|
||||
|
||||
@@ -117,7 +117,7 @@ func seedHTTPTarget(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/hook/"+webhook.ID+"/targets", form,
|
||||
"/source/"+webhook.ID+"/targets", form,
|
||||
)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||
|
||||
@@ -188,7 +188,7 @@ func submitTargetEdit(
|
||||
) *httptest.ResponseRecorder {
|
||||
return serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
|
||||
"/source/"+webhookID+"/targets/"+targetID+"/edit",
|
||||
form,
|
||||
)
|
||||
}
|
||||
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodGet,
|
||||
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/hook/"+webhook.ID+"/targets/"+target.ID+
|
||||
"/source/"+webhook.ID+"/targets/"+target.ID+
|
||||
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
||||
"&headers="+url.QueryEscape(editAuthHeader),
|
||||
form,
|
||||
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
|
||||
|
||||
get := serveTarget(
|
||||
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)
|
||||
|
||||
@@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s(
|
||||
|
||||
w := serveTarget(
|
||||
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)
|
||||
|
||||
@@ -102,7 +102,7 @@ func createWithRetries(
|
||||
|
||||
w := serveTarget(
|
||||
env, http.MethodPost,
|
||||
"/hook/"+webhook.ID+"/targets",
|
||||
"/source/"+webhook.ID+"/targets",
|
||||
createRetriesForm(retries),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ func renderPage(
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -94,11 +95,15 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
||||
t, body, ">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
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -125,57 +130,7 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
||||
|
||||
assert.Contains(t, body, "Edit Webhook")
|
||||
assert.NotContains(t, body, ">Sources<")
|
||||
assert.Contains(t, body, `href="/hook/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>`,
|
||||
)
|
||||
assert.Contains(t, body, `href="/source/wh-1"`)
|
||||
}
|
||||
|
||||
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
||||
@@ -328,7 +283,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
||||
t, body,
|
||||
`<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(
|
||||
t, body,
|
||||
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
||||
|
||||
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
|
||||
"path = ?", entrypointUUID,
|
||||
).First(&entrypoint)
|
||||
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
|
||||
// client-chosen on exactly the branch where the lookup
|
||||
// failed. DEBUG is off by default; the cap is what keeps
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
||||
)
|
||||
|
||||
router.HandleFunc(
|
||||
"/h/{uuid}",
|
||||
"/webhook/{uuid}",
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
// Stands in for the real handler: an unknown entrypoint
|
||||
// UUID 404s, a known one succeeds.
|
||||
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
||||
assertFloodIsBounded(
|
||||
t,
|
||||
func(i int) string {
|
||||
return "/h/" + attackerMarker +
|
||||
return "/webhook/" + attackerMarker +
|
||||
strings.Repeat("x", i) + "?q=" + attackerMarker
|
||||
},
|
||||
http.StatusNotFound,
|
||||
"/h/{uuid}",
|
||||
"/webhook/{uuid}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -346,10 +346,10 @@ type sizeCase struct {
|
||||
func lineSizeCases() map[string]sizeCase {
|
||||
cases := map[string]sizeCase{
|
||||
"oversized path segment": {
|
||||
target: "/h/" + attackerMarker +
|
||||
target: "/webhook/" + attackerMarker +
|
||||
strings.Repeat("x", oversizedSegmentBytes),
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantURL: "/h/{uuid}",
|
||||
wantURL: "/webhook/{uuid}",
|
||||
bound: maxLineBytes,
|
||||
},
|
||||
// /.well-known/healthcheck answers 200 to anyone and has no
|
||||
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
||||
router := accessLogRouter(m)
|
||||
|
||||
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
|
||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||
entries := accessLogEntries(t, buf)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
http.StatusNotFound,
|
||||
get(t, router, "/h/"+attackerMarker),
|
||||
get(t, router, "/webhook/"+attackerMarker),
|
||||
)
|
||||
|
||||
entries := accessLogEntries(t, buf)
|
||||
|
||||
@@ -41,7 +41,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
// CSRF is registered ahead of RequireAuth on every route
|
||||
// group that uses it, so this WARN is reachable by an
|
||||
// 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
|
||||
// the access log. remote_addr is set by net/http from the
|
||||
// accepted connection rather than by the client, and
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
|
||||
t, newHandler,
|
||||
)
|
||||
|
||||
path := "/hook/" +
|
||||
path := "/source/" +
|
||||
oversizedPathSegment(fill) + "/edit"
|
||||
|
||||
assert.Equal(
|
||||
@@ -434,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/hook/"+
|
||||
"/source/"+
|
||||
oversizedPathSegment(fill)+"/login",
|
||||
nil,
|
||||
)
|
||||
@@ -499,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
postOversize(
|
||||
h,
|
||||
"/hook/"+segment(i)+"/edit",
|
||||
"/source/"+segment(i)+"/edit",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||
// while doing it.
|
||||
|
||||
@@ -50,7 +50,7 @@ func realMethods() []string {
|
||||
// dimension varying, so any series growth a probe produces is the
|
||||
// method label's and nothing else's.
|
||||
func methodProbePath() string {
|
||||
return "/h/" + uuid.NewString()
|
||||
return "/webhook/" + uuid.NewString()
|
||||
}
|
||||
|
||||
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
|
||||
// receiverRoutePattern is the one handler label every receiver
|
||||
// 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
|
||||
// interceptor still reports status and size after the handler id
|
||||
@@ -143,13 +143,13 @@ func drivePaths(
|
||||
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.
|
||||
func receiverPaths(n int) []string {
|
||||
paths := make([]string, 0, n)
|
||||
|
||||
for range n {
|
||||
paths = append(paths, "/h/"+uuid.NewString())
|
||||
paths = append(paths, "/webhook/"+uuid.NewString())
|
||||
}
|
||||
|
||||
return paths
|
||||
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
|
||||
|
||||
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||
// 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.
|
||||
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||
// The scrape must not republish the UUIDs it was driven with.
|
||||
// They are the receiver's only credential.
|
||||
for _, p := range paths {
|
||||
id := strings.TrimPrefix(p, "/h/")
|
||||
id := strings.TrimPrefix(p, "/webhook/")
|
||||
for label := range labels {
|
||||
assert.NotContains(
|
||||
t, label, id,
|
||||
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
paths = append(paths, "/"+id)
|
||||
} else {
|
||||
paths = append(paths, "/h/"+id+"/"+id)
|
||||
paths = append(paths, "/webhook/"+id+"/"+id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
|
||||
//
|
||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||
// 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
|
||||
// write attacker-chosen text, of attacker-chosen length, into the
|
||||
// operator's log at one line per request. The pattern comes from the
|
||||
@@ -560,7 +560,7 @@ func (s *Middleware) MaxBodySize(
|
||||
// internal/server/routes.go), so an
|
||||
// unauthenticated client reaches it with a path
|
||||
// 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
|
||||
// send. At WARN, on by default, that is a
|
||||
// write into the operator's log sized by the
|
||||
|
||||
@@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/hooks", nil,
|
||||
http.MethodGet, "/sources", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ const (
|
||||
|
||||
// receiverAggregateMultiplier scales the configured
|
||||
// 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 at their full rate, while still capping what one
|
||||
// address costs the unauthenticated receiver.
|
||||
@@ -390,7 +390,7 @@ func (m *Middleware) postRateLimit(
|
||||
// It is Config.ReceiverRateLimit requests per minute.
|
||||
//
|
||||
// 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
|
||||
// and never refills one — and every such request still reaches the
|
||||
// handler's entrypoint lookup before it 404s. The outer limit is
|
||||
|
||||
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
// pass.
|
||||
for i := range limit {
|
||||
w := receiverPost(
|
||||
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||
)
|
||||
assert.Equal(
|
||||
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
|
||||
// carrying a Retry-After header.
|
||||
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.NotEmpty(
|
||||
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||
|
||||
// The same IP is not limited on a different entrypoint.
|
||||
w = receiverPost(
|
||||
handler, "9.9.9.9:1234", "/h/uuid-b",
|
||||
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
||||
)
|
||||
assert.Equal(
|
||||
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.
|
||||
w = receiverPost(
|
||||
handler, "8.8.8.8:1234", "/h/uuid-a",
|
||||
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
|
||||
const (
|
||||
limit = 2
|
||||
ip = "7.7.7.7:1234"
|
||||
path = "/h/uuid-c"
|
||||
path = "/webhook/uuid-c"
|
||||
)
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
// none of them shares a per-entrypoint bucket with another.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/h/invented-%d", i),
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
}
|
||||
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"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
|
||||
// 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(
|
||||
t, http.StatusOK, w.Code,
|
||||
"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
|
||||
// per-entrypoint limiter but still count against the aggregate.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(handler, ip, "/h/exhausted")
|
||||
w := receiverPost(handler, ip, "/webhook/exhausted")
|
||||
|
||||
want := http.StatusTooManyRequests
|
||||
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(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"requests rejected per entrypoint must still count "+
|
||||
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
||||
const (
|
||||
limit = 3
|
||||
peer = "203.0.113.10:44444"
|
||||
path = "/h/uuid-d"
|
||||
path = "/webhook/uuid-d"
|
||||
)
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// browserTimeout bounds everything one test does in the browser.
|
||||
browserTimeout = 60 * time.Second
|
||||
|
||||
// settleTimeout bounds the wait for an element to show or hide.
|
||||
settleTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||
// event log in headless Chromium, served by the real router and so
|
||||
// under the real Content-Security-Policy, and checks that the pages'
|
||||
// Alpine.js directives work.
|
||||
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, consoleProblems := startBrowser(t)
|
||||
|
||||
env := newTestEnv(t)
|
||||
srv := httptest.NewServer(env.router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
userID, _ := env.seedUser(t, "browser", "browser-password")
|
||||
webhook := env.seedWebhook(t, userID)
|
||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
||||
))
|
||||
|
||||
checkAddForms(ctx, t, srv.URL+"/source/"+webhook.ID)
|
||||
checkTargetType(ctx, t)
|
||||
checkEventToggle(
|
||||
ctx, t, srv.URL+"/source/"+webhook.ID+"/logs", event.ID,
|
||||
)
|
||||
|
||||
assert.Empty(
|
||||
t, consoleProblems(),
|
||||
"the pages printed console warnings, errors or exceptions",
|
||||
)
|
||||
}
|
||||
|
||||
// startBrowser starts headless Chromium for one test. It returns the
|
||||
// context that drives it, and a function listing every console warning
|
||||
// or error and every uncaught exception its pages raised: that is how
|
||||
// Alpine.js reports an expression it cannot run.
|
||||
//
|
||||
// The test is skipped when chromium is not on PATH. The Dockerfile's
|
||||
// test stage installs it, so the image build always runs this check.
|
||||
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
||||
t.Helper()
|
||||
|
||||
path, err := exec.LookPath("chromium")
|
||||
if err != nil {
|
||||
t.Skipf("chromium is not installed: %v", err)
|
||||
}
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
||||
t.Context(),
|
||||
append(
|
||||
chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.ExecPath(path),
|
||||
// The image build runs tests as root, where Chromium's
|
||||
// sandbox cannot start.
|
||||
chromedp.NoSandbox,
|
||||
)...,
|
||||
)
|
||||
t.Cleanup(cancelAlloc)
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
||||
t.Cleanup(cancelTimeout)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
problems []string
|
||||
)
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
var problem string
|
||||
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventConsoleAPICalled:
|
||||
if ev.Type != runtime.APITypeWarning &&
|
||||
ev.Type != runtime.APITypeError {
|
||||
return
|
||||
}
|
||||
|
||||
args := make([]string, 0, len(ev.Args))
|
||||
for _, arg := range ev.Args {
|
||||
args = append(args, string(arg.Value))
|
||||
}
|
||||
|
||||
problem = strings.Join(args, " ")
|
||||
case *runtime.EventExceptionThrown:
|
||||
problem = ev.ExceptionDetails.Error()
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
problems = append(problems, problem)
|
||||
})
|
||||
|
||||
return ctx, func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return slices.Clone(problems)
|
||||
}
|
||||
}
|
||||
|
||||
// setCookies gives the browser the cookies for the server at base.
|
||||
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
for _, c := range cookies {
|
||||
err := network.SetCookie(c.Name, c.Value).
|
||||
WithURL(base).
|
||||
Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loadPage opens url and waits for Alpine.js to start, which it does
|
||||
// by removing every x-cloak attribute. Until then x-cloak hides the
|
||||
// elements Alpine would hide, so a check made earlier proves nothing.
|
||||
func loadPage(url string) chromedp.Tasks {
|
||||
return chromedp.Tasks{
|
||||
chromedp.Navigate(url),
|
||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||
}
|
||||
}
|
||||
|
||||
// shown waits up to settleTimeout for the first element matching a CSS
|
||||
// selector to be rendered, and reports whether it was. The wait is
|
||||
// needed because Alpine.js shows an element on the next animation
|
||||
// frame, not at once.
|
||||
func shown(ctx context.Context, selector string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
||||
defer cancel()
|
||||
|
||||
return chromedp.Run(
|
||||
ctx, chromedp.WaitVisible(selector, chromedp.ByQuery),
|
||||
) == nil
|
||||
}
|
||||
|
||||
// hidden is shown's opposite: it waits for the element to be hidden.
|
||||
func hidden(ctx context.Context, selector string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
||||
defer cancel()
|
||||
|
||||
return chromedp.Run(
|
||||
ctx, chromedp.WaitNotVisible(selector, chromedp.ByQuery),
|
||||
) == nil
|
||||
}
|
||||
|
||||
// click clicks the first element matching an XPath expression.
|
||||
func click(ctx context.Context, t *testing.T, xpath string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
||||
))
|
||||
}
|
||||
|
||||
// checkAddForms loads a webhook page and checks that each section's add
|
||||
// form stays hidden until the Add button beside its heading is clicked.
|
||||
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||
|
||||
sections := []struct{ heading, form string }{
|
||||
{"Entrypoints", `form[action$="/entrypoints"]`},
|
||||
{"Targets", `form[action$="/targets"]`},
|
||||
}
|
||||
|
||||
for _, s := range sections {
|
||||
assert.Truef(
|
||||
t, hidden(ctx, s.form),
|
||||
"%s: the add form shows before Add is clicked", s.heading,
|
||||
)
|
||||
|
||||
click(ctx, t, `//h2[text()="`+s.heading+
|
||||
`"]/following-sibling::button`)
|
||||
|
||||
assert.Truef(
|
||||
t, shown(ctx, s.form),
|
||||
"%s: the add form stays hidden when Add is clicked", s.heading,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTargetType chooses Slack in the open add target form and checks
|
||||
// what the form would then submit: one url field, the Slack one, and
|
||||
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
||||
func checkTargetType(ctx context.Context, t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
chooseSlack = `(() => {
|
||||
const type = document.querySelector('select[name="type"]');
|
||||
type.value = "slack";
|
||||
type.dispatchEvent(new Event("change"));
|
||||
})()`
|
||||
submitted = `[...new FormData(
|
||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
||||
)
|
||||
|
||||
var fields []string
|
||||
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx,
|
||||
chromedp.Evaluate(chooseSlack, nil),
|
||||
chromedp.Evaluate(submitted, &fields),
|
||||
))
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
[]string{"csrf_token", "name", "type", "max_retries", "url"},
|
||||
fields,
|
||||
"with Slack chosen, the HTTP fields must not be submitted",
|
||||
)
|
||||
}
|
||||
|
||||
// checkEventToggle loads the event log and checks that clicking an
|
||||
// event's row expands it and clicking again collapses it.
|
||||
func checkEventToggle(
|
||||
ctx context.Context, t *testing.T, url, eventID string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// The row shows the event's ID; its Resubmit form is in the part
|
||||
// that expands.
|
||||
row := `//span[text()="` + eventID + `"]`
|
||||
expanded := `form[action$="/resubmit"]`
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||
|
||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||
|
||||
click(ctx, t, row)
|
||||
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
||||
|
||||
click(ctx, t, row)
|
||||
assert.True(t, hidden(ctx, expanded), "clicking it again does not collapse it")
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func (s *Server) setupUserRoutes() {
|
||||
}
|
||||
|
||||
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;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
@@ -194,7 +194,7 @@ func (s *Server) setupSourceRoutes() {
|
||||
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;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
@@ -205,14 +205,14 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Get("/edit", s.h.HandleSourceEdit())
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||
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
|
||||
// this is the only route that serves a whole one. It
|
||||
// belongs to this group for its RequireAuth and
|
||||
// NoCache; see HandleEventBodyDownload for the headers
|
||||
// that keep the bytes it returns inert.
|
||||
r.Get(
|
||||
"/events/{eventID}/body",
|
||||
"/logs/{eventID}/body",
|
||||
s.h.HandleEventBodyDownload(),
|
||||
)
|
||||
// Replay is the one page action that queues outbound work:
|
||||
@@ -279,7 +279,7 @@ func (s *Server) setupSourceRoutes() {
|
||||
|
||||
func (s *Server) setupWebhookRoutes() {
|
||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||
"/h/{uuid}",
|
||||
"/webhook/{uuid}",
|
||||
s.h.HandleWebhook(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -674,7 +674,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
||||
|
||||
require.NotNil(t, fresh, "login must set a session cookie")
|
||||
assert.Equal(
|
||||
t, "/hooks",
|
||||
t, "/sources",
|
||||
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
||||
"the new session cookie must authenticate",
|
||||
)
|
||||
@@ -741,7 +741,7 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- /hook/{sourceID} group ---
|
||||
// --- /source/{sourceID} group ---
|
||||
|
||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||
// feature the way a user does: render the event log page through
|
||||
@@ -769,11 +769,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
||||
wh := env.seedWebhook(t, userID)
|
||||
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)
|
||||
|
||||
link := regexp.MustCompile(
|
||||
`href="(/hook/[^"]+/body)"`,
|
||||
`href="(/source/[^"]+/body)"`,
|
||||
).FindStringSubmatch(page.Body.String())
|
||||
require.Len(
|
||||
t, link, 2,
|
||||
@@ -819,7 +819,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
||||
const payload = "OWNERS-PAYLOAD-77c1"
|
||||
|
||||
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")
|
||||
intruder := env.authCookies(t, intruderID, "intruder")
|
||||
@@ -853,7 +853,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
||||
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"
|
||||
|
||||
assert.Equal(
|
||||
@@ -879,7 +879,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
// The token and the action URL both come out of the rendered
|
||||
// page, so a typo in either the route pattern or the template
|
||||
// fails here.
|
||||
logsPath := "/hook/" + wh.ID + "/events"
|
||||
logsPath := "/source/" + wh.ID + "/logs"
|
||||
|
||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||
|
||||
@@ -887,7 +887,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
|
||||
action := regexp.MustCompile(
|
||||
`action="(/hook/[^"]+/replay)"`,
|
||||
`action="(/source/[^"]+/replay)"`,
|
||||
).FindStringSubmatch(page.Body.String())
|
||||
require.Len(
|
||||
t, action, 2,
|
||||
@@ -912,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
|
||||
// in the two /metrics credentials.
|
||||
func metricsConfig(
|
||||
|
||||
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
||||
//
|
||||
// URL is the third such field. NewRequest builds it as
|
||||
// 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
|
||||
// route, keeping the scheme and the host.
|
||||
//
|
||||
|
||||
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
|
||||
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
||||
)
|
||||
router.HandleFunc("/pages/login", handler)
|
||||
router.HandleFunc("/h/{uuid}", handler)
|
||||
router.HandleFunc("/webhook/{uuid}", handler)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
|
||||
// concrete path carries the entrypoint capability.
|
||||
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
||||
return sentryRequest(
|
||||
client, "/h/"+sentryReceiverUUID, "payload=hello",
|
||||
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||
)
|
||||
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) {
|
||||
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
|
||||
// what an event captured outside the router would carry.
|
||||
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||
|
||||
event := sentry.NewEvent()
|
||||
event.Request = &sentry.Request{URL: concrete}
|
||||
event.Transaction = "POST /h/" +
|
||||
event.Transaction = "POST /webhook/" +
|
||||
sentryReceiverUUID
|
||||
|
||||
scrubbed := server.ScrubSentryRequestForTest(
|
||||
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
|
||||
|
||||
event := sentry.NewEvent()
|
||||
event.Request = &sentry.Request{
|
||||
URL: "/h/" + sentryReceiverUUID,
|
||||
URL: "/webhook/" + sentryReceiverUUID,
|
||||
}
|
||||
event.Transaction = "/h/" + sentryReceiverUUID
|
||||
event.Transaction = "/webhook/" + sentryReceiverUUID
|
||||
|
||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
||||
require.NotNil(t, scrubbed)
|
||||
|
||||
+5
-4
@@ -1,15 +1,16 @@
|
||||
#!/bin/sh
|
||||
# script/assets: extract Alpine.js from its npm package tarball, committed
|
||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
|
||||
# extracted file is not committed. script/test, make build and make dev run
|
||||
# this first.
|
||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
|
||||
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
|
||||
# forbids eval. The extracted file is not committed. script/test, make
|
||||
# build and make dev run this first.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
||||
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
|
||||
>static/js/alpine.min.js
|
||||
}
|
||||
|
||||
|
||||
@@ -57,3 +57,62 @@
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
// Alpine.js components.
|
||||
//
|
||||
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
||||
// Alpine's CSP build, which cannot run expressions written in the
|
||||
// markup: a directive in templates/ may only name a property or method,
|
||||
// and each x-data names a component registered here. This script runs
|
||||
// before Alpine, whose script tag is deferred, so this listener is in
|
||||
// place when Alpine starts.
|
||||
document.addEventListener("alpine:init", function () {
|
||||
"use strict";
|
||||
|
||||
// Something a click shows and hides: the mobile menu, an add form,
|
||||
// an event in the event log, a delivery's attempts.
|
||||
window.Alpine.data("collapsible", function () {
|
||||
return {
|
||||
open: false,
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
},
|
||||
get closed() {
|
||||
return !this.open;
|
||||
},
|
||||
// Turns a downward caret up while open.
|
||||
get caretClass() {
|
||||
return { "rotate-180": this.open };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// The add target form. Only the chosen type's fields show, and the
|
||||
// others are disabled so that the form does not submit them.
|
||||
window.Alpine.data("targetForm", function () {
|
||||
return {
|
||||
targetType: "http",
|
||||
chooseType(event) {
|
||||
this.targetType = event.target.value;
|
||||
},
|
||||
get isHttp() {
|
||||
return this.targetType === "http";
|
||||
},
|
||||
get isSlack() {
|
||||
return this.targetType === "slack";
|
||||
},
|
||||
get isDatabase() {
|
||||
return this.targetType === "database";
|
||||
},
|
||||
get notHttp() {
|
||||
return !this.isHttp;
|
||||
},
|
||||
get notSlack() {
|
||||
return !this.isSlack;
|
||||
},
|
||||
get notDatabase() {
|
||||
return !this.isDatabase;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{{define "navbar"}}
|
||||
<nav class="app-bar" x-data="{ open: false }">
|
||||
<nav class="app-bar" x-data="collapsible">
|
||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||
<button @click="toggle" 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">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="closed" 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"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Desktop navigation -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
{{if .User}}
|
||||
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||
<a href="/sources" class="btn-text">Webhooks</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||
<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"/>
|
||||
@@ -38,7 +38,7 @@
|
||||
<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">
|
||||
{{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="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||
<form method="POST" action="/pages/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{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">
|
||||
<div class="mb-6">
|
||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||
@@ -14,9 +14,9 @@
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<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}}">
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
</form>
|
||||
@@ -26,10 +26,10 @@
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Entrypoints -->
|
||||
<div class="card">
|
||||
<div class="card" x-data="collapsible">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
||||
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
||||
<button @click="toggle" class="btn-text text-sm">
|
||||
<svg class="w-4 h-4 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"/>
|
||||
</svg>
|
||||
@@ -38,8 +38,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Add entrypoint form -->
|
||||
<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">
|
||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||
@@ -57,20 +57,20 @@
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{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}}">
|
||||
<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}}
|
||||
</button>
|
||||
</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}}">
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
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>
|
||||
@@ -85,10 +85,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Targets -->
|
||||
<div class="card">
|
||||
<div class="card" x-data="collapsible">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
||||
<button @click="toggle" class="btn-text text-sm">
|
||||
<svg class="w-4 h-4 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"/>
|
||||
</svg>
|
||||
@@ -97,42 +97,42 @@
|
||||
</div>
|
||||
|
||||
<!-- Add target form -->
|
||||
<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">
|
||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="targetForm" class="space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||
<select name="type" x-model="targetType" class="input text-sm w-32">
|
||||
<select name="type" @change="chooseType" class="input text-sm w-32">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="database">Database</option>
|
||||
<option value="log">Log</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||
<div x-show="isHttp">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
||||
<div x-show="isHttp">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<div x-show="isHttp">
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Max retries:</label>
|
||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'slack'">
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||
<div x-show="isSlack">
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'database'">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
||||
<div x-show="isDatabase">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||
@@ -151,14 +151,14 @@
|
||||
{{else}}
|
||||
<span class="badge-error">Inactive</span>
|
||||
{{end}}
|
||||
<a href="/hook/{{$.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">
|
||||
<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="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||
<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}}">
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||
<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>
|
||||
</form>
|
||||
@@ -182,7 +182,7 @@
|
||||
<div class="card mt-6">
|
||||
<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>
|
||||
<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 class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{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}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+15
-15
@@ -1,13 +1,13 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,8 +23,8 @@
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4" x-data="{ open: false }">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||
<div class="p-4" x-data="collapsible">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||
@@ -43,7 +43,7 @@
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
{{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}}
|
||||
</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="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>
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||
{{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 — <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 — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Deliveries}}
|
||||
@@ -71,28 +71,28 @@
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
||||
<div class="mt-2 divide-y divide-gray-200">
|
||||
{{range .Deliveries}}
|
||||
<div class="py-2" x-data="{ attempts: false }">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
||||
<div class="py-2" x-data="collapsible">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{{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="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>
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
|
||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
||||
{{if .AttemptsOmitted}}
|
||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||
{{end}}
|
||||
@@ -139,11 +139,11 @@
|
||||
{{if or .HasPrev .HasNext}}
|
||||
<div class="flex justify-center gap-2 mt-6">
|
||||
{{if .HasPrev}}
|
||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||
{{end}}
|
||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
@@ -17,7 +17,7 @@
|
||||
{{if .Webhooks}}
|
||||
<div class="grid gap-4">
|
||||
{{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>
|
||||
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||
@@ -42,7 +42,7 @@
|
||||
</svg>
|
||||
<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>
|
||||
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
|
||||
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="alert-error">{{.Error}}</div>
|
||||
{{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}}">
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">Name</label>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{define "content"}}
|
||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||
<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>
|
||||
</div>
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
{{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}}">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user