diff --git a/README.md b/README.md index c1bb19b..1c193c3 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 @@ -1183,7 +1183,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. @@ -1515,7 +1515,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) | @@ -1896,7 +1896,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 │ @@ -2122,7 +2122,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 @@ -2152,7 +2152,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 @@ -2189,7 +2189,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 @@ -2223,12 +2223,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 @@ -2272,8 +2272,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 @@ -2408,7 +2408,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 @@ -2686,10 +2686,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 @@ -2705,25 +2705,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` | Webhook 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 @@ -2923,8 +2923,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 @@ -2948,7 +2948,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). @@ -2989,8 +2989,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 b2ea384..445884e 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"), ) @@ -281,7 +281,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", @@ -435,7 +435,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", ) @@ -481,7 +481,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"), ) @@ -597,7 +597,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 abb78f4..4e7145d 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 = "" @@ -509,7 +509,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize( http.StatusServiceUnavailable, postLoginAtPath( t, h, - "/source/"+url.PathEscape( + "/hook/"+url.PathEscape( oversizedFill(fill), )+"/login", ), 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 fcaf698..9543e63 100644 --- a/internal/handlers/source_detail_test.go +++ b/internal/handlers/source_detail_test.go @@ -65,7 +65,7 @@ func renderSourceDetailPage( 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 4abb7fc..521b48b 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, ) } @@ -581,7 +581,7 @@ func (h *Handlers) applyWebhookEdit( } http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, + w, r, "/hook/"+webhook.ID, http.StatusSeeOther, ) } @@ -664,7 +664,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, @@ -1257,7 +1257,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { } http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, + w, r, "/hook/"+webhook.ID, http.StatusSeeOther, ) } } @@ -1313,7 +1313,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. @@ -1370,7 +1370,7 @@ func (h *Handlers) processTargetCreate( } http.Redirect( - w, r, "/source/"+webhook.ID, http.StatusSeeOther, + w, r, "/hook/"+webhook.ID, http.StatusSeeOther, ) } @@ -1428,7 +1428,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 @@ -1707,7 +1707,7 @@ func (h *Handlers) deleteChildResource( http.Redirect( w, r, - "/source/"+webhook.ID, + "/hook/"+webhook.ID, http.StatusSeeOther, ) } @@ -1804,7 +1804,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..29bdc7f 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,7 @@ 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"`) } // TestCreateFormRetentionCopyMatchesBehaviour pins the create form's @@ -283,7 +278,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 @@