From 4ce2116a1e57fca731239e10118cc01b13154185 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 19:13:32 +0000 Subject: [PATCH] Move webhook pages to /hook/ID and inbound URLs to /h/UUID (closes #367) 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. A new route test posts to the entrypoint URL the webhook page shows and checks that the receiver rate limit applies to it. Model: opus-5-5 --- README.md | 76 +++++++++---------- internal/gormlog/gormlog.go | 2 +- internal/handlers/delivery_replay.go | 2 +- internal/handlers/delivery_replay_test.go | 16 ++-- internal/handlers/event_body_test.go | 8 +- internal/handlers/event_resubmit.go | 2 +- internal/handlers/event_resubmit_test.go | 12 +-- internal/handlers/gormlogbound_test.go | 2 +- internal/handlers/handlers_test.go | 2 +- internal/handlers/index.go | 4 +- internal/handlers/logbound_test.go | 10 +-- internal/handlers/recent_events_test.go | 2 +- internal/handlers/source_delete_test.go | 18 ++--- .../handlers/source_detail_baseurl_test.go | 8 +- internal/handlers/source_detail_test.go | 2 +- .../source_logs_deleted_target_test.go | 2 +- internal/handlers/source_logs_test.go | 2 +- internal/handlers/source_management.go | 18 ++--- internal/handlers/source_management_test.go | 14 ++-- internal/handlers/target_create_query_test.go | 6 +- internal/handlers/target_edit.go | 4 +- internal/handlers/target_edit_test.go | 18 ++--- internal/handlers/target_retries_test.go | 2 +- internal/handlers/ui_copy_test.go | 65 +++++++++++++--- internal/handlers/webhook.go | 2 +- internal/logfield/logfield_test.go | 2 +- internal/middleware/accesslog_test.go | 16 ++-- internal/middleware/csrf.go | 2 +- internal/middleware/logbound_test.go | 6 +- internal/middleware/metrics.go | 2 +- internal/middleware/metrics_method_test.go | 2 +- internal/middleware/metrics_test.go | 12 +-- internal/middleware/middleware.go | 4 +- internal/middleware/middleware_test.go | 2 +- internal/middleware/ratelimit.go | 4 +- internal/middleware/ratelimit_test.go | 24 +++--- internal/server/routes.go | 10 +-- internal/server/routes_test.go | 69 +++++++++++++++-- internal/server/sentry.go | 2 +- internal/server/sentry_test.go | 14 ++-- templates/navbar.html | 4 +- templates/source_detail.html | 26 +++---- templates/source_edit.html | 6 +- templates/source_logs.html | 16 ++-- templates/sources_list.html | 6 +- templates/sources_new.html | 6 +- templates/target_edit.html | 6 +- 47 files changed, 319 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index fca4858..0523916 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets with retry support, logging, and observability. Category: infrastructure / web service. License: MIT. -Each entrypoint is a version 4 UUID served at `/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 shared secrets, HMAC signatures or token headers on the receiver, and will not add them — read @@ -1193,7 +1193,7 @@ backups at rest and restrict who can read them. **The entrypoint UUID is the credential, and it is the only one.** webhooker mints a version 4 UUID per entrypoint and serves it at -`/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 receiver verifies nothing else about the sender. @@ -1527,7 +1527,7 @@ the full request and creates an Event. | -------------- | ------- | ----------- | | `id` | UUID | Primary key | | `webhook_id` | UUID | Foreign key → Webhook | -| `path` | string | Unique bare UUID, generated at creation. The `/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 | | `active` | boolean | Whether this entrypoint accepts events (default: true) | @@ -1909,7 +1909,7 @@ runtime, though CGO is required at build time due to the transitive ``` External Service │ - │ POST /webhook/{uuid} + │ POST /h/{uuid} ▼ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ chi Router │────►│ Middleware │────►│ Webhook │ @@ -2135,7 +2135,7 @@ The middleware records three more on the same registry: Two of those labels are written once per request from bytes the client chose, so both are bounded to something this service registers: -- `handler` is the chi route pattern — `/webhook/{uuid}`, never the +- `handler` is the chi route pattern — `/h/{uuid}`, never the concrete path. A request matching no route carries `(unmatched)`, and no entrypoint UUID ever reaches a label. - `method` is the request method when the router can route it, and @@ -2165,7 +2165,7 @@ unpredictable rates, and blanket limits shared with other routes would cause legitimate deliveries to be dropped. The receiver instead has its own dedicated abuse limit, scoped to the -`/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 affecting other senders of the same entrypoint or the same sender's other entrypoints. Keying on the path rather than on the entrypoint @@ -2202,7 +2202,7 @@ log spends. The access log is bounded by neither limit: every request is recorded once at `INFO`, served or rejected alike. What the access log does bound is the _content_ of those lines. A 3xx -or 4xx response logs the chi route pattern — `/webhook/{uuid}`, +or 4xx response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the literal `(unmatched)` when the request hit no route at all — in place of the concrete URL. Those are the outcomes an unauthenticated client can drive for free: 404 and 429 on any @@ -2236,12 +2236,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`, The same hook rewrites the request URL. The SDK builds it as `scheme://host/path` from the concrete path, which on the receiver -route is `/webhook/` in full — and that UUID is a write +route is `/h/` in full — and that UUID is a write capability, not an identifier: anyone holding it can post events this service accepts and its targets then deliver. A tracker has its own retention, access control and deletion policy, so the rule the access log follows above does not carry across that boundary. What is sent is -the chi route pattern instead: `http://host/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 discarded rather than edited, so a future SDK version that starts @@ -2285,8 +2285,8 @@ fallback is never the concrete path. The path becomes the literal rewrite cannot parse into a scheme is withheld whole. A transaction event additionally carries the SDK's own `METHOD /path` name, built from the concrete path as well; it is rewritten on the same terms, to -`POST /webhook/{uuid}` where the pattern is known and `POST -/(redacted)` where it is not. +`POST /h/{uuid}` where the pattern is known and `POST /(redacted)` +where it is not. The headers are an allowlist for the same reason the rules above are unconditional: the SDK's own filter removes four names and passes @@ -2421,7 +2421,7 @@ logger printed the fully interpolated SQL — parameters and all — to standard output on every statement that returned an error, including a plain record-not-found, at a level no operator setting reached. Two of this service's lookups miss by design on unauthenticated routes: the -entrypoint lookup behind `/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 picks outright. Every `gorm.Open` in the service now installs the adapter in @@ -2699,10 +2699,10 @@ abuse limit later; they are tracked as future work. | 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`, `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 @@ -2718,25 +2718,25 @@ abuse limit later; they are tracked as future work. | ------ | ------------------------ | ----------- | | `GET` | `/user/{username}` | User profile page | | `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) | -| `GET` | `/sources` | List user's webhooks | -| `GET` | `/sources/new` | Create webhook form | -| `POST` | `/sources/new` | Create webhook submission | -| `GET` | `/source/{id}` | Webhook detail view | -| `GET` | `/source/{id}/edit` | Edit webhook form | -| `POST` | `/source/{id}/edit` | Edit webhook submission | -| `POST` | `/source/{id}/delete` | Delete webhook | -| `GET` | `/source/{id}/logs` | Webhook event logs | -| `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated | -| `POST` | `/source/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | -| `POST` | `/source/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | -| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook | -| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | -| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | -| `POST` | `/source/{id}/targets` | Add target to webhook | -| `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | -| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission | -| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target | -| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target | +| `GET` | `/hooks` | List user's webhooks | +| `GET` | `/hooks/new` | Create webhook form | +| `POST` | `/hooks/new` | Create webhook submission | +| `GET` | `/hook/{id}` | Webhook detail view | +| `GET` | `/hook/{id}/edit` | Edit webhook form | +| `POST` | `/hook/{id}/edit` | Edit webhook submission | +| `POST` | `/hook/{id}/delete` | Delete webhook | +| `GET` | `/hook/{id}/events` | Full Event Log | +| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated | +| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | +| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | +| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | +| `POST` | `/hook/{id}/targets` | Add target to webhook | +| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | +| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission | +| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target | +| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target | #### Infrastructure Endpoints @@ -2937,8 +2937,8 @@ local record instead of nothing. What that placement gives up is recovery of a panic in the six entries above it, none of which does more than set a header or start a timer. -Additionally, form endpoints (`/pages`, `/user/*`, `/sources`, -`/source/*`) apply a **MaxBodySize** middleware that limits +Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`, +`/hook/*`) apply a **MaxBodySize** middleware that limits POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the CSRF middleware in every one of those route groups, because gorilla/csrf parses the form; if the cap were installed after it, form @@ -2962,7 +2962,7 @@ Those same four route groups then apply **CSRF** and **NoCache** `/pages` applies **RequireAuth**. The rate limiters are per-route rather than global: **PasswordChangeRateLimit** on `/user/{username}/password` and **ReceiverRateLimit** on -`/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 check, see [The login endpoint](#the-login-endpoint). @@ -3003,8 +3003,8 @@ check, see [The login endpoint](#the-login-endpoint). by middleware that runs before CSRF parses the form - **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all state-changing forms (cookie-based double-submit tokens with - HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and - `/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and + HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and + `/user` routes. Excluded from `/h` (inbound webhook POSTs) and `/api` (stateless API). The middleware detects TLS per-request through `internal/reqtls.IsTLS` — the same predicate the session cookie uses — to set appropriate cookie security flags and Origin/Referer validation diff --git a/internal/gormlog/gormlog.go b/internal/gormlog/gormlog.go index a44dd03..3e17f75 100644 --- a/internal/gormlog/gormlog.go +++ b/internal/gormlog/gormlog.go @@ -7,7 +7,7 @@ // SQL — parameters and all — for every statement that returns an // error, including gorm.ErrRecordNotFound. Two of this service's // lookups miss by design on unauthenticated routes: the entrypoint -// lookup on /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 // the client picks outright. Under the default logger each of those // misses printed an unbounded, attacker-chosen string, at no level the diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index ee9425b..504f25b 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -362,7 +362,7 @@ func (h *Handlers) finishReplay( webhook database.Webhook, code replayOutcomeCode, ) { - dest := "/source/" + webhook.ID + "/logs?" + + dest := "/hook/" + webhook.ID + "/events?" + replayOutcomeParam + "=" + string(code) // The page is read from the form rather than the query string: diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index 4d8a3be..a301237 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -138,7 +138,7 @@ func postReplay( t.Helper() req := postRequest( - "/source/"+webhookID+"/deliveries/"+ + "/hook/"+webhookID+"/deliveries/"+ deliveryID+"/replay", authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, @@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=queued", + "/hook/"+wh.ID+"/events?replay=queued", w.Header().Get("Location"), ) @@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=target-deleted", + "/hook/"+wh.ID+"/events?replay=target-deleted", w.Header().Get("Location"), ) @@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { require.Equal(t, http.StatusSeeOther, missing.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=target-missing", + "/hook/"+wh.ID+"/events?replay=target-missing", missing.Header().Get("Location"), ) } @@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( require.Equal(t, http.StatusSeeOther, first.Code) require.Equal( t, - "/source/"+wh.ID+"/logs?replay=queued", + "/hook/"+wh.ID+"/events?replay=queued", first.Header().Get("Location"), ) @@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( require.Equal(t, http.StatusSeeOther, second.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=in-flight", + "/hook/"+wh.ID+"/events?replay=in-flight", second.Header().Get("Location"), ) @@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( require.Equal(t, http.StatusSeeOther, pending.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?replay=not-terminal", + "/hook/"+wh.ID+"/events?replay=not-terminal", pending.Header().Get("Location"), ) } @@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) { assert.Contains( t, body, - `action="/source/`+wh.ID+`/deliveries/`+ + `action="/hook/`+wh.ID+`/deliveries/`+ original.ID+`/replay"`, ) assert.Contains(t, body, `method="POST"`) diff --git a/internal/handlers/event_body_test.go b/internal/handlers/event_body_test.go index e4656ac..e758635 100644 --- a/internal/handlers/event_body_test.go +++ b/internal/handlers/event_body_test.go @@ -64,8 +64,8 @@ func fetchEventBody( req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, - "/source/"+url.PathEscape(sourceID)+ - "/logs/"+url.PathEscape(eventID)+"/body", + "/hook/"+url.PathEscape(sourceID)+ + "/events/"+url.PathEscape(eventID)+"/body", nil, ) @@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload( page := renderSourceLogsPage(t, h, sess, big.ID) assert.Contains( t, page, - "/source/"+big.ID+"/logs/"+bigEvt.ID+"/body", + "/hook/"+big.ID+"/events/"+bigEvt.ID+"/body", ) small := seedWebhook(t, db) @@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload( page = renderSourceLogsPage(t, h, sess, small.ID) assert.NotContains( t, page, - "/source/"+small.ID+"/logs/"+smallEvt.ID+"/body", + "/hook/"+small.ID+"/events/"+smallEvt.ID+"/body", ) } diff --git a/internal/handlers/event_resubmit.go b/internal/handlers/event_resubmit.go index 21e54b8..92eae23 100644 --- a/internal/handlers/event_resubmit.go +++ b/internal/handlers/event_resubmit.go @@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit( webhook database.Webhook, code resubmitOutcomeCode, ) { - dest := "/source/" + webhook.ID + "/logs?" + + dest := "/hook/" + webhook.ID + "/events?" + resubmitOutcomeParam + "=" + string(code) // The page is read from the form rather than the query string: diff --git a/internal/handlers/event_resubmit_test.go b/internal/handlers/event_resubmit_test.go index 5038166..921139d 100644 --- a/internal/handlers/event_resubmit_test.go +++ b/internal/handlers/event_resubmit_test.go @@ -65,7 +65,7 @@ func postResubmit( t.Helper() req := postRequest( - "/source/"+webhookID+"/events/"+eventID+"/resubmit", + "/hook/"+webhookID+"/events/"+eventID+"/resubmit", authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ), @@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent( require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=queued", + "/hook/"+wh.ID+"/events?resubmit=queued", w.Header().Get("Location"), ) @@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) { require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=queued", + "/hook/"+wh.ID+"/events?resubmit=queued", w.Header().Get("Location"), "a resubmit must not be refused while an earlier "+ "one is in flight", @@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) { require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=queued", + "/hook/"+wh.ID+"/events?resubmit=queued", w.Header().Get("Location"), "an inactive target is skipped, not an error", ) @@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent( require.Equal(t, http.StatusSeeOther, w.Code) assert.Equal( t, - "/source/"+wh.ID+"/logs?resubmit=no-targets", + "/hook/"+wh.ID+"/events?resubmit=no-targets", w.Header().Get("Location"), ) @@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) { ) assert.Contains( 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", ) } diff --git a/internal/handlers/gormlogbound_test.go b/internal/handlers/gormlogbound_test.go index 123eb4d..d5458dc 100644 --- a/internal/handlers/gormlogbound_test.go +++ b/internal/handlers/gormlogbound_test.go @@ -306,7 +306,7 @@ func postWebhook( t.Helper() req := httptest.NewRequestWithContext( - context.Background(), http.MethodPost, "/webhook/x", + context.Background(), http.MethodPost, "/h/x", strings.NewReader("{}"), ) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 3e9874a..8dd0c68 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -176,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) { assert.Equal(t, http.StatusSeeOther, w2.Code) assert.Equal( - t, "/sources", w2.Header().Get("Location"), + t, "/hooks", w2.Header().Get("Location"), ) } diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 17272d0..edbfc76 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -5,13 +5,13 @@ import ( ) // 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. func (s *Handlers) HandleIndex() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess, err := s.session.Get(r) if err == nil && s.session.IsAuthenticated(sess) { - http.Redirect(w, r, "/sources", http.StatusSeeOther) + http.Redirect(w, r, "/hooks", http.StatusSeeOther) return } diff --git a/internal/handlers/logbound_test.go b/internal/handlers/logbound_test.go index bddb149..239bde3 100644 --- a/internal/handlers/logbound_test.go +++ b/internal/handlers/logbound_test.go @@ -4,7 +4,7 @@ package handlers_test // this package reach a value an UNAUTHENTICATED client picks outright // and of a length it picks outright: // -// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose +// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose // path segment matched no stored entrypoint and so is bounded by // nothing; // - the failed-login DEBUG lines, whose username is a form field. @@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) { // route pattern. func receiverRouter(h *handlers.Handlers) *chi.Mux { router := chi.NewRouter() - router.Post("/webhook/{uuid}", h.HandleWebhook()) + router.Post("/h/{uuid}", h.HandleWebhook()) return router } -// postReceiver sends one POST at /webhook/. +// postReceiver sends one POST at /h/. // // RawPath is cleared after parsing so chi routes on the decoded path // and the handler sees the raw bytes rather than their percent-escaped @@ -210,7 +210,7 @@ func postReceiver( req := httptest.NewRequestWithContext( context.Background(), http.MethodPost, - "/webhook/"+url.PathEscape(segment), + "/h/"+url.PathEscape(segment), strings.NewReader(""), ) req.URL.RawPath = "" @@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize( http.StatusServiceUnavailable, postLoginAtPath( t, h, - "/source/"+url.PathEscape( + "/hook/"+url.PathEscape( oversizedFill(fill), )+"/login", ), diff --git a/internal/handlers/recent_events_test.go b/internal/handlers/recent_events_test.go index 2fb7f43..34fa45b 100644 --- a/internal/handlers/recent_events_test.go +++ b/internal/handlers/recent_events_test.go @@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) { body := strings.Repeat("é", 1024) req := httptest.NewRequestWithContext( - context.Background(), http.MethodPost, "/webhook/x", + context.Background(), http.MethodPost, "/h/x", strings.NewReader(body), ) diff --git a/internal/handlers/source_delete_test.go b/internal/handlers/source_delete_test.go index 5441139..58db5c5 100644 --- a/internal/handlers/source_delete_test.go +++ b/internal/handlers/source_delete_test.go @@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) { ) req := postRequest( - "/source/"+wh.ID+"/delete", + "/hook/"+wh.ID+"/delete", cookies, map[string]string{paramSourceID: wh.ID}, ) @@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) { ) req := postRequest( - "/source/"+wh.ID+"/delete", + "/hook/"+wh.ID+"/delete", cookies, map[string]string{paramSourceID: wh.ID}, ) @@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything( ) req := postRequest( - "/source/"+wh.ID+"/delete", + "/hook/"+wh.ID+"/delete", cookies, map[string]string{paramSourceID: wh.ID}, ) @@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything( ) assert.Empty( t, w.Header().Get("Location"), - "a failed deletion must not redirect to /sources", + "a failed deletion must not redirect to /hooks", ) assert.Equal( @@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase( ) req := postRequest( - "/source/"+wh.ID+"/delete", + "/hook/"+wh.ID+"/delete", cookies, map[string]string{paramSourceID: wh.ID}, ) @@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase( h.HandleSourceDelete().ServeHTTP(w, req) require.Equal(t, http.StatusSeeOther, w.Code) - assert.Equal(t, "/sources", w.Header().Get("Location")) + assert.Equal(t, "/hooks", w.Header().Get("Location")) assert.Equal( t, int64(0), @@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone( ) req := postRequest( - "/source/"+wh.ID+"/targets/"+tgt.ID+"/delete", + "/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete", cookies, map[string]string{ paramSourceID: wh.ID, @@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains( ) req := postRequest( - "/source/"+wh.ID+"/targets/"+doomed.ID+"/delete", + "/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete", cookies, map[string]string{ paramSourceID: wh.ID, @@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted( ) req := postRequest( - "/source/"+wh.ID+"/targets/"+other.ID+"/delete", + "/hook/"+wh.ID+"/targets/"+other.ID+"/delete", cookies, map[string]string{ paramSourceID: wh.ID, diff --git a/internal/handlers/source_detail_baseurl_test.go b/internal/handlers/source_detail_baseurl_test.go index c3e0417..4b4f2c8 100644 --- a/internal/handlers/source_detail_baseurl_test.go +++ b/internal/handlers/source_detail_baseurl_test.go @@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL( req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, - "/source/"+f.webhook, + "/hook/"+f.webhook, nil, ) req.Host = host @@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) { assert.Equal( t, - tc.scheme+"://"+host+"/webhook/"+fixture.path, + tc.scheme+"://"+host+"/h/"+fixture.path, fixture.entrypointURL( t, host, forwardedProto(tc.header), ), @@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader( assert.Equal( t, - "https://"+host+"/webhook/"+fixture.path, + "https://"+host+"/h/"+fixture.path, got, "a connection this process terminated with TLS "+ "outranks a header claiming plaintext", @@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) { assert.Equal( t, - "https://"+host+"/webhook/"+fixture.path, + "https://"+host+"/h/"+fixture.path, fixture.entrypointURL( t, host, forwardedProto("HTTPS"), ), diff --git a/internal/handlers/source_detail_test.go b/internal/handlers/source_detail_test.go index 7e3f232..faa945e 100644 --- a/internal/handlers/source_detail_test.go +++ b/internal/handlers/source_detail_test.go @@ -82,7 +82,7 @@ func serveSourceDetailPage( req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, - "/source/"+webhookID, + "/hook/"+webhookID, nil, ) diff --git a/internal/handlers/source_logs_deleted_target_test.go b/internal/handlers/source_logs_deleted_target_test.go index 31c7a1f..1f1f520 100644 --- a/internal/handlers/source_logs_deleted_target_test.go +++ b/internal/handlers/source_logs_deleted_target_test.go @@ -28,7 +28,7 @@ func deleteTargetThroughHandler( t.Helper() req := postRequest( - "/source/"+webhookID+"/targets/"+targetID+"/delete", + "/hook/"+webhookID+"/targets/"+targetID+"/delete", authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ), diff --git a/internal/handlers/source_logs_test.go b/internal/handlers/source_logs_test.go index 90f5a60..d0b5b78 100644 --- a/internal/handlers/source_logs_test.go +++ b/internal/handlers/source_logs_test.go @@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery( req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, - "/source/"+webhookID+"/logs"+query, + "/hook/"+webhookID+"/events"+query, nil, ) diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 9147c1f..77a5164 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -330,7 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint( ) 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( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, + w, r, "/hook/"+webhook.ID, http.StatusSeeOther, ) } @@ -671,7 +671,7 @@ func (h *Handlers) deleteWebhookResources( return } - http.Redirect(w, r, "/sources", http.StatusSeeOther) + http.Redirect(w, r, "/hooks", http.StatusSeeOther) } // commitWebhookDeletion soft-deletes a webhook's entrypoints, @@ -1264,7 +1264,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { } 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. // 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 // the request line, unlike the body, is what logs, proxies, // Referer headers and error trackers record. @@ -1377,7 +1377,7 @@ func (h *Handlers) processTargetCreate( } http.Redirect( - w, r, "/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 // 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 // request line, unlike the body, is what logs, proxies, Referer // headers and error trackers record. The headers field is under the @@ -1714,7 +1714,7 @@ func (h *Handlers) deleteChildResource( http.Redirect( w, r, - "/source/"+webhook.ID, + "/hook/"+webhook.ID, http.StatusSeeOther, ) } @@ -1811,7 +1811,7 @@ func (h *Handlers) toggleChildResource( http.Redirect( w, r, - "/source/"+webhook.ID, + "/hook/"+webhook.ID, http.StatusSeeOther, ) } diff --git a/internal/handlers/source_management_test.go b/internal/handlers/source_management_test.go index 508af5d..9967a85 100644 --- a/internal/handlers/source_management_test.go +++ b/internal/handlers/source_management_test.go @@ -105,7 +105,7 @@ func submitCreate( form.Set("retention_days", *retention) } - req := formRequest("/sources/new", cookies, form, nil) + req := formRequest("/hooks/new", cookies, form, nil) w := httptest.NewRecorder() h.HandleSourceCreateSubmit().ServeHTTP(w, req) @@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) { w := httptest.NewRecorder() env.handlers.HandleSourceCreate().ServeHTTP( - w, getRequest(t, "/sources/new", env.cookies, nil), + w, getRequest(t, "/hooks/new", env.cookies, nil), ) require.Equal(t, http.StatusOK, w.Code) @@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput( form.Set("description", description) form.Set("retention_days", "nonsense") - req := formRequest("/sources/new", env.cookies, form, nil) + req := formRequest("/hooks/new", env.cookies, form, nil) w := httptest.NewRecorder() env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req) @@ -430,7 +430,7 @@ func submitEdit( form.Set("retention_days", retention) req := formRequest( - "/source/"+wh.ID+"/edit", + "/hook/"+wh.ID+"/edit", env.cookies, form, map[string]string{sourceIDParam: wh.ID}, @@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) { ) req := getRequest( - t, "/source/"+wh.ID+"/edit", env.cookies, + t, "/hook/"+wh.ID+"/edit", env.cookies, map[string]string{sourceIDParam: wh.ID}, ) w := httptest.NewRecorder() @@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( listW := httptest.NewRecorder() env.handlers.HandleSourceList().ServeHTTP( - listW, getRequest(t, "/sources", env.cookies, nil), + listW, getRequest(t, "/hooks", env.cookies, nil), ) require.Equal(t, http.StatusOK, listW.Code) @@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( env.handlers.HandleSourceDetail().ServeHTTP( detailW, getRequest( - t, "/source/"+wh.ID, env.cookies, + t, "/hook/"+wh.ID, env.cookies, map[string]string{sourceIDParam: wh.ID}, ), ) diff --git a/internal/handlers/target_create_query_test.go b/internal/handlers/target_create_query_test.go index 84b0b5e..d8c542c 100644 --- a/internal/handlers/target_create_query_test.go +++ b/internal/handlers/target_create_query_test.go @@ -76,11 +76,11 @@ func postTargetCreate( router := chi.NewRouter() router.Use(mw.Logging()) router.Post( - "/source/{sourceID}/targets", + "/hook/{sourceID}/targets", env.handlers.HandleTargetCreate(), ) - target := "/source/" + webhookID + "/targets" + target := "/hook/" + webhookID + "/targets" if query != "" { target += "?" + query } @@ -114,7 +114,7 @@ func postTargetCreate( // regression test for the ingress leak. r.FormValue falls back to the // query string when a field is absent from the POST body, so // -// POST /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 // carried on the request line — where logs, proxies, Referer headers diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index 8ff03b5..89a4ca1 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -47,7 +47,7 @@ type targetEditView struct { // // This page is the one place the full destination URL and header // values are shown. It is reachable only through the -// /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 // owns; masking (delivery.TargetView) is unchanged everywhere else. func (h *Handlers) HandleTargetEdit() http.HandlerFunc { @@ -163,7 +163,7 @@ func (h *Handlers) applyTargetEdit( } http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, + w, r, "/hook/"+webhook.ID, http.StatusSeeOther, ) } diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 64a93e4..40eed2c 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -42,15 +42,15 @@ const ( func targetRouter(env *sourceTestEnv) *chi.Mux { router := chi.NewRouter() router.Post( - "/source/{sourceID}/targets", + "/hook/{sourceID}/targets", env.handlers.HandleTargetCreate(), ) router.Get( - "/source/{sourceID}/targets/{targetID}/edit", + "/hook/{sourceID}/targets/{targetID}/edit", env.handlers.HandleTargetEdit(), ) router.Post( - "/source/{sourceID}/targets/{targetID}/edit", + "/hook/{sourceID}/targets/{targetID}/edit", env.handlers.HandleTargetEditSubmit(), ) @@ -117,7 +117,7 @@ func seedHTTPTarget( w := serveTarget( env, http.MethodPost, - "/source/"+webhook.ID+"/targets", form, + "/hook/"+webhook.ID+"/targets", form, ) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) @@ -188,7 +188,7 @@ func submitTargetEdit( ) *httptest.ResponseRecorder { return serveTarget( env, http.MethodPost, - "/source/"+webhookID+"/targets/"+targetID+"/edit", + "/hook/"+webhookID+"/targets/"+targetID+"/edit", form, ) } @@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked( w := serveTarget( env, http.MethodGet, - "/source/"+webhook.ID+"/targets/"+target.ID+"/edit", + "/hook/"+webhook.ID+"/targets/"+target.ID+"/edit", nil, ) require.Equal(t, http.StatusOK, w.Code) @@ -508,7 +508,7 @@ func assertEditIgnoresQueryString( w := serveTarget( env, http.MethodPost, - "/source/"+webhook.ID+"/targets/"+target.ID+ + "/hook/"+webhook.ID+"/targets/"+target.ID+ "/edit?url="+url.QueryEscape(editReplacedURL)+ "&headers="+url.QueryEscape(editAuthHeader), form, @@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s( get := serveTarget( env, http.MethodGet, - "/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil, + "/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil, ) assert.Equal(t, http.StatusNotFound, get.Code) @@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s( w := serveTarget( env, http.MethodGet, - "/source/"+other.ID+"/targets/"+target.ID+"/edit", nil, + "/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil, ) assert.Equal(t, http.StatusNotFound, w.Code) diff --git a/internal/handlers/target_retries_test.go b/internal/handlers/target_retries_test.go index 51840c5..db8f539 100644 --- a/internal/handlers/target_retries_test.go +++ b/internal/handlers/target_retries_test.go @@ -102,7 +102,7 @@ func createWithRetries( w := serveTarget( env, http.MethodPost, - "/source/"+webhook.ID+"/targets", + "/hook/"+webhook.ID+"/targets", createRetriesForm(retries), ) diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 8598de9..ddf4186 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -54,8 +54,7 @@ func renderPage( } // TestNavbarUsesWebhookTerminology pins the user-visible navigation -// label to "Webhooks". The /sources route is deliberately unchanged, so -// the assertion targets the link text rather than the href. +// label to "Webhooks" and its link to the webhook list at /hooks. func TestNavbarUsesWebhookTerminology(t *testing.T) { t.Parallel() @@ -95,15 +94,11 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) { t, body, ">Sources<", "no user-visible element may still be labelled Sources", ) - assert.Contains( - t, body, `href="/sources"`, - "the /sources route itself must not change", - ) + assert.Contains(t, body, `href="/hooks"`) } // TestEditPageUsesWebhookTerminology pins the edit page's heading and -// its back link. The link's href still points at /source/{id}, which is -// intentional: only user-visible copy changes. +// its back link to the webhook page at /hook/{id}. func TestEditPageUsesWebhookTerminology(t *testing.T) { t.Parallel() @@ -130,7 +125,57 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) { assert.Contains(t, body, "Edit Webhook") 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, + `Full Event Log`, + "the button at the top of the webhook page", + ) + assert.Contains( + t, detailBody, + `Full Event Log`, + "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, + `

Full Event Log

`, + ) } // TestCreateFormRetentionCopyMatchesBehaviour pins the create form's @@ -283,7 +328,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) { t, body, `/edit lands here. The + // /hook//edit lands here. The // method and path are capped against the same budgets as // the access log. remote_addr is set by net/http from the // accepted connection rather than by the client, and diff --git a/internal/middleware/logbound_test.go b/internal/middleware/logbound_test.go index 649370c..1deb300 100644 --- a/internal/middleware/logbound_test.go +++ b/internal/middleware/logbound_test.go @@ -383,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) { t, newHandler, ) - path := "/source/" + + path := "/hook/" + oversizedPathSegment(fill) + "/edit" assert.Equal( @@ -434,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) { req := httptest.NewRequestWithContext( context.Background(), http.MethodPost, - "/source/"+ + "/hook/"+ oversizedPathSegment(fill)+"/login", nil, ) @@ -499,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog( http.StatusRequestEntityTooLarge, postOversize( h, - "/source/"+segment(i)+"/edit", + "/hook/"+segment(i)+"/edit", ), ) } diff --git a/internal/middleware/metrics.go b/internal/middleware/metrics.go index 6ac39be..dfb7799 100644 --- a/internal/middleware/metrics.go +++ b/internal/middleware/metrics.go @@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute // // The pattern is what bounds the label's domain to the routes the // service registers. The path does not bound it at all — every byte -// after /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 // the entrypoint UUID (the receiver's only credential) in the scrape // while doing it. diff --git a/internal/middleware/metrics_method_test.go b/internal/middleware/metrics_method_test.go index 3a47cde..7f20d79 100644 --- a/internal/middleware/metrics_method_test.go +++ b/internal/middleware/metrics_method_test.go @@ -50,7 +50,7 @@ func realMethods() []string { // dimension varying, so any series growth a probe produces is the // method label's and nothing else's. func methodProbePath() string { - return "/webhook/" + uuid.NewString() + return "/h/" + uuid.NewString() } // inventedMethods returns n distinct RFC 9110 method tokens that no diff --git a/internal/middleware/metrics_test.go b/internal/middleware/metrics_test.go index d19c30c..2ee798d 100644 --- a/internal/middleware/metrics_test.go +++ b/internal/middleware/metrics_test.go @@ -28,7 +28,7 @@ const ( // receiverRoutePattern is the one handler label every receiver // 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 // interceptor still reports status and size after the handler id @@ -143,13 +143,13 @@ func drivePaths( return drive(t, h, probes) } -// receiverPaths returns n distinct /webhook/ paths, each naming a +// receiverPaths returns n distinct /h/ paths, each naming a // fresh UUID exactly as an unauthenticated flood would. func receiverPaths(n int) []string { paths := make([]string, 0, n) for range n { - paths = append(paths, "/webhook/"+uuid.NewString()) + paths = append(paths, "/h/"+uuid.NewString()) } return paths @@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string { // TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct // assertion the issue asks for: N requests to N distinct -// /webhook/ paths must produce exactly ONE handler label, the +// /h/ paths must produce exactly ONE handler label, the // route pattern. Before the fix this produced N of them. func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) { t.Parallel() @@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) { // The scrape must not republish the UUIDs it was driven with. // They are the receiver's only credential. for _, p := range paths { - id := strings.TrimPrefix(p, "/webhook/") + id := strings.TrimPrefix(p, "/h/") for label := range labels { assert.NotContains( t, label, id, @@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) { if i%2 == 0 { paths = append(paths, "/"+id) } else { - paths = append(paths, "/webhook/"+id+"/"+id) + paths = append(paths, "/h/"+id+"/"+id) } } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 98140fb..dd2dd75 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -257,7 +257,7 @@ func concreteLogURL(r *http.Request) string { // // 3xx and 4xx responses get the chi route pattern instead. Those are // the outcomes an unauthenticated client drives for free: 404 or 429 -// on any invented /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 // write attacker-chosen text, of attacker-chosen length, into the // operator's log at one line per request. The pattern comes from the @@ -560,7 +560,7 @@ func (s *Middleware) MaxBodySize( // internal/server/routes.go), so an // unauthenticated client reaches it with a path // of its own choosing and its own length — - // POST /source/<8 KB>/edit with an oversize + // POST /hook/<8 KB>/edit with an oversize // declared Content-Length costs nothing to // send. At WARN, on by default, that is a // write into the operator's log sized by the diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 097162d..0902b3b 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) { req := httptest.NewRequestWithContext( context.Background(), - http.MethodGet, "/sources", nil, + http.MethodGet, "/hooks", nil, ) w := httptest.NewRecorder() diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index dc602ad..3f65b9d 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -63,7 +63,7 @@ const ( // receiverAggregateMultiplier scales the configured // per-entrypoint receiver limit into the aggregate limit one - // client IP may spend across the whole /webhook/* route. Ten + // client IP may spend across the whole /h/* route. Ten // entrypoints' worth lets a single sender address drive several // entrypoints at their full rate, while still capping what one // address costs the unauthenticated receiver. @@ -390,7 +390,7 @@ func (m *Middleware) postRateLimit( // It is Config.ReceiverRateLimit requests per minute. // // That limit alone bounds nothing in aggregate. The route pattern -// /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 // and never refills one — and every such request still reaches the // handler's entrypoint lookup before it 404s. The outer limit is diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index 678354f..da1bb38 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) { // pass. for i := range limit { w := receiverPost( - handler, "9.9.9.9:1234", "/webhook/uuid-a", + handler, "9.9.9.9:1234", "/h/uuid-a", ) assert.Equal( t, http.StatusOK, w.Code, @@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) { // The next request over the limit is rejected with a 429 // carrying a Retry-After header. w := receiverPost( - handler, "9.9.9.9:1234", "/webhook/uuid-a", + handler, "9.9.9.9:1234", "/h/uuid-a", ) assert.Equal(t, http.StatusTooManyRequests, w.Code) assert.NotEmpty( @@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) { // The same IP is not limited on a different entrypoint. w = receiverPost( - handler, "9.9.9.9:1234", "/webhook/uuid-b", + handler, "9.9.9.9:1234", "/h/uuid-b", ) assert.Equal( t, http.StatusOK, w.Code, @@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) { // A different IP is not limited on the same entrypoint. w = receiverPost( - handler, "8.8.8.8:1234", "/webhook/uuid-a", + handler, "8.8.8.8:1234", "/h/uuid-a", ) assert.Equal( t, http.StatusOK, w.Code, @@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) { const ( limit = 2 ip = "7.7.7.7:1234" - path = "/webhook/uuid-c" + path = "/h/uuid-c" ) handler := receiverLimitedHandler(t, limit) @@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths( // none of them shares a per-entrypoint bucket with another. for i := range aggregate { w := receiverPost( - handler, ip, fmt.Sprintf("/webhook/invented-%d", i), + handler, ip, fmt.Sprintf("/h/invented-%d", i), ) assert.Equal( t, http.StatusOK, w.Code, @@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths( } w := receiverPost( - handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate), + handler, ip, fmt.Sprintf("/h/invented-%d", aggregate), ) assert.Equal( t, http.StatusTooManyRequests, w.Code, "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 // 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( t, http.StatusOK, w.Code, "a different client IP must not be affected", @@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate( // limit requests are served; the rest are rejected by the // per-entrypoint limiter but still count against the aggregate. for i := range aggregate { - w := receiverPost(handler, ip, "/webhook/exhausted") + w := receiverPost(handler, ip, "/h/exhausted") want := http.StatusTooManyRequests 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( t, http.StatusTooManyRequests, w.Code, "requests rejected per entrypoint must still count "+ @@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer( const ( limit = 3 peer = "203.0.113.10:44444" - path = "/webhook/uuid-d" + path = "/h/uuid-d" ) handler := receiverLimitedHandler(t, limit) diff --git a/internal/server/routes.go b/internal/server/routes.go index 7f3699a..34c39a4 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -182,7 +182,7 @@ func (s *Server) setupUserRoutes() { } 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; // see maxFormBodySize for why, and for what it costs. r.Use(s.mw.MaxBodySize(maxFormBodySize)) @@ -194,7 +194,7 @@ func (s *Server) setupSourceRoutes() { r.Post("/new", s.h.HandleSourceCreateSubmit()) }) - s.router.Route("/source/{sourceID}", func(r chi.Router) { + s.router.Route("/hook/{sourceID}", func(r chi.Router) { // MaxBodySize precedes CSRF and RequireAuth deliberately; // see maxFormBodySize for why, and for what it costs. r.Use(s.mw.MaxBodySize(maxFormBodySize)) @@ -205,14 +205,14 @@ func (s *Server) setupSourceRoutes() { r.Get("/edit", s.h.HandleSourceEdit()) r.Post("/edit", s.h.HandleSourceEditSubmit()) r.Post("/delete", s.h.HandleSourceDelete()) - r.Get("/logs", s.h.HandleSourceLogs()) + r.Get("/events", s.h.HandleSourceLogs()) // The log page renders each body only up to its cap, so // this is the only route that serves a whole one. It // belongs to this group for its RequireAuth and // NoCache; see HandleEventBodyDownload for the headers // that keep the bytes it returns inert. r.Get( - "/logs/{eventID}/body", + "/events/{eventID}/body", s.h.HandleEventBodyDownload(), ) // Replay is the one page action that queues outbound work: @@ -279,7 +279,7 @@ func (s *Server) setupSourceRoutes() { func (s *Server) setupWebhookRoutes() { s.router.With(s.mw.ReceiverRateLimit()).HandleFunc( - "/webhook/{uuid}", + "/h/{uuid}", s.h.HandleWebhook(), ) } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index a06272f..a41c4ec 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -674,7 +674,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) { require.NotNil(t, fresh, "login must set a session cookie") assert.Equal( - t, "/sources", + t, "/hooks", env.get("/", []*http.Cookie{fresh}).Header().Get("Location"), "the new session cookie must authenticate", ) @@ -741,7 +741,7 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) { ) } -// --- /source/{sourceID} group --- +// --- /hook/{sourceID} group --- // TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole // feature the way a user does: render the event log page through @@ -769,11 +769,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) { wh := env.seedWebhook(t, userID) env.seedEvent(t, wh.ID, stored) - page := env.get("/source/"+wh.ID+"/logs", cookies) + page := env.get("/hook/"+wh.ID+"/events", cookies) require.Equal(t, http.StatusOK, page.Code) link := regexp.MustCompile( - `href="(/source/[^"]+/body)"`, + `href="(/hook/[^"]+/body)"`, ).FindStringSubmatch(page.Body.String()) require.Len( t, link, 2, @@ -819,7 +819,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) { const payload = "OWNERS-PAYLOAD-77c1" evt := env.seedEvent(t, wh.ID, payload) - path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body" + path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body" intruderID, _ := env.seedUser(t, "intruder", "somepassword") intruder := env.authCookies(t, intruderID, "intruder") @@ -853,7 +853,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) { evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`) dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID) - path := "/source/" + wh.ID + "/deliveries/" + dlv.ID + + path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID + "/replay" assert.Equal( @@ -879,7 +879,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) { // The token and the action URL both come out of the rendered // page, so a typo in either the route pattern or the template // fails here. - logsPath := "/source/" + wh.ID + "/logs" + logsPath := "/hook/" + wh.ID + "/events" token, cookies := env.csrfFrom(t, logsPath, cookies) @@ -887,7 +887,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) { require.Equal(t, http.StatusOK, page.Code) action := regexp.MustCompile( - `action="(/source/[^"]+/replay)"`, + `action="(/hook/[^"]+/replay)"`, ).FindStringSubmatch(page.Body.String()) require.Len( t, action, 2, @@ -912,6 +912,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/[^<]+)`). + FindStringSubmatch(page.Body.String()) + require.Len( + t, shown, 2, "the webhook page should show the entrypoint URL", + ) + + for i := range limit { + assert.Equal( + t, http.StatusOK, + env.post(shown[1], url.Values{}, nil).Code, + "request %d should reach the receiver", i, + ) + } + + assert.Equal( + t, http.StatusTooManyRequests, + env.post(shown[1], url.Values{}, nil).Code, + "the receiver rate limit must apply to the entrypoint URL", + ) +} + // metricsConfig is a Config differing from the routing default only // in the two /metrics credentials. func metricsConfig( diff --git a/internal/server/sentry.go b/internal/server/sentry.go index a88e7d1..9056246 100644 --- a/internal/server/sentry.go +++ b/internal/server/sentry.go @@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions { // // URL is the third such field. NewRequest builds it as // scheme://host/path (interfaces.go:183), and on the receiver route -// that path is /webhook/ in full — a write capability, not an +// that path is /h/ in full — a write capability, not an // identifier. It is rebuilt here from the chi route pattern, on every // route, keeping the scheme and the host. // diff --git a/internal/server/sentry_test.go b/internal/server/sentry_test.go index cb5f1a6..86e5cb0 100644 --- a/internal/server/sentry_test.go +++ b/internal/server/sentry_test.go @@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler { sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle, ) router.HandleFunc("/pages/login", handler) - router.HandleFunc("/webhook/{uuid}", handler) + router.HandleFunc("/h/{uuid}", handler) return router } @@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request { // concrete path carries the entrypoint capability. func sentryReceiverRequest(client *sentry.Client) *http.Request { return sentryRequest( - client, "/webhook/"+sentryReceiverUUID, "payload=hello", + client, "/h/"+sentryReceiverUUID, "payload=hello", ) } @@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern( t, marshalEvent(t, event), sentryReceiverUUID, ) 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) { 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 // what an event captured outside the router would carry. @@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) { event := sentry.NewEvent() event.Request = &sentry.Request{URL: concrete} - event.Transaction = "POST /webhook/" + + event.Transaction = "POST /h/" + sentryReceiverUUID scrubbed := server.ScrubSentryRequestForTest( @@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) { event := sentry.NewEvent() event.Request = &sentry.Request{ - URL: "/webhook/" + sentryReceiverUUID, + URL: "/h/" + sentryReceiverUUID, } - event.Transaction = "/webhook/" + sentryReceiverUUID + event.Transaction = "/h/" + sentryReceiverUUID scrubbed := server.ScrubSentryRequestForTest(event, nil) require.NotNil(t, scrubbed) diff --git a/templates/navbar.html b/templates/navbar.html index edd5573..3374bd4 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -16,7 +16,7 @@