Move webhook pages to /hook/ID and inbound URLs to /h/UUID (closes #367)
check / check (push) Successful in 3m41s
check / check (push) Successful in 3m41s
The webhook page and everything under it move from /source/ID to /hook/ID, the list and new-webhook form to /hooks and /hooks/new, and the event log from .../logs to /hook/ID/events, body download included. Entrypoint URLs move from /webhook/UUID to /h/UUID, and the webhook page shows only that form. The old paths are gone. Links, redirects, form actions, tests, comments and the README follow. Both links to the event log page, and its title and heading, now read "Full Event Log". Go identifiers and template file names are unchanged. Route tests follow every link and submit every form the templates render to a moved page, through the production router, with the link, form action and token taken from the rendered page. Model: opus-5-5
This commit is contained in:
@@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets
|
|||||||
with retry support, logging, and observability. Category: infrastructure
|
with retry support, logging, and observability. Category: infrastructure
|
||||||
/ web service. License: MIT.
|
/ web service. License: MIT.
|
||||||
|
|
||||||
Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and
|
Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and
|
||||||
that UUID is the entrypoint's only credential. webhooker does not use
|
that UUID is the entrypoint's only credential. webhooker does not use
|
||||||
shared secrets, HMAC signatures or token headers on the receiver, and
|
shared secrets, HMAC signatures or token headers on the receiver, and
|
||||||
will not add them — read
|
will not add them — read
|
||||||
@@ -1188,7 +1188,7 @@ backups at rest and restrict who can read them.
|
|||||||
|
|
||||||
**The entrypoint UUID is the credential, and it is the only one.**
|
**The entrypoint UUID is the credential, and it is the only one.**
|
||||||
webhooker mints a version 4 UUID per entrypoint and serves it at
|
webhooker mints a version 4 UUID per entrypoint and serves it at
|
||||||
`/webhook/{uuid}`. Possession of that URL is the authentication:
|
`/h/{uuid}`. Possession of that URL is the authentication:
|
||||||
anyone who holds it can submit events to the entrypoint, and the
|
anyone who holds it can submit events to the entrypoint, and the
|
||||||
receiver verifies nothing else about the sender.
|
receiver verifies nothing else about the sender.
|
||||||
|
|
||||||
@@ -1523,7 +1523,7 @@ the full request and creates an Event.
|
|||||||
| -------------- | ------- | ----------- |
|
| -------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `path` | string | Unique bare UUID, generated at creation. The `/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) |
|
| `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) |
|
||||||
| `description` | string | Optional description |
|
| `description` | string | Optional description |
|
||||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||||
|
|
||||||
@@ -1905,7 +1905,7 @@ runtime, though CGO is required at build time due to the transitive
|
|||||||
```
|
```
|
||||||
External Service
|
External Service
|
||||||
│
|
│
|
||||||
│ POST /webhook/{uuid}
|
│ POST /h/{uuid}
|
||||||
▼
|
▼
|
||||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ chi Router │────►│ Middleware │────►│ Webhook │
|
│ chi Router │────►│ Middleware │────►│ Webhook │
|
||||||
@@ -2131,7 +2131,7 @@ The middleware records three more on the same registry:
|
|||||||
Two of those labels are written once per request from bytes the client
|
Two of those labels are written once per request from bytes the client
|
||||||
chose, so both are bounded to something this service registers:
|
chose, so both are bounded to something this service registers:
|
||||||
|
|
||||||
- `handler` is the chi route pattern — `/webhook/{uuid}`, never the
|
- `handler` is the chi route pattern — `/h/{uuid}`, never the
|
||||||
concrete path. A request matching no route carries `(unmatched)`,
|
concrete path. A request matching no route carries `(unmatched)`,
|
||||||
and no entrypoint UUID ever reaches a label.
|
and no entrypoint UUID ever reaches a label.
|
||||||
- `method` is the request method when the router can route it, and
|
- `method` is the request method when the router can route it, and
|
||||||
@@ -2161,7 +2161,7 @@ unpredictable rates, and blanket limits shared with other routes would
|
|||||||
cause legitimate deliveries to be dropped.
|
cause legitimate deliveries to be dropped.
|
||||||
|
|
||||||
The receiver instead has its own dedicated abuse limit, scoped to the
|
The receiver instead has its own dedicated abuse limit, scoped to the
|
||||||
`/webhook/{uuid}` route only and keyed per client IP per request path
|
`/h/{uuid}` route only and keyed per client IP per request path
|
||||||
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
||||||
affecting other senders of the same entrypoint or the same sender's
|
affecting other senders of the same entrypoint or the same sender's
|
||||||
other entrypoints. Keying on the path rather than on the entrypoint
|
other entrypoints. Keying on the path rather than on the entrypoint
|
||||||
@@ -2198,7 +2198,7 @@ log spends. The access log is bounded by neither limit: every request
|
|||||||
is recorded once at `INFO`, served or rejected alike.
|
is recorded once at `INFO`, served or rejected alike.
|
||||||
|
|
||||||
What the access log does bound is the _content_ of those lines. A 3xx
|
What the access log does bound is the _content_ of those lines. A 3xx
|
||||||
or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
|
or 4xx response logs the chi route pattern — `/h/{uuid}`,
|
||||||
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
||||||
no route at all — in place of the concrete URL. Those are the outcomes
|
no route at all — in place of the concrete URL. Those are the outcomes
|
||||||
an unauthenticated client can drive for free: 404 and 429 on any
|
an unauthenticated client can drive for free: 404 and 429 on any
|
||||||
@@ -2232,12 +2232,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
|||||||
|
|
||||||
The same hook rewrites the request URL. The SDK builds it as
|
The same hook rewrites the request URL. The SDK builds it as
|
||||||
`scheme://host/path` from the concrete path, which on the receiver
|
`scheme://host/path` from the concrete path, which on the receiver
|
||||||
route is `/webhook/<uuid>` in full — and that UUID is a write
|
route is `/h/<uuid>` in full — and that UUID is a write
|
||||||
capability, not an identifier: anyone holding it can post events this
|
capability, not an identifier: anyone holding it can post events this
|
||||||
service accepts and its targets then deliver. A tracker has its own
|
service accepts and its targets then deliver. A tracker has its own
|
||||||
retention, access control and deletion policy, so the rule the access
|
retention, access control and deletion policy, so the rule the access
|
||||||
log follows above does not carry across that boundary. What is sent is
|
log follows above does not carry across that boundary. What is sent is
|
||||||
the chi route pattern instead: `http://host/webhook/{uuid}`.
|
the chi route pattern instead: `http://host/h/{uuid}`.
|
||||||
|
|
||||||
The scheme and the host are kept, and everything else in the URL is
|
The scheme and the host are kept, and everything else in the URL is
|
||||||
discarded rather than edited, so a future SDK version that starts
|
discarded rather than edited, so a future SDK version that starts
|
||||||
@@ -2281,8 +2281,8 @@ fallback is never the concrete path. The path becomes the literal
|
|||||||
rewrite cannot parse into a scheme is withheld whole. A transaction
|
rewrite cannot parse into a scheme is withheld whole. A transaction
|
||||||
event additionally carries the SDK's own `METHOD /path` name, built
|
event additionally carries the SDK's own `METHOD /path` name, built
|
||||||
from the concrete path as well; it is rewritten on the same terms, to
|
from the concrete path as well; it is rewritten on the same terms, to
|
||||||
`POST /webhook/{uuid}` where the pattern is known and `POST
|
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)`
|
||||||
/(redacted)` where it is not.
|
where it is not.
|
||||||
|
|
||||||
The headers are an allowlist for the same reason the rules above are
|
The headers are an allowlist for the same reason the rules above are
|
||||||
unconditional: the SDK's own filter removes four names and passes
|
unconditional: the SDK's own filter removes four names and passes
|
||||||
@@ -2417,7 +2417,7 @@ logger printed the fully interpolated SQL — parameters and all — to
|
|||||||
standard output on every statement that returned an error, including a
|
standard output on every statement that returned an error, including a
|
||||||
plain record-not-found, at a level no operator setting reached. Two of
|
plain record-not-found, at a level no operator setting reached. Two of
|
||||||
this service's lookups miss by design on unauthenticated routes: the
|
this service's lookups miss by design on unauthenticated routes: the
|
||||||
entrypoint lookup behind `/webhook/{uuid}` and the user lookup behind
|
entrypoint lookup behind `/h/{uuid}` and the user lookup behind
|
||||||
the login form, whose path segment and submitted username the client
|
the login form, whose path segment and submitted username the client
|
||||||
picks outright. Every
|
picks outright. Every
|
||||||
`gorm.Open` in the service now installs the adapter in
|
`gorm.Open` in the service now installs the adapter in
|
||||||
@@ -2694,10 +2694,10 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------------------- | ----------- |
|
| ------ | --------------------------- | ----------- |
|
||||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
|
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||||
| `POST` | `/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)) |
|
| `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)) |
|
||||||
|
|
||||||
#### Authentication Endpoints
|
#### Authentication Endpoints
|
||||||
|
|
||||||
@@ -2713,25 +2713,25 @@ abuse limit later; they are tracked as future work.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/sources` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/sources/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/sources/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
| `GET` | `/source/{id}` | Webhook detail view |
|
| `GET` | `/hook/{id}` | Webhook detail view |
|
||||||
| `GET` | `/source/{id}/edit` | Edit webhook form |
|
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||||
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/source/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||||
| `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 |
|
| `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` | `/source/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/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` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||||
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
| `POST` | `/hook/{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 |
|
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
|
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||||
|
|
||||||
#### Infrastructure Endpoints
|
#### Infrastructure Endpoints
|
||||||
|
|
||||||
@@ -2932,8 +2932,8 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -2957,7 +2957,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
|
|||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
`/user/{username}/password` and **ReceiverRateLimit** on
|
`/user/{username}/password` and **ReceiverRateLimit** on
|
||||||
`/webhook/{uuid}`. There is deliberately none on `/pages/login` — that
|
`/h/{uuid}`. There is deliberately none on `/pages/login` — that
|
||||||
endpoint counts failures inside the handler, after the credential
|
endpoint counts failures inside the handler, after the credential
|
||||||
check, see [The login endpoint](#the-login-endpoint).
|
check, see [The login endpoint](#the-login-endpoint).
|
||||||
|
|
||||||
@@ -2998,8 +2998,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
by middleware that runs before CSRF parses the form
|
by middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
on all state-changing forms (cookie-based double-submit tokens with
|
on all state-changing forms (cookie-based double-submit tokens with
|
||||||
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||||
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
|
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||||
`/api` (stateless API). The middleware detects TLS per-request through
|
`/api` (stateless API). The middleware detects TLS per-request through
|
||||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||||
to set appropriate cookie security flags and Origin/Referer validation
|
to set appropriate cookie security flags and Origin/Referer validation
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// SQL — parameters and all — for every statement that returns an
|
// SQL — parameters and all — for every statement that returns an
|
||||||
// error, including gorm.ErrRecordNotFound. Two of this service's
|
// error, including gorm.ErrRecordNotFound. Two of this service's
|
||||||
// lookups miss by design on unauthenticated routes: the entrypoint
|
// lookups miss by design on unauthenticated routes: the entrypoint
|
||||||
// lookup on /webhook/{uuid}, whose path segment the client picks
|
// lookup on /h/{uuid}, whose path segment the client picks
|
||||||
// outright, and the user lookup behind the login form, whose username
|
// outright, and the user lookup behind the login form, whose username
|
||||||
// the client picks outright. Under the default logger each of those
|
// the client picks outright. Under the default logger each of those
|
||||||
// misses printed an unbounded, attacker-chosen string, at no level the
|
// misses printed an unbounded, attacker-chosen string, at no level the
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func (h *Handlers) finishReplay(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code replayOutcomeCode,
|
code replayOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/source/" + webhook.ID + "/logs?" +
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
replayOutcomeParam + "=" + string(code)
|
replayOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func postReplay(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/deliveries/"+
|
"/hook/"+webhookID+"/deliveries/"+
|
||||||
deliveryID+"/replay",
|
deliveryID+"/replay",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=target-deleted",
|
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=target-missing",
|
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||||
missing.Header().Get("Location"),
|
missing.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||||
require.Equal(
|
require.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
first.Header().Get("Location"),
|
first.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=in-flight",
|
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||||
second.Header().Get("Location"),
|
second.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=not-terminal",
|
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`action="/source/`+wh.ID+`/deliveries/`+
|
`action="/hook/`+wh.ID+`/deliveries/`+
|
||||||
original.ID+`/replay"`,
|
original.ID+`/replay"`,
|
||||||
)
|
)
|
||||||
assert.Contains(t, body, `method="POST"`)
|
assert.Contains(t, body, `method="POST"`)
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ func fetchEventBody(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+url.PathEscape(sourceID)+
|
"/hook/"+url.PathEscape(sourceID)+
|
||||||
"/logs/"+url.PathEscape(eventID)+"/body",
|
"/events/"+url.PathEscape(eventID)+"/body",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
|||||||
page := renderSourceLogsPage(t, h, sess, big.ID)
|
page := renderSourceLogsPage(t, h, sess, big.ID)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, page,
|
t, page,
|
||||||
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
|
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
|
||||||
)
|
)
|
||||||
|
|
||||||
small := seedWebhook(t, db)
|
small := seedWebhook(t, db)
|
||||||
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
|||||||
page = renderSourceLogsPage(t, h, sess, small.ID)
|
page = renderSourceLogsPage(t, h, sess, small.ID)
|
||||||
assert.NotContains(
|
assert.NotContains(
|
||||||
t, page,
|
t, page,
|
||||||
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
|
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code resubmitOutcomeCode,
|
code resubmitOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/source/" + webhook.ID + "/logs?" +
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
resubmitOutcomeParam + "=" + string(code)
|
resubmitOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func postResubmit(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
),
|
),
|
||||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"a resubmit must not be refused while an earlier "+
|
"a resubmit must not be refused while an earlier "+
|
||||||
"one is in flight",
|
"one is in flight",
|
||||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"an inactive target is skipped, not an error",
|
"an inactive target is skipped, not an error",
|
||||||
)
|
)
|
||||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
||||||
"the log must offer the resubmit action per event",
|
"the log must offer the resubmit action per event",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ func postWebhook(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(), http.MethodPost, "/webhook/x",
|
context.Background(), http.MethodPost, "/h/x",
|
||||||
strings.NewReader("{}"),
|
strings.NewReader("{}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "/sources", w2.Header().Get("Location"),
|
t, "/hooks", w2.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleIndex returns a handler for the root path that redirects
|
// HandleIndex returns a handler for the root path that redirects
|
||||||
// based on authentication state: authenticated users go to /sources
|
// based on authentication state: authenticated users go to /hooks
|
||||||
// (the dashboard), unauthenticated users go to the login page.
|
// (the dashboard), unauthenticated users go to the login page.
|
||||||
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
sess, err := s.session.Get(r)
|
sess, err := s.session.Get(r)
|
||||||
if err == nil && s.session.IsAuthenticated(sess) {
|
if err == nil && s.session.IsAuthenticated(sess) {
|
||||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package handlers_test
|
|||||||
// this package reach a value an UNAUTHENTICATED client picks outright
|
// this package reach a value an UNAUTHENTICATED client picks outright
|
||||||
// and of a length it picks outright:
|
// and of a length it picks outright:
|
||||||
//
|
//
|
||||||
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
|
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
|
||||||
// path segment matched no stored entrypoint and so is bounded by
|
// path segment matched no stored entrypoint and so is bounded by
|
||||||
// nothing;
|
// nothing;
|
||||||
// - the failed-login DEBUG lines, whose username is a form field.
|
// - the failed-login DEBUG lines, whose username is a form field.
|
||||||
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
|
|||||||
// route pattern.
|
// route pattern.
|
||||||
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post("/webhook/{uuid}", h.HandleWebhook())
|
router.Post("/h/{uuid}", h.HandleWebhook())
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReceiver sends one POST at /webhook/<segment>.
|
// postReceiver sends one POST at /h/<segment>.
|
||||||
//
|
//
|
||||||
// RawPath is cleared after parsing so chi routes on the decoded path
|
// RawPath is cleared after parsing so chi routes on the decoded path
|
||||||
// and the handler sees the raw bytes rather than their percent-escaped
|
// and the handler sees the raw bytes rather than their percent-escaped
|
||||||
@@ -210,7 +210,7 @@ func postReceiver(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/webhook/"+url.PathEscape(segment),
|
"/h/"+url.PathEscape(segment),
|
||||||
strings.NewReader(""),
|
strings.NewReader(""),
|
||||||
)
|
)
|
||||||
req.URL.RawPath = ""
|
req.URL.RawPath = ""
|
||||||
@@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
|
|||||||
http.StatusServiceUnavailable,
|
http.StatusServiceUnavailable,
|
||||||
postLoginAtPath(
|
postLoginAtPath(
|
||||||
t, h,
|
t, h,
|
||||||
"/source/"+url.PathEscape(
|
"/hook/"+url.PathEscape(
|
||||||
oversizedFill(fill),
|
oversizedFill(fill),
|
||||||
)+"/login",
|
)+"/login",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
|||||||
body := strings.Repeat("é", 1024)
|
body := strings.Repeat("é", 1024)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(), http.MethodPost, "/webhook/x",
|
context.Background(), http.MethodPost, "/h/x",
|
||||||
strings.NewReader(body),
|
strings.NewReader(body),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|||||||
)
|
)
|
||||||
assert.Empty(
|
assert.Empty(
|
||||||
t, w.Header().Get("Location"),
|
t, w.Header().Get("Location"),
|
||||||
"a failed deletion must not redirect to /sources",
|
"a failed deletion must not redirect to /hooks",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/sources", w.Header().Get("Location"))
|
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+f.webhook,
|
"/hook/"+f.webhook,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
req.Host = host
|
req.Host = host
|
||||||
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
tc.scheme+"://"+host+"/webhook/"+fixture.path,
|
tc.scheme+"://"+host+"/h/"+fixture.path,
|
||||||
fixture.entrypointURL(
|
fixture.entrypointURL(
|
||||||
t, host, forwardedProto(tc.header),
|
t, host, forwardedProto(tc.header),
|
||||||
),
|
),
|
||||||
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://"+host+"/webhook/"+fixture.path,
|
"https://"+host+"/h/"+fixture.path,
|
||||||
got,
|
got,
|
||||||
"a connection this process terminated with TLS "+
|
"a connection this process terminated with TLS "+
|
||||||
"outranks a header claiming plaintext",
|
"outranks a header claiming plaintext",
|
||||||
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://"+host+"/webhook/"+fixture.path,
|
"https://"+host+"/h/"+fixture.path,
|
||||||
fixture.entrypointURL(
|
fixture.entrypointURL(
|
||||||
t, host, forwardedProto("HTTPS"),
|
t, host, forwardedProto("HTTPS"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func serveSourceDetailPage(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+webhookID,
|
"/hook/"+webhookID,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/targets/"+targetID+"/delete",
|
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+webhookID+"/logs"+query,
|
"/hook/"+webhookID+"/events"+query,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +671,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||||
@@ -1264,7 +1264,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1320,7 +1320,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
//
|
//
|
||||||
// Every field here is read with PostFormValue, not FormValue.
|
// Every field here is read with PostFormValue, not FormValue.
|
||||||
// FormValue falls back to the query string, which would let
|
// FormValue falls back to the query string, which would let
|
||||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
// configure a target from a value the request line carries — and
|
// configure a target from a value the request line carries — and
|
||||||
// the request line, unlike the body, is what logs, proxies,
|
// the request line, unlike the body, is what logs, proxies,
|
||||||
// Referer headers and error trackers record.
|
// Referer headers and error trackers record.
|
||||||
@@ -1377,7 +1377,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1435,7 +1435,7 @@ type targetFormInput struct {
|
|||||||
//
|
//
|
||||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||||
// falls back to the query string, which would let
|
// falls back to the query string, which would let
|
||||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
// configure a target from a value the request line carries — and the
|
// configure a target from a value the request line carries — and the
|
||||||
// request line, unlike the body, is what logs, proxies, Referer
|
// request line, unlike the body, is what logs, proxies, Referer
|
||||||
// headers and error trackers record. The headers field is under the
|
// headers and error trackers record. The headers field is under the
|
||||||
@@ -1714,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/source/"+webhook.ID,
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1811,7 +1811,7 @@ func (h *Handlers) toggleChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/source/"+webhook.ID,
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func submitCreate(
|
|||||||
form.Set("retention_days", *retention)
|
form.Set("retention_days", *retention)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := formRequest("/sources/new", cookies, form, nil)
|
req := formRequest("/hooks/new", cookies, form, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||||
@@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.handlers.HandleSourceCreate().ServeHTTP(
|
env.handlers.HandleSourceCreate().ServeHTTP(
|
||||||
w, getRequest(t, "/sources/new", env.cookies, nil),
|
w, getRequest(t, "/hooks/new", env.cookies, nil),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
@@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
|||||||
form.Set("description", description)
|
form.Set("description", description)
|
||||||
form.Set("retention_days", "nonsense")
|
form.Set("retention_days", "nonsense")
|
||||||
|
|
||||||
req := formRequest("/sources/new", env.cookies, form, nil)
|
req := formRequest("/hooks/new", env.cookies, form, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||||
@@ -430,7 +430,7 @@ func submitEdit(
|
|||||||
form.Set("retention_days", retention)
|
form.Set("retention_days", retention)
|
||||||
|
|
||||||
req := formRequest(
|
req := formRequest(
|
||||||
"/source/"+wh.ID+"/edit",
|
"/hook/"+wh.ID+"/edit",
|
||||||
env.cookies,
|
env.cookies,
|
||||||
form,
|
form,
|
||||||
map[string]string{sourceIDParam: wh.ID},
|
map[string]string{sourceIDParam: wh.ID},
|
||||||
@@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := getRequest(
|
req := getRequest(
|
||||||
t, "/source/"+wh.ID+"/edit", env.cookies,
|
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
||||||
map[string]string{sourceIDParam: wh.ID},
|
map[string]string{sourceIDParam: wh.ID},
|
||||||
)
|
)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
|||||||
|
|
||||||
listW := httptest.NewRecorder()
|
listW := httptest.NewRecorder()
|
||||||
env.handlers.HandleSourceList().ServeHTTP(
|
env.handlers.HandleSourceList().ServeHTTP(
|
||||||
listW, getRequest(t, "/sources", env.cookies, nil),
|
listW, getRequest(t, "/hooks", env.cookies, nil),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, listW.Code)
|
require.Equal(t, http.StatusOK, listW.Code)
|
||||||
@@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
|||||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||||
detailW,
|
detailW,
|
||||||
getRequest(
|
getRequest(
|
||||||
t, "/source/"+wh.ID, env.cookies,
|
t, "/hook/"+wh.ID, env.cookies,
|
||||||
map[string]string{sourceIDParam: wh.ID},
|
map[string]string{sourceIDParam: wh.ID},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ func postTargetCreate(
|
|||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Use(mw.Logging())
|
router.Use(mw.Logging())
|
||||||
router.Post(
|
router.Post(
|
||||||
"/source/{sourceID}/targets",
|
"/hook/{sourceID}/targets",
|
||||||
env.handlers.HandleTargetCreate(),
|
env.handlers.HandleTargetCreate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
target := "/source/" + webhookID + "/targets"
|
target := "/hook/" + webhookID + "/targets"
|
||||||
if query != "" {
|
if query != "" {
|
||||||
target += "?" + query
|
target += "?" + query
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func postTargetCreate(
|
|||||||
// regression test for the ingress leak. r.FormValue falls back to the
|
// regression test for the ingress leak. r.FormValue falls back to the
|
||||||
// query string when a field is absent from the POST body, so
|
// query string when a field is absent from the POST body, so
|
||||||
//
|
//
|
||||||
// POST /source/{id}/targets?url=https://hooks.slack.com/services/...
|
// POST /hook/{id}/targets?url=https://hooks.slack.com/services/...
|
||||||
//
|
//
|
||||||
// with an empty url field used to create a working target from a value
|
// with an empty url field used to create a working target from a value
|
||||||
// carried on the request line — where logs, proxies, Referer headers
|
// carried on the request line — where logs, proxies, Referer headers
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type targetEditView struct {
|
|||||||
//
|
//
|
||||||
// This page is the one place the full destination URL and header
|
// This page is the one place the full destination URL and header
|
||||||
// values are shown. It is reachable only through the
|
// values are shown. It is reachable only through the
|
||||||
// /source/{sourceID} route group, which supplies RequireAuth and
|
// /hook/{sourceID} route group, which supplies RequireAuth and
|
||||||
// NoCache, and only for a target of a webhook the session's user
|
// NoCache, and only for a target of a webhook the session's user
|
||||||
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
||||||
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||||
@@ -163,7 +163,7 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ const (
|
|||||||
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post(
|
router.Post(
|
||||||
"/source/{sourceID}/targets",
|
"/hook/{sourceID}/targets",
|
||||||
env.handlers.HandleTargetCreate(),
|
env.handlers.HandleTargetCreate(),
|
||||||
)
|
)
|
||||||
router.Get(
|
router.Get(
|
||||||
"/source/{sourceID}/targets/{targetID}/edit",
|
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||||
env.handlers.HandleTargetEdit(),
|
env.handlers.HandleTargetEdit(),
|
||||||
)
|
)
|
||||||
router.Post(
|
router.Post(
|
||||||
"/source/{sourceID}/targets/{targetID}/edit",
|
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||||
env.handlers.HandleTargetEditSubmit(),
|
env.handlers.HandleTargetEditSubmit(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func seedHTTPTarget(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhook.ID+"/targets", form,
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ func submitTargetEdit(
|
|||||||
) *httptest.ResponseRecorder {
|
) *httptest.ResponseRecorder {
|
||||||
return serveTarget(
|
return serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhookID+"/targets/"+targetID+"/edit",
|
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
|
||||||
form,
|
form,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhook.ID+"/targets/"+target.ID+
|
"/hook/"+webhook.ID+"/targets/"+target.ID+
|
||||||
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
||||||
"&headers="+url.QueryEscape(editAuthHeader),
|
"&headers="+url.QueryEscape(editAuthHeader),
|
||||||
form,
|
form,
|
||||||
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
|
|||||||
|
|
||||||
get := serveTarget(
|
get := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusNotFound, get.Code)
|
assert.Equal(t, http.StatusNotFound, get.Code)
|
||||||
|
|
||||||
@@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func createWithRetries(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhook.ID+"/targets",
|
"/hook/"+webhook.ID+"/targets",
|
||||||
createRetriesForm(retries),
|
createRetriesForm(retries),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ func renderPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
||||||
// label to "Webhooks". The /sources route is deliberately unchanged, so
|
// label to "Webhooks" and its link to the webhook list at /hooks.
|
||||||
// the assertion targets the link text rather than the href.
|
|
||||||
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -95,15 +94,11 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
|||||||
t, body, ">Sources<",
|
t, body, ">Sources<",
|
||||||
"no user-visible element may still be labelled Sources",
|
"no user-visible element may still be labelled Sources",
|
||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(t, body, `href="/hooks"`)
|
||||||
t, body, `href="/sources"`,
|
|
||||||
"the /sources route itself must not change",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
||||||
// its back link. The link's href still points at /source/{id}, which is
|
// its back link to the webhook page at /hook/{id}.
|
||||||
// intentional: only user-visible copy changes.
|
|
||||||
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -130,7 +125,57 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, body, "Edit Webhook")
|
assert.Contains(t, body, "Edit Webhook")
|
||||||
assert.NotContains(t, body, ">Sources<")
|
assert.NotContains(t, body, ">Sources<")
|
||||||
assert.Contains(t, body, `href="/source/wh-1"`)
|
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>`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
||||||
@@ -283,7 +328,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
t, body,
|
t, body,
|
||||||
`<code id="entrypoint-url-ep-1"`,
|
`<code id="entrypoint-url-ep-1"`,
|
||||||
)
|
)
|
||||||
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
|
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
|
|||||||
"path = ?", entrypointUUID,
|
"path = ?", entrypointUUID,
|
||||||
).First(&entrypoint)
|
).First(&entrypoint)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
// The receiver is unauthenticated and /webhook/{uuid}
|
// The receiver is unauthenticated and /h/{uuid}
|
||||||
// matches any single segment, so this value is entirely
|
// matches any single segment, so this value is entirely
|
||||||
// client-chosen on exactly the branch where the lookup
|
// client-chosen on exactly the branch where the lookup
|
||||||
// failed. DEBUG is off by default; the cap is what keeps
|
// failed. DEBUG is off by default; the cap is what keeps
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, s := range []string{
|
for _, s := range []string{
|
||||||
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
|
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)",
|
||||||
} {
|
} {
|
||||||
assert.Equal(t, s, logfield.Truncate(s, budget))
|
assert.Equal(t, s, logfield.Truncate(s, budget))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
|||||||
)
|
)
|
||||||
|
|
||||||
router.HandleFunc(
|
router.HandleFunc(
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Stands in for the real handler: an unknown entrypoint
|
// Stands in for the real handler: an unknown entrypoint
|
||||||
// UUID 404s, a known one succeeds.
|
// UUID 404s, a known one succeeds.
|
||||||
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
|||||||
assertFloodIsBounded(
|
assertFloodIsBounded(
|
||||||
t,
|
t,
|
||||||
func(i int) string {
|
func(i int) string {
|
||||||
return "/webhook/" + attackerMarker +
|
return "/h/" + attackerMarker +
|
||||||
strings.Repeat("x", i) + "?q=" + attackerMarker
|
strings.Repeat("x", i) + "?q=" + attackerMarker
|
||||||
},
|
},
|
||||||
http.StatusNotFound,
|
http.StatusNotFound,
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,10 +346,10 @@ type sizeCase struct {
|
|||||||
func lineSizeCases() map[string]sizeCase {
|
func lineSizeCases() map[string]sizeCase {
|
||||||
cases := map[string]sizeCase{
|
cases := map[string]sizeCase{
|
||||||
"oversized path segment": {
|
"oversized path segment": {
|
||||||
target: "/webhook/" + attackerMarker +
|
target: "/h/" + attackerMarker +
|
||||||
strings.Repeat("x", oversizedSegmentBytes),
|
strings.Repeat("x", oversizedSegmentBytes),
|
||||||
wantStatus: http.StatusNotFound,
|
wantStatus: http.StatusNotFound,
|
||||||
wantURL: "/webhook/{uuid}",
|
wantURL: "/h/{uuid}",
|
||||||
bound: maxLineBytes,
|
bound: maxLineBytes,
|
||||||
},
|
},
|
||||||
// /.well-known/healthcheck answers 200 to anyone and has no
|
// /.well-known/healthcheck answers 200 to anyone and has no
|
||||||
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
|||||||
router := accessLogRouter(m)
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
|
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// The path resolved against a stored entrypoint, so it stays. The
|
// The path resolved against a stored entrypoint, so it stays. The
|
||||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
|
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
|
||||||
assert.NotContains(t, buf.String(), "src=ci")
|
assert.NotContains(t, buf.String(), "src=ci")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
http.StatusNotFound,
|
http.StatusNotFound,
|
||||||
get(t, router, "/webhook/"+attackerMarker),
|
get(t, router, "/h/"+attackerMarker),
|
||||||
)
|
)
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
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
|
// CSRF is registered ahead of RequireAuth on every route
|
||||||
// group that uses it, so this WARN is reachable by an
|
// group that uses it, so this WARN is reachable by an
|
||||||
// unauthenticated client: a POST with no token to
|
// unauthenticated client: a POST with no token to
|
||||||
// /source/<any length of any text>/edit lands here. The
|
// /hook/<any length of any text>/edit lands here. The
|
||||||
// method and path are capped against the same budgets as
|
// method and path are capped against the same budgets as
|
||||||
// the access log. remote_addr is set by net/http from the
|
// the access log. remote_addr is set by net/http from the
|
||||||
// accepted connection rather than by the client, and
|
// accepted connection rather than by the client, and
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
|
|||||||
t, newHandler,
|
t, newHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
path := "/source/" +
|
path := "/hook/" +
|
||||||
oversizedPathSegment(fill) + "/edit"
|
oversizedPathSegment(fill) + "/edit"
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -434,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/source/"+
|
"/hook/"+
|
||||||
oversizedPathSegment(fill)+"/login",
|
oversizedPathSegment(fill)+"/login",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
@@ -499,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
|
|||||||
http.StatusRequestEntityTooLarge,
|
http.StatusRequestEntityTooLarge,
|
||||||
postOversize(
|
postOversize(
|
||||||
h,
|
h,
|
||||||
"/source/"+segment(i)+"/edit",
|
"/hook/"+segment(i)+"/edit",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
|
|||||||
//
|
//
|
||||||
// The pattern is what bounds the label's domain to the routes the
|
// The pattern is what bounds the label's domain to the routes the
|
||||||
// service registers. The path does not bound it at all — every byte
|
// service registers. The path does not bound it at all — every byte
|
||||||
// after /webhook/ is client-chosen, so labelling by path lets any
|
// after /h/ is client-chosen, so labelling by path lets any
|
||||||
// unauthenticated client mint permanent series at will, and publishes
|
// unauthenticated client mint permanent series at will, and publishes
|
||||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||||
// while doing it.
|
// while doing it.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func realMethods() []string {
|
|||||||
// dimension varying, so any series growth a probe produces is the
|
// dimension varying, so any series growth a probe produces is the
|
||||||
// method label's and nothing else's.
|
// method label's and nothing else's.
|
||||||
func methodProbePath() string {
|
func methodProbePath() string {
|
||||||
return "/webhook/" + uuid.NewString()
|
return "/h/" + uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
|
|
||||||
// receiverRoutePattern is the one handler label every receiver
|
// receiverRoutePattern is the one handler label every receiver
|
||||||
// request must produce, however the client varies the path.
|
// request must produce, however the client varies the path.
|
||||||
receiverRoutePattern = "/webhook/{uuid}"
|
receiverRoutePattern = "/h/{uuid}"
|
||||||
|
|
||||||
// okRoute is a static route used to pin that the response-writer
|
// okRoute is a static route used to pin that the response-writer
|
||||||
// interceptor still reports status and size after the handler id
|
// interceptor still reports status and size after the handler id
|
||||||
@@ -143,13 +143,13 @@ func drivePaths(
|
|||||||
return drive(t, h, probes)
|
return drive(t, h, probes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiverPaths returns n distinct /webhook/ paths, each naming a
|
// receiverPaths returns n distinct /h/ paths, each naming a
|
||||||
// fresh UUID exactly as an unauthenticated flood would.
|
// fresh UUID exactly as an unauthenticated flood would.
|
||||||
func receiverPaths(n int) []string {
|
func receiverPaths(n int) []string {
|
||||||
paths := make([]string, 0, n)
|
paths := make([]string, 0, n)
|
||||||
|
|
||||||
for range n {
|
for range n {
|
||||||
paths = append(paths, "/webhook/"+uuid.NewString())
|
paths = append(paths, "/h/"+uuid.NewString())
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
|
|||||||
|
|
||||||
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||||
// assertion the issue asks for: N requests to N distinct
|
// assertion the issue asks for: N requests to N distinct
|
||||||
// /webhook/<uuid> paths must produce exactly ONE handler label, the
|
// /h/<uuid> paths must produce exactly ONE handler label, the
|
||||||
// route pattern. Before the fix this produced N of them.
|
// route pattern. Before the fix this produced N of them.
|
||||||
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
|||||||
// The scrape must not republish the UUIDs it was driven with.
|
// The scrape must not republish the UUIDs it was driven with.
|
||||||
// They are the receiver's only credential.
|
// They are the receiver's only credential.
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
id := strings.TrimPrefix(p, "/webhook/")
|
id := strings.TrimPrefix(p, "/h/")
|
||||||
for label := range labels {
|
for label := range labels {
|
||||||
assert.NotContains(
|
assert.NotContains(
|
||||||
t, label, id,
|
t, label, id,
|
||||||
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
|||||||
if i%2 == 0 {
|
if i%2 == 0 {
|
||||||
paths = append(paths, "/"+id)
|
paths = append(paths, "/"+id)
|
||||||
} else {
|
} else {
|
||||||
paths = append(paths, "/webhook/"+id+"/"+id)
|
paths = append(paths, "/h/"+id+"/"+id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
|
|||||||
//
|
//
|
||||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||||
// the outcomes an unauthenticated client drives for free: 404 or 429
|
// the outcomes an unauthenticated client drives for free: 404 or 429
|
||||||
// on any invented /webhook/ path, 303 to the login page on any
|
// on any invented /h/ path, 303 to the login page on any
|
||||||
// invented /user/ path. Logging the concrete URL there lets a flood
|
// invented /user/ path. Logging the concrete URL there lets a flood
|
||||||
// write attacker-chosen text, of attacker-chosen length, into the
|
// write attacker-chosen text, of attacker-chosen length, into the
|
||||||
// operator's log at one line per request. The pattern comes from the
|
// operator's log at one line per request. The pattern comes from the
|
||||||
@@ -560,7 +560,7 @@ func (s *Middleware) MaxBodySize(
|
|||||||
// internal/server/routes.go), so an
|
// internal/server/routes.go), so an
|
||||||
// unauthenticated client reaches it with a path
|
// unauthenticated client reaches it with a path
|
||||||
// of its own choosing and its own length —
|
// of its own choosing and its own length —
|
||||||
// POST /source/<8 KB>/edit with an oversize
|
// POST /hook/<8 KB>/edit with an oversize
|
||||||
// declared Content-Length costs nothing to
|
// declared Content-Length costs nothing to
|
||||||
// send. At WARN, on by default, that is a
|
// send. At WARN, on by default, that is a
|
||||||
// write into the operator's log sized by the
|
// write into the operator's log sized by the
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet, "/sources", nil,
|
http.MethodGet, "/hooks", nil,
|
||||||
)
|
)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const (
|
|||||||
|
|
||||||
// receiverAggregateMultiplier scales the configured
|
// receiverAggregateMultiplier scales the configured
|
||||||
// per-entrypoint receiver limit into the aggregate limit one
|
// per-entrypoint receiver limit into the aggregate limit one
|
||||||
// client IP may spend across the whole /webhook/* route. Ten
|
// client IP may spend across the whole /h/* route. Ten
|
||||||
// entrypoints' worth lets a single sender address drive several
|
// entrypoints' worth lets a single sender address drive several
|
||||||
// entrypoints at their full rate, while still capping what one
|
// entrypoints at their full rate, while still capping what one
|
||||||
// address costs the unauthenticated receiver.
|
// address costs the unauthenticated receiver.
|
||||||
@@ -389,7 +389,7 @@ func (m *Middleware) postRateLimit(
|
|||||||
// It is Config.ReceiverRateLimit requests per minute.
|
// It is Config.ReceiverRateLimit requests per minute.
|
||||||
//
|
//
|
||||||
// That limit alone bounds nothing in aggregate. The route pattern
|
// That limit alone bounds nothing in aggregate. The route pattern
|
||||||
// /webhook/{uuid} matches any single segment, so a client that
|
// /h/{uuid} matches any single segment, so a client that
|
||||||
// invents a fresh path per request mints a fresh bucket per request
|
// invents a fresh path per request mints a fresh bucket per request
|
||||||
// and never refills one — and every such request still reaches the
|
// and never refills one — and every such request still reaches the
|
||||||
// handler's entrypoint lookup before it 404s. The outer limit is
|
// handler's entrypoint lookup before it 404s. The outer limit is
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
// pass.
|
// pass.
|
||||||
for i := range limit {
|
for i := range limit {
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
// The next request over the limit is rejected with a 429
|
// The next request over the limit is rejected with a 429
|
||||||
// carrying a Retry-After header.
|
// carrying a Retry-After header.
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||||
assert.NotEmpty(
|
assert.NotEmpty(
|
||||||
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
|
|
||||||
// The same IP is not limited on a different entrypoint.
|
// The same IP is not limited on a different entrypoint.
|
||||||
w = receiverPost(
|
w = receiverPost(
|
||||||
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
handler, "9.9.9.9:1234", "/h/uuid-b",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
|
|
||||||
// A different IP is not limited on the same entrypoint.
|
// A different IP is not limited on the same entrypoint.
|
||||||
w = receiverPost(
|
w = receiverPost(
|
||||||
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
handler, "8.8.8.8:1234", "/h/uuid-a",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
limit = 2
|
limit = 2
|
||||||
ip = "7.7.7.7:1234"
|
ip = "7.7.7.7:1234"
|
||||||
path = "/webhook/uuid-c"
|
path = "/h/uuid-c"
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := receiverLimitedHandler(t, limit)
|
handler := receiverLimitedHandler(t, limit)
|
||||||
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
|||||||
// none of them shares a per-entrypoint bucket with another.
|
// none of them shares a per-entrypoint bucket with another.
|
||||||
for i := range aggregate {
|
for i := range aggregate {
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
handler, ip, fmt.Sprintf("/h/invented-%d", i),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusTooManyRequests, w.Code,
|
t, http.StatusTooManyRequests, w.Code,
|
||||||
"a client must not be able to raise its aggregate rate "+
|
"a client must not be able to raise its aggregate rate "+
|
||||||
"against /webhook/* by varying the path",
|
"against /h/* by varying the path",
|
||||||
)
|
)
|
||||||
|
|
||||||
// The aggregate limit is still per client IP: exhausting one
|
// The aggregate limit is still per client IP: exhausting one
|
||||||
// address must not throttle another.
|
// address must not throttle another.
|
||||||
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
|
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0")
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
"a different client IP must not be affected",
|
"a different client IP must not be affected",
|
||||||
@@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
|||||||
// limit requests are served; the rest are rejected by the
|
// limit requests are served; the rest are rejected by the
|
||||||
// per-entrypoint limiter but still count against the aggregate.
|
// per-entrypoint limiter but still count against the aggregate.
|
||||||
for i := range aggregate {
|
for i := range aggregate {
|
||||||
w := receiverPost(handler, ip, "/webhook/exhausted")
|
w := receiverPost(handler, ip, "/h/exhausted")
|
||||||
|
|
||||||
want := http.StatusTooManyRequests
|
want := http.StatusTooManyRequests
|
||||||
if i < limit {
|
if i < limit {
|
||||||
@@ -784,7 +784,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
w := receiverPost(handler, ip, "/webhook/never-used")
|
w := receiverPost(handler, ip, "/h/never-used")
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusTooManyRequests, w.Code,
|
t, http.StatusTooManyRequests, w.Code,
|
||||||
"requests rejected per entrypoint must still count "+
|
"requests rejected per entrypoint must still count "+
|
||||||
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
|||||||
const (
|
const (
|
||||||
limit = 3
|
limit = 3
|
||||||
peer = "203.0.113.10:44444"
|
peer = "203.0.113.10:44444"
|
||||||
path = "/webhook/uuid-d"
|
path = "/h/uuid-d"
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := receiverLimitedHandler(t, limit)
|
handler := receiverLimitedHandler(t, limit)
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func (s *Server) setupUserRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
@@ -195,7 +195,7 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
@@ -206,14 +206,14 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Get("/edit", s.h.HandleSourceEdit())
|
r.Get("/edit", s.h.HandleSourceEdit())
|
||||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
r.Post("/delete", s.h.HandleSourceDelete())
|
r.Post("/delete", s.h.HandleSourceDelete())
|
||||||
r.Get("/logs", s.h.HandleSourceLogs())
|
r.Get("/events", s.h.HandleSourceLogs())
|
||||||
// The log page renders each body only up to its cap, so
|
// The log page renders each body only up to its cap, so
|
||||||
// this is the only route that serves a whole one. It
|
// this is the only route that serves a whole one. It
|
||||||
// belongs to this group for its RequireAuth and
|
// belongs to this group for its RequireAuth and
|
||||||
// NoCache; see HandleEventBodyDownload for the headers
|
// NoCache; see HandleEventBodyDownload for the headers
|
||||||
// that keep the bytes it returns inert.
|
// that keep the bytes it returns inert.
|
||||||
r.Get(
|
r.Get(
|
||||||
"/logs/{eventID}/body",
|
"/events/{eventID}/body",
|
||||||
s.h.HandleEventBodyDownload(),
|
s.h.HandleEventBodyDownload(),
|
||||||
)
|
)
|
||||||
// Replay is the one page action that queues outbound work:
|
// Replay is the one page action that queues outbound work:
|
||||||
@@ -280,7 +280,7 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupWebhookRoutes() {
|
func (s *Server) setupWebhookRoutes() {
|
||||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
s.h.HandleWebhook(),
|
s.h.HandleWebhook(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+420
-10
@@ -47,8 +47,8 @@ type noopNotifier struct{}
|
|||||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||||
|
|
||||||
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
||||||
// dependency. These tests never delete a webhook, so there is
|
// dependency. No test here checks what gets evicted, so it records
|
||||||
// nothing to record.
|
// nothing.
|
||||||
type noopEvictor struct{}
|
type noopEvictor struct{}
|
||||||
|
|
||||||
func (e *noopEvictor) EvictWebhook(string) {}
|
func (e *noopEvictor) EvictWebhook(string) {}
|
||||||
@@ -240,6 +240,26 @@ func (e *testEnv) csrfFrom(
|
|||||||
return token, combined
|
return token, combined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// urlFrom renders the page at path and returns the link or form
|
||||||
|
// action that pattern's one group captures, so a test requests the
|
||||||
|
// URL the template emitted rather than one it wrote itself.
|
||||||
|
func (e *testEnv) urlFrom(
|
||||||
|
t *testing.T,
|
||||||
|
path, pattern string,
|
||||||
|
cookies []*http.Cookie,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := e.get(path, cookies)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
match := regexp.MustCompile(pattern).
|
||||||
|
FindStringSubmatch(w.Body.String())
|
||||||
|
require.Len(t, match, 2, "%s should render %s", path, pattern)
|
||||||
|
|
||||||
|
return html.UnescapeString(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
// authCookies forges an authenticated session for the given user.
|
// authCookies forges an authenticated session for the given user.
|
||||||
func (e *testEnv) authCookies(
|
func (e *testEnv) authCookies(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
@@ -674,7 +694,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
|||||||
|
|
||||||
require.NotNil(t, fresh, "login must set a session cookie")
|
require.NotNil(t, fresh, "login must set a session cookie")
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "/sources",
|
t, "/hooks",
|
||||||
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
||||||
"the new session cookie must authenticate",
|
"the new session cookie must authenticate",
|
||||||
)
|
)
|
||||||
@@ -741,7 +761,344 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /source/{sourceID} group ---
|
// --- /hooks group ---
|
||||||
|
|
||||||
|
// TestHooks_ListAndNewWebhookForm gets the webhook list through the
|
||||||
|
// production router, follows both of its links to the new-webhook
|
||||||
|
// form, then submits the form to the action and with the token the
|
||||||
|
// page rendered. A mistyped route, link or form action fails here;
|
||||||
|
// the handler tests cannot catch any of them, because they never
|
||||||
|
// route a request.
|
||||||
|
func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "lister", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "lister")
|
||||||
|
|
||||||
|
// The list shows its "Create Webhook" link only while it is empty.
|
||||||
|
createLink := env.urlFrom(
|
||||||
|
t, "/hooks", `href="([^"]+)"[^>]*>Create Webhook<`, cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing := env.seedWebhook(t, userID)
|
||||||
|
|
||||||
|
list := env.get("/hooks", cookies)
|
||||||
|
require.Equal(t, http.StatusOK, list.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, list.Body.String(), `href="/hook/`+existing.ID+`"`,
|
||||||
|
"the list should link the user's webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The "New Webhook" link has an icon between its href and its text.
|
||||||
|
newLink := env.urlFrom(
|
||||||
|
t, "/hooks", `href="([^"]+)"[^>]*>(?:\s*<[^>]*>)*\s*New Webhook`,
|
||||||
|
cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
token, cookies := env.csrfFrom(t, newLink, cookies)
|
||||||
|
action := env.urlFrom(t, newLink, `action="(/hooks[^"]*)"`, cookies)
|
||||||
|
assert.Equal(
|
||||||
|
t, action,
|
||||||
|
env.urlFrom(t, createLink, `action="(/hooks[^"]*)"`, cookies),
|
||||||
|
"both links should open the new-webhook form",
|
||||||
|
)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
form.Set("name", "created")
|
||||||
|
|
||||||
|
w := env.post(action, form, cookies)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
var created database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "/hook/"+created.ID, w.Header().Get("Location"),
|
||||||
|
"creating a webhook should redirect to its page",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /hook/{sourceID} group ---
|
||||||
|
|
||||||
|
// TestHook_EditFormAndDelete follows the webhook page's Edit link to
|
||||||
|
// the edit form and submits it, then deletes the webhook with the
|
||||||
|
// form on its page, every URL and token taken from the rendered
|
||||||
|
// pages.
|
||||||
|
func TestHook_EditFormAndDelete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "editor", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "editor")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
page := "/hook/" + wh.ID
|
||||||
|
|
||||||
|
editPage := env.urlFrom(t, page, `href="(/hook/[^/"]+/edit)"`, cookies)
|
||||||
|
token, cookies := env.csrfFrom(t, editPage, cookies)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
form.Set("name", "renamed")
|
||||||
|
|
||||||
|
w := env.post(
|
||||||
|
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
||||||
|
form, cookies,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(t, page, w.Header().Get("Location"))
|
||||||
|
|
||||||
|
var edited database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().First(&edited, "id = ?", wh.ID).Error)
|
||||||
|
assert.Equal(t, "renamed", edited.Name)
|
||||||
|
|
||||||
|
form = url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
|
w = env.post(
|
||||||
|
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
||||||
|
form, cookies,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusNotFound, env.get(page, cookies).Code,
|
||||||
|
"a deleted webhook's page should be gone",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHook_EntrypointActions adds, deactivates and deletes an
|
||||||
|
// entrypoint with the forms on the webhook page, each submitted to
|
||||||
|
// the action and with the token the page rendered.
|
||||||
|
func TestHook_EntrypointActions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "epuser", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "epuser")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
page := "/hook/" + wh.ID
|
||||||
|
|
||||||
|
token, cookies := env.csrfFrom(t, page, cookies)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
|
// submit posts the webhook page's form whose action pattern
|
||||||
|
// captures, and requires the redirect back to that page.
|
||||||
|
submit := func(pattern string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
require.Equal(t, page, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(`action="(/hook/[^/"]+/entrypoints)"`)
|
||||||
|
|
||||||
|
var added database.Entrypoint
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
env.db.DB().First(&added, "webhook_id = ?", wh.ID).Error,
|
||||||
|
)
|
||||||
|
require.True(t, added.Active)
|
||||||
|
|
||||||
|
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
|
||||||
|
|
||||||
|
var toggled database.Entrypoint
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
env.db.DB().First(&toggled, "id = ?", added.ID).Error,
|
||||||
|
)
|
||||||
|
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||||
|
|
||||||
|
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
|
||||||
|
|
||||||
|
var left int64
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().Model(&database.Entrypoint{}).
|
||||||
|
Where("webhook_id = ?", wh.ID).Count(&left).Error)
|
||||||
|
assert.Zero(t, left, "the delete should remove the entrypoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHook_TargetActions adds a target with the form on the webhook
|
||||||
|
// page, follows its Edit link to the target edit form and submits
|
||||||
|
// it, then deactivates and deletes it, every URL and token taken from
|
||||||
|
// the rendered pages.
|
||||||
|
func TestHook_TargetActions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "tgtuser", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "tgtuser")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
page := "/hook/" + wh.ID
|
||||||
|
|
||||||
|
token, cookies := env.csrfFrom(t, page, cookies)
|
||||||
|
|
||||||
|
// submit posts form, with the token, to the action pattern
|
||||||
|
// captures on the page at from, and requires the redirect back to
|
||||||
|
// the webhook page.
|
||||||
|
submit := func(from, pattern string, form url.Values) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
|
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
require.Equal(t, page, w.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
||||||
|
"name": {"added"},
|
||||||
|
"type": {string(database.TargetTypeLog)},
|
||||||
|
})
|
||||||
|
|
||||||
|
editPage := env.urlFrom(
|
||||||
|
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
||||||
|
)
|
||||||
|
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
||||||
|
url.Values{"name": {"renamed"}})
|
||||||
|
|
||||||
|
var edited database.Target
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
env.db.DB().First(&edited, "webhook_id = ?", wh.ID).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(t, "renamed", edited.Name)
|
||||||
|
require.True(t, edited.Active)
|
||||||
|
|
||||||
|
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
|
||||||
|
url.Values{})
|
||||||
|
|
||||||
|
var toggled database.Target
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
env.db.DB().First(&toggled, "id = ?", edited.ID).Error,
|
||||||
|
)
|
||||||
|
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||||
|
|
||||||
|
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
||||||
|
url.Values{})
|
||||||
|
|
||||||
|
var left int64
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().Model(&database.Target{}).
|
||||||
|
Where("webhook_id = ?", wh.ID).Count(&left).Error)
|
||||||
|
assert.Zero(t, left, "the delete should remove the target")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHook_ResubmitFromEventLog follows the webhook page's "Full
|
||||||
|
// Event Log" link, then resubmits a stored event with the form on
|
||||||
|
// that page, submitted to the action and with the token the page
|
||||||
|
// rendered.
|
||||||
|
func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "resubmitter")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
|
||||||
|
|
||||||
|
logsPath := env.urlFrom(
|
||||||
|
t, "/hook/"+wh.ID, `href="([^"]+)"[^>]*>Full Event Log<`, cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
|
w := env.post(
|
||||||
|
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
||||||
|
form, cookies,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var events int64
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
webhookDB.Model(&database.Event{}).Count(&events).Error,
|
||||||
|
)
|
||||||
|
assert.Equal(t, int64(2), events, "the resubmit stores a new event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||||
|
// the tests above do not: the navbar's "Webhooks" links, the back and
|
||||||
|
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
||||||
|
// link beside the recent events, and the event log's page links. Each
|
||||||
|
// must point where it should, and that page must render.
|
||||||
|
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "navigator", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "navigator")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
tgt := env.seedTarget(t, wh.ID)
|
||||||
|
|
||||||
|
// The event log shows 25 events a page; one more gives it a second
|
||||||
|
// page, so it renders its Next and Previous links.
|
||||||
|
for range 26 {
|
||||||
|
env.seedEvent(t, wh.ID, "paged")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := "/hooks"
|
||||||
|
newForm := list + "/new"
|
||||||
|
page := "/hook/" + wh.ID
|
||||||
|
targetEdit := page + "/targets/" + tgt.ID + "/edit"
|
||||||
|
events := page + "/events"
|
||||||
|
back := `href="([^"]+)"[^>]*>← Back to `
|
||||||
|
cancel := `href="([^"]+)"[^>]*>Cancel<`
|
||||||
|
|
||||||
|
for _, link := range []struct{ from, pattern, want string }{
|
||||||
|
// The navbar on the profile page: its desktop link, then its
|
||||||
|
// mobile menu link.
|
||||||
|
{
|
||||||
|
"/user/navigator/",
|
||||||
|
`href="([^"]+)" class="btn-text">Webhooks<`,
|
||||||
|
list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/user/navigator/",
|
||||||
|
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`,
|
||||||
|
list,
|
||||||
|
},
|
||||||
|
{list, `href="(/hook/[^"]+)"`, page},
|
||||||
|
{newForm, back, list},
|
||||||
|
{newForm, cancel, list},
|
||||||
|
{page, back, list},
|
||||||
|
{page, `Recent Events</h2>\s*<a href="([^"]+)"`, events},
|
||||||
|
{page + "/edit", back, page},
|
||||||
|
{page + "/edit", cancel, page},
|
||||||
|
{targetEdit, back, page},
|
||||||
|
{targetEdit, cancel, page},
|
||||||
|
{events, back, page},
|
||||||
|
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
||||||
|
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
||||||
|
} {
|
||||||
|
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
||||||
|
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
||||||
|
assert.Equal(t, http.StatusOK, env.get(got, cookies).Code, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||||
// feature the way a user does: render the event log page through
|
// feature the way a user does: render the event log page through
|
||||||
@@ -769,11 +1126,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
|||||||
wh := env.seedWebhook(t, userID)
|
wh := env.seedWebhook(t, userID)
|
||||||
env.seedEvent(t, wh.ID, stored)
|
env.seedEvent(t, wh.ID, stored)
|
||||||
|
|
||||||
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
page := env.get("/hook/"+wh.ID+"/events", cookies)
|
||||||
require.Equal(t, http.StatusOK, page.Code)
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
link := regexp.MustCompile(
|
link := regexp.MustCompile(
|
||||||
`href="(/source/[^"]+/body)"`,
|
`href="(/hook/[^"]+/body)"`,
|
||||||
).FindStringSubmatch(page.Body.String())
|
).FindStringSubmatch(page.Body.String())
|
||||||
require.Len(
|
require.Len(
|
||||||
t, link, 2,
|
t, link, 2,
|
||||||
@@ -819,7 +1176,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|||||||
const payload = "OWNERS-PAYLOAD-77c1"
|
const payload = "OWNERS-PAYLOAD-77c1"
|
||||||
|
|
||||||
evt := env.seedEvent(t, wh.ID, payload)
|
evt := env.seedEvent(t, wh.ID, payload)
|
||||||
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body"
|
||||||
|
|
||||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
||||||
intruder := env.authCookies(t, intruderID, "intruder")
|
intruder := env.authCookies(t, intruderID, "intruder")
|
||||||
@@ -853,7 +1210,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
||||||
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
|
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
|
||||||
|
|
||||||
path := "/source/" + wh.ID + "/deliveries/" + dlv.ID +
|
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID +
|
||||||
"/replay"
|
"/replay"
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -879,7 +1236,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
// The token and the action URL both come out of the rendered
|
// The token and the action URL both come out of the rendered
|
||||||
// page, so a typo in either the route pattern or the template
|
// page, so a typo in either the route pattern or the template
|
||||||
// fails here.
|
// fails here.
|
||||||
logsPath := "/source/" + wh.ID + "/logs"
|
logsPath := "/hook/" + wh.ID + "/events"
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||||
|
|
||||||
@@ -887,7 +1244,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, page.Code)
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
action := regexp.MustCompile(
|
action := regexp.MustCompile(
|
||||||
`action="(/source/[^"]+/replay)"`,
|
`action="(/hook/[^"]+/replay)"`,
|
||||||
).FindStringSubmatch(page.Body.String())
|
).FindStringSubmatch(page.Body.String())
|
||||||
require.Len(
|
require.Len(
|
||||||
t, action, 2,
|
t, action, 2,
|
||||||
@@ -912,6 +1269,59 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- /h/{uuid} receiver ---
|
||||||
|
|
||||||
|
// TestReceiver_EntrypointURLIsRateLimited takes the entrypoint URL
|
||||||
|
// the webhook page shows and posts to it through the production
|
||||||
|
// router until the receiver rate limit refuses it. The URL has to
|
||||||
|
// reach the receiver, and the limit has to apply to it.
|
||||||
|
func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 2
|
||||||
|
|
||||||
|
env := newTestEnvWithConfig(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
ReceiverRateLimit: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "receiver", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "receiver")
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
||||||
|
&database.Entrypoint{
|
||||||
|
WebhookID: wh.ID,
|
||||||
|
Path: "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60",
|
||||||
|
Active: true,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
page := env.get("/hook/"+wh.ID, cookies)
|
||||||
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
|
shown := regexp.MustCompile(`(/h/[^<]+)</code>`).
|
||||||
|
FindStringSubmatch(page.Body.String())
|
||||||
|
require.Len(
|
||||||
|
t, shown, 2, "the webhook page should show the entrypoint URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range limit {
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusOK,
|
||||||
|
env.post(shown[1], url.Values{}, nil).Code,
|
||||||
|
"request %d should reach the receiver", i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusTooManyRequests,
|
||||||
|
env.post(shown[1], url.Values{}, nil).Code,
|
||||||
|
"the receiver rate limit must apply to the entrypoint URL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// metricsConfig is a Config differing from the routing default only
|
// metricsConfig is a Config differing from the routing default only
|
||||||
// in the two /metrics credentials.
|
// in the two /metrics credentials.
|
||||||
func metricsConfig(
|
func metricsConfig(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
|||||||
//
|
//
|
||||||
// URL is the third such field. NewRequest builds it as
|
// URL is the third such field. NewRequest builds it as
|
||||||
// scheme://host/path (interfaces.go:183), and on the receiver route
|
// scheme://host/path (interfaces.go:183), and on the receiver route
|
||||||
// that path is /webhook/<uuid> in full — a write capability, not an
|
// that path is /h/<uuid> in full — a write capability, not an
|
||||||
// identifier. It is rebuilt here from the chi route pattern, on every
|
// identifier. It is rebuilt here from the chi route pattern, on every
|
||||||
// route, keeping the scheme and the host.
|
// route, keeping the scheme and the host.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
|
|||||||
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
||||||
)
|
)
|
||||||
router.HandleFunc("/pages/login", handler)
|
router.HandleFunc("/pages/login", handler)
|
||||||
router.HandleFunc("/webhook/{uuid}", handler)
|
router.HandleFunc("/h/{uuid}", handler)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
|
|||||||
// concrete path carries the entrypoint capability.
|
// concrete path carries the entrypoint capability.
|
||||||
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
||||||
return sentryRequest(
|
return sentryRequest(
|
||||||
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
|
client, "/h/"+sentryReceiverUUID, "payload=hello",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
|||||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "http://example.com/webhook/{uuid}", event.Request.URL,
|
t, "http://example.com/h/{uuid}", event.Request.URL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
|
|||||||
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
concrete := "https://example.com/webhook/" + sentryReceiverUUID
|
concrete := "https://example.com/h/" + sentryReceiverUUID
|
||||||
|
|
||||||
// A request with no chi routing context on it at all, which is
|
// A request with no chi routing context on it at all, which is
|
||||||
// what an event captured outside the router would carry.
|
// what an event captured outside the router would carry.
|
||||||
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
|||||||
|
|
||||||
event := sentry.NewEvent()
|
event := sentry.NewEvent()
|
||||||
event.Request = &sentry.Request{URL: concrete}
|
event.Request = &sentry.Request{URL: concrete}
|
||||||
event.Transaction = "POST /webhook/" +
|
event.Transaction = "POST /h/" +
|
||||||
sentryReceiverUUID
|
sentryReceiverUUID
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(
|
scrubbed := server.ScrubSentryRequestForTest(
|
||||||
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
|
|||||||
|
|
||||||
event := sentry.NewEvent()
|
event := sentry.NewEvent()
|
||||||
event.Request = &sentry.Request{
|
event.Request = &sentry.Request{
|
||||||
URL: "/webhook/" + sentryReceiverUUID,
|
URL: "/h/" + sentryReceiverUUID,
|
||||||
}
|
}
|
||||||
event.Transaction = "/webhook/" + sentryReceiverUUID
|
event.Transaction = "/h/" + sentryReceiverUUID
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
||||||
require.NotNil(t, scrubbed)
|
require.NotNil(t, scrubbed)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text">Webhooks</a>
|
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -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 x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/hook/{{.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?')">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
<button type="submit" class="btn-primary text-sm">Add</button>
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||||
@@ -57,20 +57,20 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2 mt-1">
|
<div class="flex items-start gap-2 mt-1">
|
||||||
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
|
||||||
<!-- Hidden until app.js reveals it; without the
|
<!-- Hidden until app.js reveals it; without the
|
||||||
script the URL above stays selectable. -->
|
script the URL above stays selectable. -->
|
||||||
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
@@ -151,14 +151,14 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
<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="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.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>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="alert-error">{{.Error}}</div>
|
<div class="alert-error">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.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">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
||||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<input type="hidden" name="page" value="{{$.Page}}">
|
||||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||||
{{if .BodyTruncated}}
|
{{if .BodyTruncated}}
|
||||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.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="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{{if .Status.Terminal}}
|
{{if .Status.Terminal}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<input type="hidden" name="page" value="{{$.Page}}">
|
||||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
{{if or .HasPrev .HasNext}}
|
{{if or .HasPrev .HasNext}}
|
||||||
<div class="flex justify-center gap-2 mt-6">
|
<div class="flex justify-center gap-2 mt-6">
|
||||||
{{if .HasPrev}}
|
{{if .HasPrev}}
|
||||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||||
{{if .HasNext}}
|
{{if .HasNext}}
|
||||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
||||||
<a href="/sources/new" class="btn-primary">
|
<a href="/hooks/new" class="btn-primary">
|
||||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{{if .Webhooks}}
|
{{if .Webhooks}}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{{range .Webhooks}}
|
{{range .Webhooks}}
|
||||||
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
|
<a href="/hook/{{.ID}}" class="card-elevated p-6 block">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
||||||
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
||||||
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" 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>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="alert-error">{{.Error}}</div>
|
<div class="alert-error">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/sources/new" class="space-y-6">
|
<form method="POST" action="/hooks/new" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||||
<a href="/sources" class="btn-secondary">Cancel</a>
|
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.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>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
||||||
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user