Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2ce961fe5 |
@@ -7,7 +7,7 @@ services, durably stores them, and delivers them to configured targets
|
|||||||
with retry support, logging, and observability. Category: infrastructure
|
with retry support, logging, and observability. Category: infrastructure
|
||||||
/ web service. License: MIT.
|
/ web service. License: MIT.
|
||||||
|
|
||||||
Each entrypoint is a version 4 UUID served at `/webhook/{uuid}`, and
|
Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and
|
||||||
that UUID is the entrypoint's only credential. webhooker does not use
|
that UUID is the entrypoint's only credential. webhooker does not use
|
||||||
shared secrets, HMAC signatures or token headers on the receiver, and
|
shared secrets, HMAC signatures or token headers on the receiver, and
|
||||||
will not add them — read
|
will not add them — read
|
||||||
@@ -1178,7 +1178,7 @@ backups at rest and restrict who can read them.
|
|||||||
|
|
||||||
**The entrypoint UUID is the credential, and it is the only one.**
|
**The entrypoint UUID is the credential, and it is the only one.**
|
||||||
webhooker mints a version 4 UUID per entrypoint and serves it at
|
webhooker mints a version 4 UUID per entrypoint and serves it at
|
||||||
`/webhook/{uuid}`. Possession of that URL is the authentication:
|
`/h/{uuid}`. Possession of that URL is the authentication:
|
||||||
anyone who holds it can submit events to the entrypoint, and the
|
anyone who holds it can submit events to the entrypoint, and the
|
||||||
receiver verifies nothing else about the sender.
|
receiver verifies nothing else about the sender.
|
||||||
|
|
||||||
@@ -1512,7 +1512,7 @@ the full request and creates an Event.
|
|||||||
| -------------- | ------- | ----------- |
|
| -------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
||||||
| `description` | string | Optional description |
|
| `description` | string | Optional description |
|
||||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||||
|
|
||||||
@@ -1893,7 +1893,7 @@ runtime, though CGO is required at build time due to the transitive
|
|||||||
```
|
```
|
||||||
External Service
|
External Service
|
||||||
│
|
│
|
||||||
│ POST /webhook/{uuid}
|
│ POST /h/{uuid}
|
||||||
▼
|
▼
|
||||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ chi Router │────►│ Middleware │────►│ Webhook │
|
│ chi Router │────►│ Middleware │────►│ Webhook │
|
||||||
@@ -2119,7 +2119,7 @@ The middleware records three more on the same registry:
|
|||||||
Two of those labels are written once per request from bytes the client
|
Two of those labels are written once per request from bytes the client
|
||||||
chose, so both are bounded to something this service registers:
|
chose, so both are bounded to something this service registers:
|
||||||
|
|
||||||
- `handler` is the chi route pattern — `/webhook/{uuid}`, never the
|
- `handler` is the chi route pattern — `/h/{uuid}`, never the
|
||||||
concrete path. A request matching no route carries `(unmatched)`,
|
concrete path. A request matching no route carries `(unmatched)`,
|
||||||
and no entrypoint UUID ever reaches a label.
|
and no entrypoint UUID ever reaches a label.
|
||||||
- `method` is the request method when the router can route it, and
|
- `method` is the request method when the router can route it, and
|
||||||
@@ -2149,7 +2149,7 @@ unpredictable rates, and blanket limits shared with other routes would
|
|||||||
cause legitimate deliveries to be dropped.
|
cause legitimate deliveries to be dropped.
|
||||||
|
|
||||||
The receiver instead has its own dedicated abuse limit, scoped to the
|
The receiver instead has its own dedicated abuse limit, scoped to the
|
||||||
`/webhook/{uuid}` route only and keyed per client IP per request path
|
`/h/{uuid}` route only and keyed per client IP per request path
|
||||||
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
||||||
affecting other senders of the same entrypoint or the same sender's
|
affecting other senders of the same entrypoint or the same sender's
|
||||||
other entrypoints. Keying on the path rather than on the entrypoint
|
other entrypoints. Keying on the path rather than on the entrypoint
|
||||||
@@ -2186,7 +2186,7 @@ log spends. The access log is bounded by neither limit: every request
|
|||||||
is recorded once at `INFO`, served or rejected alike.
|
is recorded once at `INFO`, served or rejected alike.
|
||||||
|
|
||||||
What the access log does bound is the _content_ of those lines. A 3xx
|
What the access log does bound is the _content_ of those lines. A 3xx
|
||||||
or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
|
or 4xx response logs the chi route pattern — `/h/{uuid}`,
|
||||||
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
||||||
no route at all — in place of the concrete URL. Those are the outcomes
|
no route at all — in place of the concrete URL. Those are the outcomes
|
||||||
an unauthenticated client can drive for free: 404 and 429 on any
|
an unauthenticated client can drive for free: 404 and 429 on any
|
||||||
@@ -2220,12 +2220,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
|||||||
|
|
||||||
The same hook rewrites the request URL. The SDK builds it as
|
The same hook rewrites the request URL. The SDK builds it as
|
||||||
`scheme://host/path` from the concrete path, which on the receiver
|
`scheme://host/path` from the concrete path, which on the receiver
|
||||||
route is `/webhook/<uuid>` in full — and that UUID is a write
|
route is `/h/<uuid>` in full — and that UUID is a write
|
||||||
capability, not an identifier: anyone holding it can post events this
|
capability, not an identifier: anyone holding it can post events this
|
||||||
service accepts and its targets then deliver. A tracker has its own
|
service accepts and its targets then deliver. A tracker has its own
|
||||||
retention, access control and deletion policy, so the rule the access
|
retention, access control and deletion policy, so the rule the access
|
||||||
log follows above does not carry across that boundary. What is sent is
|
log follows above does not carry across that boundary. What is sent is
|
||||||
the chi route pattern instead: `http://host/webhook/{uuid}`.
|
the chi route pattern instead: `http://host/h/{uuid}`.
|
||||||
|
|
||||||
The scheme and the host are kept, and everything else in the URL is
|
The scheme and the host are kept, and everything else in the URL is
|
||||||
discarded rather than edited, so a future SDK version that starts
|
discarded rather than edited, so a future SDK version that starts
|
||||||
@@ -2269,8 +2269,8 @@ fallback is never the concrete path. The path becomes the literal
|
|||||||
rewrite cannot parse into a scheme is withheld whole. A transaction
|
rewrite cannot parse into a scheme is withheld whole. A transaction
|
||||||
event additionally carries the SDK's own `METHOD /path` name, built
|
event additionally carries the SDK's own `METHOD /path` name, built
|
||||||
from the concrete path as well; it is rewritten on the same terms, to
|
from the concrete path as well; it is rewritten on the same terms, to
|
||||||
`POST /webhook/{uuid}` where the pattern is known and `POST
|
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)`
|
||||||
/(redacted)` where it is not.
|
where it is not.
|
||||||
|
|
||||||
The headers are an allowlist for the same reason the rules above are
|
The headers are an allowlist for the same reason the rules above are
|
||||||
unconditional: the SDK's own filter removes four names and passes
|
unconditional: the SDK's own filter removes four names and passes
|
||||||
@@ -2405,7 +2405,7 @@ logger printed the fully interpolated SQL — parameters and all — to
|
|||||||
standard output on every statement that returned an error, including a
|
standard output on every statement that returned an error, including a
|
||||||
plain record-not-found, at a level no operator setting reached. Two of
|
plain record-not-found, at a level no operator setting reached. Two of
|
||||||
this service's lookups miss by design on unauthenticated routes: the
|
this service's lookups miss by design on unauthenticated routes: the
|
||||||
entrypoint lookup behind `/webhook/{uuid}` and the user lookup behind
|
entrypoint lookup behind `/h/{uuid}` and the user lookup behind
|
||||||
the login form, whose path segment and submitted username the client
|
the login form, whose path segment and submitted username the client
|
||||||
picks outright. Every
|
picks outright. Every
|
||||||
`gorm.Open` in the service now installs the adapter in
|
`gorm.Open` in the service now installs the adapter in
|
||||||
@@ -2683,10 +2683,10 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------------------- | ----------- |
|
| ------ | --------------------------- | ----------- |
|
||||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
|
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||||
| `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||||
|
|
||||||
#### Authentication Endpoints
|
#### Authentication Endpoints
|
||||||
|
|
||||||
@@ -2702,25 +2702,25 @@ abuse limit later; they are tracked as future work.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/sources` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/sources/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/sources/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
| `GET` | `/source/{id}` | Webhook detail view |
|
| `GET` | `/hook/{id}` | Webhook detail view |
|
||||||
| `GET` | `/source/{id}/edit` | Edit webhook form |
|
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||||
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/source/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||||
| `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
||||||
| `POST` | `/source/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/source/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||||
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||||
| `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
|
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||||
|
|
||||||
#### Infrastructure Endpoints
|
#### Infrastructure Endpoints
|
||||||
|
|
||||||
@@ -2921,8 +2921,8 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -2946,7 +2946,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
|
|||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
`/user/{username}/password` and **ReceiverRateLimit** on
|
`/user/{username}/password` and **ReceiverRateLimit** on
|
||||||
`/webhook/{uuid}`. There is deliberately none on `/pages/login` — that
|
`/h/{uuid}`. There is deliberately none on `/pages/login` — that
|
||||||
endpoint counts failures inside the handler, after the credential
|
endpoint counts failures inside the handler, after the credential
|
||||||
check, see [The login endpoint](#the-login-endpoint).
|
check, see [The login endpoint](#the-login-endpoint).
|
||||||
|
|
||||||
@@ -2987,8 +2987,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
by middleware that runs before CSRF parses the form
|
by middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
on all state-changing forms (cookie-based double-submit tokens with
|
on all state-changing forms (cookie-based double-submit tokens with
|
||||||
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||||
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
|
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||||
`/api` (stateless API). The middleware detects TLS per-request through
|
`/api` (stateless API). The middleware detects TLS per-request through
|
||||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||||
to set appropriate cookie security flags and Origin/Referer validation
|
to set appropriate cookie security flags and Origin/Referer validation
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
@@ -178,10 +177,6 @@ func newApp() *fx.App {
|
|||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
// The registry /metrics serves, and the delivery
|
|
||||||
// collectors registered on it.
|
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
// The one SSRF guard both target-creation validation
|
// The one SSRF guard both target-creation validation
|
||||||
// and the delivery dialer consult, so they cannot
|
// and the delivery dialer consult, so they cannot
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ type EngineParams struct {
|
|||||||
DBManager *database.WebhookDBManager
|
DBManager *database.WebhookDBManager
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
SSRFGuard *Guard
|
SSRFGuard *Guard
|
||||||
Metrics *metrics.Set
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine processes queued deliveries in the background
|
// Engine processes queued deliveries in the background
|
||||||
@@ -168,10 +167,10 @@ type Engine struct {
|
|||||||
retryCh chan Task
|
retryCh chan Task
|
||||||
workers int
|
workers int
|
||||||
|
|
||||||
// mtr is the delivery metric set. Production wires the one
|
// mtr is the delivery metric set. Production wires the
|
||||||
// registered on the registry /metrics serves; a test can
|
// process-wide one; a test can substitute a set registered on
|
||||||
// substitute a set registered on a registry it holds, so it can
|
// a private registry so its assertions are not disturbed by
|
||||||
// gather what its own deliveries recorded.
|
// deliveries other tests are making at the same time.
|
||||||
mtr *metrics.Set
|
mtr *metrics.Set
|
||||||
|
|
||||||
// targets maps each target type to its implementation.
|
// targets maps each target type to its implementation.
|
||||||
@@ -205,7 +204,7 @@ func New(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: defaultWorkers,
|
workers: defaultWorkers,
|
||||||
mtr: params.Metrics,
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
|
|
||||||
e.initTargets(&http.Client{
|
e.initTargets(&http.Client{
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
@@ -390,7 +389,7 @@ func NewTestEngine(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -405,7 +404,7 @@ func NewTestEngineSmallRetry(
|
|||||||
e := &Engine{
|
e := &Engine{
|
||||||
log: log,
|
log: log,
|
||||||
retryCh: make(chan Task, 1),
|
retryCh: make(chan Task, 1),
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(nil)
|
e.initTargets(nil)
|
||||||
|
|
||||||
@@ -428,7 +427,7 @@ func NewTestEngineWithDB(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: workers,
|
workers: workers,
|
||||||
mtr: metrics.New(prometheus.NewRegistry()),
|
mtr: metrics.Default(),
|
||||||
}
|
}
|
||||||
e.initTargets(client)
|
e.initTargets(client)
|
||||||
|
|
||||||
@@ -436,7 +435,8 @@ func NewTestEngineWithDB(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
||||||
// assert on collectors registered on a registry it holds.
|
// assert on collectors registered on a private registry instead of
|
||||||
|
// the process-wide ones every other test is also moving.
|
||||||
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
||||||
e.mtr = mtr
|
e.mtr = mtr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// mIsolate gives the setup's engine a metric set registered on a
|
// mIsolate gives the setup's engine a metric set registered on a
|
||||||
// registry this test holds, so its exact assertions can gather from
|
// private registry. The process-wide collectors are moved by every
|
||||||
// it.
|
// other delivery test running in parallel, so exact assertions are
|
||||||
|
// only possible against a registry this test owns.
|
||||||
func mIsolate(
|
func mIsolate(
|
||||||
t *testing.T, s iSetup,
|
t *testing.T, s iSetup,
|
||||||
) *prometheus.Registry {
|
) *prometheus.Registry {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// SQL — parameters and all — for every statement that returns an
|
// SQL — parameters and all — for every statement that returns an
|
||||||
// error, including gorm.ErrRecordNotFound. Two of this service's
|
// error, including gorm.ErrRecordNotFound. Two of this service's
|
||||||
// lookups miss by design on unauthenticated routes: the entrypoint
|
// lookups miss by design on unauthenticated routes: the entrypoint
|
||||||
// lookup on /webhook/{uuid}, whose path segment the client picks
|
// lookup on /h/{uuid}, whose path segment the client picks
|
||||||
// outright, and the user lookup behind the login form, whose username
|
// outright, and the user lookup behind the login form, whose username
|
||||||
// the client picks outright. Under the default logger each of those
|
// the client picks outright. Under the default logger each of those
|
||||||
// misses printed an unbounded, attacker-chosen string, at no level the
|
// misses printed an unbounded, attacker-chosen string, at no level the
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func (h *Handlers) finishReplay(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code replayOutcomeCode,
|
code replayOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/source/" + webhook.ID + "/logs?" +
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
replayOutcomeParam + "=" + string(code)
|
replayOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func postReplay(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/deliveries/"+
|
"/hook/"+webhookID+"/deliveries/"+
|
||||||
deliveryID+"/replay",
|
deliveryID+"/replay",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=target-deleted",
|
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, missing.Code)
|
require.Equal(t, http.StatusSeeOther, missing.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=target-missing",
|
"/hook/"+wh.ID+"/events?replay=target-missing",
|
||||||
missing.Header().Get("Location"),
|
missing.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, first.Code)
|
require.Equal(t, http.StatusSeeOther, first.Code)
|
||||||
require.Equal(
|
require.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=queued",
|
"/hook/"+wh.ID+"/events?replay=queued",
|
||||||
first.Header().Get("Location"),
|
first.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, second.Code)
|
require.Equal(t, http.StatusSeeOther, second.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=in-flight",
|
"/hook/"+wh.ID+"/events?replay=in-flight",
|
||||||
second.Header().Get("Location"),
|
second.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
require.Equal(t, http.StatusSeeOther, pending.Code)
|
require.Equal(t, http.StatusSeeOther, pending.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?replay=not-terminal",
|
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -501,7 +501,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`action="/source/`+wh.ID+`/deliveries/`+
|
`action="/hook/`+wh.ID+`/deliveries/`+
|
||||||
original.ID+`/replay"`,
|
original.ID+`/replay"`,
|
||||||
)
|
)
|
||||||
assert.Contains(t, body, `method="POST"`)
|
assert.Contains(t, body, `method="POST"`)
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ func fetchEventBody(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+url.PathEscape(sourceID)+
|
"/hook/"+url.PathEscape(sourceID)+
|
||||||
"/logs/"+url.PathEscape(eventID)+"/body",
|
"/events/"+url.PathEscape(eventID)+"/body",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -490,7 +490,7 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
|||||||
page := renderSourceLogsPage(t, h, sess, big.ID)
|
page := renderSourceLogsPage(t, h, sess, big.ID)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, page,
|
t, page,
|
||||||
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
|
"/hook/"+big.ID+"/events/"+bigEvt.ID+"/body",
|
||||||
)
|
)
|
||||||
|
|
||||||
small := seedWebhook(t, db)
|
small := seedWebhook(t, db)
|
||||||
@@ -501,6 +501,6 @@ func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
|||||||
page = renderSourceLogsPage(t, h, sess, small.ID)
|
page = renderSourceLogsPage(t, h, sess, small.ID)
|
||||||
assert.NotContains(
|
assert.NotContains(
|
||||||
t, page,
|
t, page,
|
||||||
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
|
"/hook/"+small.ID+"/events/"+smallEvt.ID+"/body",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code resubmitOutcomeCode,
|
code resubmitOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/source/" + webhook.ID + "/logs?" +
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
resubmitOutcomeParam + "=" + string(code)
|
resubmitOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func postResubmit(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
),
|
),
|
||||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"a resubmit must not be refused while an earlier "+
|
"a resubmit must not be refused while an earlier "+
|
||||||
"one is in flight",
|
"one is in flight",
|
||||||
@@ -435,7 +435,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"an inactive target is skipped, not an error",
|
"an inactive target is skipped, not an error",
|
||||||
)
|
)
|
||||||
@@ -481,7 +481,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
||||||
"the log must offer the resubmit action per event",
|
"the log must offer the resubmit action per event",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ func postWebhook(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(), http.MethodPost, "/webhook/x",
|
context.Background(), http.MethodPost, "/h/x",
|
||||||
strings.NewReader("{}"),
|
strings.NewReader("{}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
@@ -62,8 +61,6 @@ type HandlersParams struct {
|
|||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
Evictor delivery.WebhookEvictor
|
Evictor delivery.WebhookEvictor
|
||||||
SSRFGuard *delivery.Guard
|
SSRFGuard *delivery.Guard
|
||||||
Metrics *metrics.Set
|
|
||||||
Registry *prometheus.Registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP handler methods for all application
|
// Handlers provides HTTP handler methods for all application
|
||||||
@@ -125,7 +122,7 @@ func New(
|
|||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.notifier = params.Notifier
|
s.notifier = params.Notifier
|
||||||
s.evictor = params.Evictor
|
s.evictor = params.Evictor
|
||||||
s.mtr = params.Metrics
|
s.mtr = metrics.Default()
|
||||||
s.ssrf = params.SSRFGuard
|
s.ssrf = params.SSRFGuard
|
||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
@@ -110,8 +109,6 @@ func newTestApp(
|
|||||||
func(r *recordingEvictor) delivery.WebhookEvictor {
|
func(r *recordingEvictor) delivery.WebhookEvictor {
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
@@ -179,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "/sources", w2.Header().Get("Location"),
|
t, "/hooks", w2.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleIndex returns a handler for the root path that redirects
|
// HandleIndex returns a handler for the root path that redirects
|
||||||
// based on authentication state: authenticated users go to /sources
|
// based on authentication state: authenticated users go to /hooks
|
||||||
// (the dashboard), unauthenticated users go to the login page.
|
// (the dashboard), unauthenticated users go to the login page.
|
||||||
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
sess, err := s.session.Get(r)
|
sess, err := s.session.Get(r)
|
||||||
if err == nil && s.session.IsAuthenticated(sess) {
|
if err == nil && s.session.IsAuthenticated(sess) {
|
||||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package handlers_test
|
|||||||
// this package reach a value an UNAUTHENTICATED client picks outright
|
// this package reach a value an UNAUTHENTICATED client picks outright
|
||||||
// and of a length it picks outright:
|
// and of a length it picks outright:
|
||||||
//
|
//
|
||||||
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
|
// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose
|
||||||
// path segment matched no stored entrypoint and so is bounded by
|
// path segment matched no stored entrypoint and so is bounded by
|
||||||
// nothing;
|
// nothing;
|
||||||
// - the failed-login DEBUG lines, whose username is a form field.
|
// - the failed-login DEBUG lines, whose username is a form field.
|
||||||
@@ -190,12 +190,12 @@ func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
|
|||||||
// route pattern.
|
// route pattern.
|
||||||
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post("/webhook/{uuid}", h.HandleWebhook())
|
router.Post("/h/{uuid}", h.HandleWebhook())
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReceiver sends one POST at /webhook/<segment>.
|
// postReceiver sends one POST at /h/<segment>.
|
||||||
//
|
//
|
||||||
// RawPath is cleared after parsing so chi routes on the decoded path
|
// RawPath is cleared after parsing so chi routes on the decoded path
|
||||||
// and the handler sees the raw bytes rather than their percent-escaped
|
// and the handler sees the raw bytes rather than their percent-escaped
|
||||||
@@ -210,7 +210,7 @@ func postReceiver(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/webhook/"+url.PathEscape(segment),
|
"/h/"+url.PathEscape(segment),
|
||||||
strings.NewReader(""),
|
strings.NewReader(""),
|
||||||
)
|
)
|
||||||
req.URL.RawPath = ""
|
req.URL.RawPath = ""
|
||||||
@@ -507,7 +507,7 @@ func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
|
|||||||
http.StatusServiceUnavailable,
|
http.StatusServiceUnavailable,
|
||||||
postLoginAtPath(
|
postLoginAtPath(
|
||||||
t, h,
|
t, h,
|
||||||
"/source/"+url.PathEscape(
|
"/hook/"+url.PathEscape(
|
||||||
oversizedFill(fill),
|
oversizedFill(fill),
|
||||||
)+"/login",
|
)+"/login",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleMetrics returns the Prometheus scrape handler for the
|
|
||||||
// registry built by metrics.NewRegistry, which the HTTP, delivery, Go
|
|
||||||
// runtime and process collectors register on. It is what
|
|
||||||
// promhttp.Handler builds for the global default registry, including
|
|
||||||
// the promhttp_metric_handler_* series that count scrapes, pointed at
|
|
||||||
// that registry instead.
|
|
||||||
func (s *Handlers) HandleMetrics() http.HandlerFunc {
|
|
||||||
reg := s.params.Registry
|
|
||||||
|
|
||||||
return promhttp.InstrumentMetricHandler(
|
|
||||||
reg, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
|
|
||||||
).ServeHTTP
|
|
||||||
}
|
|
||||||
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|||||||
)
|
)
|
||||||
assert.Empty(
|
assert.Empty(
|
||||||
t, w.Header().Get("Location"),
|
t, w.Header().Get("Location"),
|
||||||
"a failed deletion must not redirect to /sources",
|
"a failed deletion must not redirect to /hooks",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/sources", w.Header().Get("Location"))
|
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+f.webhook,
|
"/hook/"+f.webhook,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
req.Host = host
|
req.Host = host
|
||||||
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
tc.scheme+"://"+host+"/webhook/"+fixture.path,
|
tc.scheme+"://"+host+"/h/"+fixture.path,
|
||||||
fixture.entrypointURL(
|
fixture.entrypointURL(
|
||||||
t, host, forwardedProto(tc.header),
|
t, host, forwardedProto(tc.header),
|
||||||
),
|
),
|
||||||
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://"+host+"/webhook/"+fixture.path,
|
"https://"+host+"/h/"+fixture.path,
|
||||||
got,
|
got,
|
||||||
"a connection this process terminated with TLS "+
|
"a connection this process terminated with TLS "+
|
||||||
"outranks a header claiming plaintext",
|
"outranks a header claiming plaintext",
|
||||||
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://"+host+"/webhook/"+fixture.path,
|
"https://"+host+"/h/"+fixture.path,
|
||||||
fixture.entrypointURL(
|
fixture.entrypointURL(
|
||||||
t, host, forwardedProto("HTTPS"),
|
t, host, forwardedProto("HTTPS"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func renderSourceDetailPage(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+webhookID,
|
"/hook/"+webhookID,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/targets/"+targetID+"/delete",
|
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+webhookID+"/logs"+query,
|
"/hook/"+webhookID+"/events"+query,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +581,7 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||||
@@ -1257,7 +1257,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1313,7 +1313,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
//
|
//
|
||||||
// Every field here is read with PostFormValue, not FormValue.
|
// Every field here is read with PostFormValue, not FormValue.
|
||||||
// FormValue falls back to the query string, which would let
|
// FormValue falls back to the query string, which would let
|
||||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
// configure a target from a value the request line carries — and
|
// configure a target from a value the request line carries — and
|
||||||
// the request line, unlike the body, is what logs, proxies,
|
// the request line, unlike the body, is what logs, proxies,
|
||||||
// Referer headers and error trackers record.
|
// Referer headers and error trackers record.
|
||||||
@@ -1370,7 +1370,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
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
|
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||||
// falls back to the query string, which would let
|
// falls back to the query string, which would let
|
||||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
// configure a target from a value the request line carries — and the
|
// configure a target from a value the request line carries — and the
|
||||||
// request line, unlike the body, is what logs, proxies, Referer
|
// request line, unlike the body, is what logs, proxies, Referer
|
||||||
// headers and error trackers record. The headers field is under the
|
// headers and error trackers record. The headers field is under the
|
||||||
@@ -1707,7 +1707,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/source/"+webhook.ID,
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1804,7 +1804,7 @@ func (h *Handlers) toggleChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/source/"+webhook.ID,
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func submitCreate(
|
|||||||
form.Set("retention_days", *retention)
|
form.Set("retention_days", *retention)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := formRequest("/sources/new", cookies, form, nil)
|
req := formRequest("/hooks/new", cookies, form, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
h.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||||
@@ -265,7 +265,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) {
|
|||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
env.handlers.HandleSourceCreate().ServeHTTP(
|
env.handlers.HandleSourceCreate().ServeHTTP(
|
||||||
w, getRequest(t, "/sources/new", env.cookies, nil),
|
w, getRequest(t, "/hooks/new", env.cookies, nil),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
@@ -402,7 +402,7 @@ func TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput(
|
|||||||
form.Set("description", description)
|
form.Set("description", description)
|
||||||
form.Set("retention_days", "nonsense")
|
form.Set("retention_days", "nonsense")
|
||||||
|
|
||||||
req := formRequest("/sources/new", env.cookies, form, nil)
|
req := formRequest("/hooks/new", env.cookies, form, nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
env.handlers.HandleSourceCreateSubmit().ServeHTTP(w, req)
|
||||||
@@ -430,7 +430,7 @@ func submitEdit(
|
|||||||
form.Set("retention_days", retention)
|
form.Set("retention_days", retention)
|
||||||
|
|
||||||
req := formRequest(
|
req := formRequest(
|
||||||
"/source/"+wh.ID+"/edit",
|
"/hook/"+wh.ID+"/edit",
|
||||||
env.cookies,
|
env.cookies,
|
||||||
form,
|
form,
|
||||||
map[string]string{sourceIDParam: wh.ID},
|
map[string]string{sourceIDParam: wh.ID},
|
||||||
@@ -512,7 +512,7 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := getRequest(
|
req := getRequest(
|
||||||
t, "/source/"+wh.ID+"/edit", env.cookies,
|
t, "/hook/"+wh.ID+"/edit", env.cookies,
|
||||||
map[string]string{sourceIDParam: wh.ID},
|
map[string]string{sourceIDParam: wh.ID},
|
||||||
)
|
)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -567,7 +567,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
|||||||
|
|
||||||
listW := httptest.NewRecorder()
|
listW := httptest.NewRecorder()
|
||||||
env.handlers.HandleSourceList().ServeHTTP(
|
env.handlers.HandleSourceList().ServeHTTP(
|
||||||
listW, getRequest(t, "/sources", env.cookies, nil),
|
listW, getRequest(t, "/hooks", env.cookies, nil),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, listW.Code)
|
require.Equal(t, http.StatusOK, listW.Code)
|
||||||
@@ -578,7 +578,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber(
|
|||||||
env.handlers.HandleSourceDetail().ServeHTTP(
|
env.handlers.HandleSourceDetail().ServeHTTP(
|
||||||
detailW,
|
detailW,
|
||||||
getRequest(
|
getRequest(
|
||||||
t, "/source/"+wh.ID, env.cookies,
|
t, "/hook/"+wh.ID, env.cookies,
|
||||||
map[string]string{sourceIDParam: wh.ID},
|
map[string]string{sourceIDParam: wh.ID},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,11 +76,11 @@ func postTargetCreate(
|
|||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Use(mw.Logging())
|
router.Use(mw.Logging())
|
||||||
router.Post(
|
router.Post(
|
||||||
"/source/{sourceID}/targets",
|
"/hook/{sourceID}/targets",
|
||||||
env.handlers.HandleTargetCreate(),
|
env.handlers.HandleTargetCreate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
target := "/source/" + webhookID + "/targets"
|
target := "/hook/" + webhookID + "/targets"
|
||||||
if query != "" {
|
if query != "" {
|
||||||
target += "?" + query
|
target += "?" + query
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func postTargetCreate(
|
|||||||
// regression test for the ingress leak. r.FormValue falls back to the
|
// regression test for the ingress leak. r.FormValue falls back to the
|
||||||
// query string when a field is absent from the POST body, so
|
// query string when a field is absent from the POST body, so
|
||||||
//
|
//
|
||||||
// POST /source/{id}/targets?url=https://hooks.slack.com/services/...
|
// POST /hook/{id}/targets?url=https://hooks.slack.com/services/...
|
||||||
//
|
//
|
||||||
// with an empty url field used to create a working target from a value
|
// with an empty url field used to create a working target from a value
|
||||||
// carried on the request line — where logs, proxies, Referer headers
|
// carried on the request line — where logs, proxies, Referer headers
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type targetEditView struct {
|
|||||||
//
|
//
|
||||||
// This page is the one place the full destination URL and header
|
// This page is the one place the full destination URL and header
|
||||||
// values are shown. It is reachable only through the
|
// values are shown. It is reachable only through the
|
||||||
// /source/{sourceID} route group, which supplies RequireAuth and
|
// /hook/{sourceID} route group, which supplies RequireAuth and
|
||||||
// NoCache, and only for a target of a webhook the session's user
|
// NoCache, and only for a target of a webhook the session's user
|
||||||
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
||||||
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
||||||
@@ -163,7 +163,7 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ const (
|
|||||||
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
func targetRouter(env *sourceTestEnv) *chi.Mux {
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post(
|
router.Post(
|
||||||
"/source/{sourceID}/targets",
|
"/hook/{sourceID}/targets",
|
||||||
env.handlers.HandleTargetCreate(),
|
env.handlers.HandleTargetCreate(),
|
||||||
)
|
)
|
||||||
router.Get(
|
router.Get(
|
||||||
"/source/{sourceID}/targets/{targetID}/edit",
|
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||||
env.handlers.HandleTargetEdit(),
|
env.handlers.HandleTargetEdit(),
|
||||||
)
|
)
|
||||||
router.Post(
|
router.Post(
|
||||||
"/source/{sourceID}/targets/{targetID}/edit",
|
"/hook/{sourceID}/targets/{targetID}/edit",
|
||||||
env.handlers.HandleTargetEditSubmit(),
|
env.handlers.HandleTargetEditSubmit(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func seedHTTPTarget(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhook.ID+"/targets", form,
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ func submitTargetEdit(
|
|||||||
) *httptest.ResponseRecorder {
|
) *httptest.ResponseRecorder {
|
||||||
return serveTarget(
|
return serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhookID+"/targets/"+targetID+"/edit",
|
"/hook/"+webhookID+"/targets/"+targetID+"/edit",
|
||||||
form,
|
form,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/source/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
@@ -508,7 +508,7 @@ func assertEditIgnoresQueryString(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhook.ID+"/targets/"+target.ID+
|
"/hook/"+webhook.ID+"/targets/"+target.ID+
|
||||||
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
"/edit?url="+url.QueryEscape(editReplacedURL)+
|
||||||
"&headers="+url.QueryEscape(editAuthHeader),
|
"&headers="+url.QueryEscape(editAuthHeader),
|
||||||
form,
|
form,
|
||||||
@@ -592,7 +592,7 @@ func assertTargetOfAnotherWebhook404s(
|
|||||||
|
|
||||||
get := serveTarget(
|
get := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/source/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
"/hook/"+mine.ID+"/targets/"+target.ID+"/edit", nil,
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusNotFound, get.Code)
|
assert.Equal(t, http.StatusNotFound, get.Code)
|
||||||
|
|
||||||
@@ -630,7 +630,7 @@ func assertWebhookOfAnotherUser404s(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodGet,
|
env, http.MethodGet,
|
||||||
"/source/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
"/hook/"+other.ID+"/targets/"+target.ID+"/edit", nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func createWithRetries(
|
|||||||
|
|
||||||
w := serveTarget(
|
w := serveTarget(
|
||||||
env, http.MethodPost,
|
env, http.MethodPost,
|
||||||
"/source/"+webhook.ID+"/targets",
|
"/hook/"+webhook.ID+"/targets",
|
||||||
createRetriesForm(retries),
|
createRetriesForm(retries),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ func renderPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
||||||
// label to "Webhooks". The /sources route is deliberately unchanged, so
|
// label to "Webhooks" and its link to the webhook list at /hooks.
|
||||||
// the assertion targets the link text rather than the href.
|
|
||||||
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -95,15 +94,11 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
|||||||
t, body, ">Sources<",
|
t, body, ">Sources<",
|
||||||
"no user-visible element may still be labelled Sources",
|
"no user-visible element may still be labelled Sources",
|
||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(t, body, `href="/hooks"`)
|
||||||
t, body, `href="/sources"`,
|
|
||||||
"the /sources route itself must not change",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
||||||
// its back link. The link's href still points at /source/{id}, which is
|
// its back link to the webhook page at /hook/{id}.
|
||||||
// intentional: only user-visible copy changes.
|
|
||||||
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -130,7 +125,57 @@ func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
|||||||
|
|
||||||
assert.Contains(t, body, "Edit Webhook")
|
assert.Contains(t, body, "Edit Webhook")
|
||||||
assert.NotContains(t, body, ">Sources<")
|
assert.NotContains(t, body, ">Sources<")
|
||||||
assert.Contains(t, body, `href="/source/wh-1"`)
|
assert.Contains(t, body, `href="/hook/wh-1"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventLogPageIsCalledFullEventLog pins the one name the event log
|
||||||
|
// page at /hook/{id}/events goes by: both links to it on the webhook
|
||||||
|
// page, and its own heading, read "Full Event Log".
|
||||||
|
func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// A pointer, as in the handlers: source_detail.html calls
|
||||||
|
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
||||||
|
// over their lists, and a list left out renders as empty, so the
|
||||||
|
// lists are left out.
|
||||||
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||||
|
webhook.ID = testWebhookID
|
||||||
|
|
||||||
|
detailBody := renderPage(
|
||||||
|
t, h, sess, "source_detail.html", map[string]any{
|
||||||
|
dataKeyWebhook: webhook,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, detailBody,
|
||||||
|
`<a href="/hook/wh-1/events" class="btn-secondary">Full Event Log</a>`,
|
||||||
|
"the button at the top of the webhook page",
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, detailBody,
|
||||||
|
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`,
|
||||||
|
"the link under recent events",
|
||||||
|
)
|
||||||
|
|
||||||
|
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||||
|
dataKeyWebhook: webhook,
|
||||||
|
"TotalEvents": int64(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, logBody,
|
||||||
|
`<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
||||||
@@ -283,7 +328,7 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
t, body,
|
t, body,
|
||||||
`<code id="entrypoint-url-ep-1"`,
|
`<code id="entrypoint-url-ep-1"`,
|
||||||
)
|
)
|
||||||
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
|
assert.Contains(t, body, "https://hooks.example.com/h/abc123")
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func (h *Handlers) lookupEntrypoint(
|
|||||||
"path = ?", entrypointUUID,
|
"path = ?", entrypointUUID,
|
||||||
).First(&entrypoint)
|
).First(&entrypoint)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
// The receiver is unauthenticated and /webhook/{uuid}
|
// The receiver is unauthenticated and /h/{uuid}
|
||||||
// matches any single segment, so this value is entirely
|
// matches any single segment, so this value is entirely
|
||||||
// client-chosen on exactly the branch where the lookup
|
// client-chosen on exactly the branch where the lookup
|
||||||
// failed. DEBUG is off by default; the cap is what keeps
|
// failed. DEBUG is off by default; the cap is what keeps
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, s := range []string{
|
for _, s := range []string{
|
||||||
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
|
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)",
|
||||||
} {
|
} {
|
||||||
assert.Equal(t, s, logfield.Truncate(s, budget))
|
assert.Equal(t, s, logfield.Truncate(s, budget))
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-27
@@ -3,18 +3,17 @@
|
|||||||
// deliveries are attempted, how they end, how long they take, how
|
// deliveries are attempted, how they end, how long they take, how
|
||||||
// deep the queues are, and how many circuit breakers are open.
|
// deep the queues are, and how many circuit breakers are open.
|
||||||
//
|
//
|
||||||
// It also builds the registry the authenticated /metrics route
|
// The inbound HTTP metrics come from the go-http-metrics recorder in
|
||||||
// serves. In production, these collectors, the inbound HTTP metrics
|
// internal/middleware and land on prometheus.DefaultRegisterer. These
|
||||||
// recorded in internal/middleware, and the Go runtime and process
|
// collectors register there too, so both surfaces are gathered by the
|
||||||
// collectors all register on that one registry, never on Prometheus's
|
// one promhttp handler mounted on the authenticated /metrics route.
|
||||||
// global default.
|
|
||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
@@ -58,31 +57,25 @@ var knownTargetTypes = []database.TargetType{
|
|||||||
database.TargetTypeSlack,
|
database.TargetTypeSlack,
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry returns the registry /metrics serves, carrying the Go
|
// defaultSet is the process-wide metric set, registered on the same
|
||||||
// runtime and process collectors that Prometheus's global default
|
// registry the HTTP middleware and the /metrics handler already use.
|
||||||
// registry carries, so the go_* and process_* series stay in the
|
// It is built on first use rather than in an init so that a test
|
||||||
// scrape.
|
// binary that never touches metrics never registers them.
|
||||||
//
|
//
|
||||||
// A registry of its own, rather than the global default, is what lets
|
//nolint:gochecknoglobals // one process-wide registration, by design
|
||||||
// two dependency graphs in one process — two tests, say — each
|
var defaultSet = sync.OnceValue(func() *Set {
|
||||||
// register their collectors without the second registration
|
return New(prometheus.DefaultRegisterer)
|
||||||
// panicking.
|
})
|
||||||
func NewRegistry() *prometheus.Registry {
|
|
||||||
reg := prometheus.NewRegistry()
|
|
||||||
reg.MustRegister(
|
|
||||||
collectors.NewGoCollector(),
|
|
||||||
collectors.NewProcessCollector(
|
|
||||||
collectors.ProcessCollectorOpts{},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return reg
|
// Default returns the process-wide metric set.
|
||||||
|
func Default() *Set {
|
||||||
|
return defaultSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set is one registered group of webhooker's delivery collectors.
|
// Set is one registered group of webhooker's delivery collectors.
|
||||||
// Production builds one on the registry /metrics serves; tests build
|
// Production uses the single Default set; tests build their own
|
||||||
// their own against a private registry so assertions are not
|
// against a private registry so assertions are not disturbed by
|
||||||
// disturbed by deliveries other tests are making concurrently.
|
// deliveries other tests are making concurrently.
|
||||||
type Set struct {
|
type Set struct {
|
||||||
eventsReceived prometheus.Counter
|
eventsReceived prometheus.Counter
|
||||||
deliveryAttempts *prometheus.CounterVec
|
deliveryAttempts *prometheus.CounterVec
|
||||||
@@ -100,7 +93,7 @@ type Set struct {
|
|||||||
// New registers a full set of delivery collectors on reg and returns
|
// New registers a full set of delivery collectors on reg and returns
|
||||||
// it. It panics if reg already holds them, which is the intended
|
// it. It panics if reg already holds them, which is the intended
|
||||||
// behaviour for a duplicate registration.
|
// behaviour for a duplicate registration.
|
||||||
func New(reg *prometheus.Registry) *Set {
|
func New(reg prometheus.Registerer) *Set {
|
||||||
factory := promauto.With(reg)
|
factory := promauto.With(reg)
|
||||||
|
|
||||||
s := &Set{
|
s := &Set{
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
|||||||
)
|
)
|
||||||
|
|
||||||
router.HandleFunc(
|
router.HandleFunc(
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Stands in for the real handler: an unknown entrypoint
|
// Stands in for the real handler: an unknown entrypoint
|
||||||
// UUID 404s, a known one succeeds.
|
// UUID 404s, a known one succeeds.
|
||||||
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
|||||||
assertFloodIsBounded(
|
assertFloodIsBounded(
|
||||||
t,
|
t,
|
||||||
func(i int) string {
|
func(i int) string {
|
||||||
return "/webhook/" + attackerMarker +
|
return "/h/" + attackerMarker +
|
||||||
strings.Repeat("x", i) + "?q=" + attackerMarker
|
strings.Repeat("x", i) + "?q=" + attackerMarker
|
||||||
},
|
},
|
||||||
http.StatusNotFound,
|
http.StatusNotFound,
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,10 +346,10 @@ type sizeCase struct {
|
|||||||
func lineSizeCases() map[string]sizeCase {
|
func lineSizeCases() map[string]sizeCase {
|
||||||
cases := map[string]sizeCase{
|
cases := map[string]sizeCase{
|
||||||
"oversized path segment": {
|
"oversized path segment": {
|
||||||
target: "/webhook/" + attackerMarker +
|
target: "/h/" + attackerMarker +
|
||||||
strings.Repeat("x", oversizedSegmentBytes),
|
strings.Repeat("x", oversizedSegmentBytes),
|
||||||
wantStatus: http.StatusNotFound,
|
wantStatus: http.StatusNotFound,
|
||||||
wantURL: "/webhook/{uuid}",
|
wantURL: "/h/{uuid}",
|
||||||
bound: maxLineBytes,
|
bound: maxLineBytes,
|
||||||
},
|
},
|
||||||
// /.well-known/healthcheck answers 200 to anyone and has no
|
// /.well-known/healthcheck answers 200 to anyone and has no
|
||||||
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
|||||||
router := accessLogRouter(m)
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
|
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// The path resolved against a stored entrypoint, so it stays. The
|
// The path resolved against a stored entrypoint, so it stays. The
|
||||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
|
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
|
||||||
assert.NotContains(t, buf.String(), "src=ci")
|
assert.NotContains(t, buf.String(), "src=ci")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
http.StatusNotFound,
|
http.StatusNotFound,
|
||||||
get(t, router, "/webhook/"+attackerMarker),
|
get(t, router, "/h/"+attackerMarker),
|
||||||
)
|
)
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
|||||||
// CSRF is registered ahead of RequireAuth on every route
|
// CSRF is registered ahead of RequireAuth on every route
|
||||||
// group that uses it, so this WARN is reachable by an
|
// group that uses it, so this WARN is reachable by an
|
||||||
// unauthenticated client: a POST with no token to
|
// unauthenticated client: a POST with no token to
|
||||||
// /source/<any length of any text>/edit lands here. The
|
// /hook/<any length of any text>/edit lands here. The
|
||||||
// method and path are capped against the same budgets as
|
// method and path are capped against the same budgets as
|
||||||
// the access log. remote_addr is set by net/http from the
|
// the access log. remote_addr is set by net/http from the
|
||||||
// accepted connection rather than by the client, and
|
// accepted connection rather than by the client, and
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
|
|
||||||
// MetricsMiddlewareForTest builds the metrics recording middleware
|
// MetricsMiddlewareForTest builds the metrics recording middleware
|
||||||
// against a caller-supplied recorder, so a test can gather from its
|
// against a caller-supplied recorder, so a test can gather from its
|
||||||
// own Prometheus registry without building a whole Middleware.
|
// own Prometheus registry rather than the process-wide default one
|
||||||
|
// that Middleware.Metrics uses.
|
||||||
func MetricsMiddlewareForTest(
|
func MetricsMiddlewareForTest(
|
||||||
rec httpmetrics.Recorder,
|
rec httpmetrics.Recorder,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ func TestLogLines_ClientChosenPathDoesNotSizeTheLine(t *testing.T) {
|
|||||||
t, newHandler,
|
t, newHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
path := "/source/" +
|
path := "/hook/" +
|
||||||
oversizedPathSegment(fill) + "/edit"
|
oversizedPathSegment(fill) + "/edit"
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -434,7 +434,7 @@ func TestLoginThrottle_LogLineDoesNotTrackPathSize(t *testing.T) {
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/source/"+
|
"/hook/"+
|
||||||
oversizedPathSegment(fill)+"/login",
|
oversizedPathSegment(fill)+"/login",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
@@ -499,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
|
|||||||
http.StatusRequestEntityTooLarge,
|
http.StatusRequestEntityTooLarge,
|
||||||
postOversize(
|
postOversize(
|
||||||
h,
|
h,
|
||||||
"/source/"+segment(i)+"/edit",
|
"/hook/"+segment(i)+"/edit",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
||||||
|
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
)
|
)
|
||||||
@@ -39,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
|
|||||||
//
|
//
|
||||||
// The pattern is what bounds the label's domain to the routes the
|
// The pattern is what bounds the label's domain to the routes the
|
||||||
// service registers. The path does not bound it at all — every byte
|
// service registers. The path does not bound it at all — every byte
|
||||||
// after /webhook/ is client-chosen, so labelling by path lets any
|
// after /h/ is client-chosen, so labelling by path lets any
|
||||||
// unauthenticated client mint permanent series at will, and publishes
|
// unauthenticated client mint permanent series at will, and publishes
|
||||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||||
// while doing it.
|
// while doing it.
|
||||||
@@ -150,17 +151,17 @@ func (r boundedLabelRecorder) AddInflightRequests(
|
|||||||
|
|
||||||
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
var _ httpmetrics.Recorder = boundedLabelRecorder{}
|
||||||
|
|
||||||
// Metrics returns middleware that records Prometheus HTTP metrics
|
// Metrics returns middleware that records Prometheus HTTP metrics on
|
||||||
// with the Middleware's one recorder, which New builds on the registry
|
// the default registry, which is the one the /metrics route gathers.
|
||||||
// the /metrics route serves and NewForTest on a registry of its own.
|
|
||||||
// Every call reuses that recorder, so any number of routers can
|
|
||||||
// install it.
|
|
||||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
return metricsMiddleware(s.metricsRecorder)
|
return metricsMiddleware(
|
||||||
|
prommetrics.NewRecorder(prommetrics.Config{}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// metricsMiddleware builds the recording middleware against a given
|
// metricsMiddleware builds the recording middleware against a given
|
||||||
// recorder, so tests can gather from a registry of their own.
|
// recorder, so tests can gather from a registry of their own instead
|
||||||
|
// of the process-wide default.
|
||||||
func metricsMiddleware(
|
func metricsMiddleware(
|
||||||
rec httpmetrics.Recorder,
|
rec httpmetrics.Recorder,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func realMethods() []string {
|
|||||||
// dimension varying, so any series growth a probe produces is the
|
// dimension varying, so any series growth a probe produces is the
|
||||||
// method label's and nothing else's.
|
// method label's and nothing else's.
|
||||||
func methodProbePath() string {
|
func methodProbePath() string {
|
||||||
return "/webhook/" + uuid.NewString()
|
return "/h/" + uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
|
|
||||||
// receiverRoutePattern is the one handler label every receiver
|
// receiverRoutePattern is the one handler label every receiver
|
||||||
// request must produce, however the client varies the path.
|
// request must produce, however the client varies the path.
|
||||||
receiverRoutePattern = "/webhook/{uuid}"
|
receiverRoutePattern = "/h/{uuid}"
|
||||||
|
|
||||||
// okRoute is a static route used to pin that the response-writer
|
// okRoute is a static route used to pin that the response-writer
|
||||||
// interceptor still reports status and size after the handler id
|
// interceptor still reports status and size after the handler id
|
||||||
@@ -57,8 +57,9 @@ const (
|
|||||||
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
// Server.setupWebhookRoutes inside it. That ordering is the whole
|
||||||
// defect, so a test that flattens it would prove nothing.
|
// defect, so a test that flattens it would prove nothing.
|
||||||
//
|
//
|
||||||
// The recorder writes to a registry of the test's own, so each test
|
// The recorder writes to a registry of the test's own rather than the
|
||||||
// observes only its own traffic.
|
// process-wide default one, so each test observes only its own
|
||||||
|
// traffic.
|
||||||
func metricsTestRouter(
|
func metricsTestRouter(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
receiverLimit int,
|
receiverLimit int,
|
||||||
@@ -142,13 +143,13 @@ func drivePaths(
|
|||||||
return drive(t, h, probes)
|
return drive(t, h, probes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiverPaths returns n distinct /webhook/ paths, each naming a
|
// receiverPaths returns n distinct /h/ paths, each naming a
|
||||||
// fresh UUID exactly as an unauthenticated flood would.
|
// fresh UUID exactly as an unauthenticated flood would.
|
||||||
func receiverPaths(n int) []string {
|
func receiverPaths(n int) []string {
|
||||||
paths := make([]string, 0, n)
|
paths := make([]string, 0, n)
|
||||||
|
|
||||||
for range n {
|
for range n {
|
||||||
paths = append(paths, "/webhook/"+uuid.NewString())
|
paths = append(paths, "/h/"+uuid.NewString())
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
@@ -219,7 +220,7 @@ func keys(set map[string]struct{}) []string {
|
|||||||
|
|
||||||
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||||
// assertion the issue asks for: N requests to N distinct
|
// assertion the issue asks for: N requests to N distinct
|
||||||
// /webhook/<uuid> paths must produce exactly ONE handler label, the
|
// /h/<uuid> paths must produce exactly ONE handler label, the
|
||||||
// route pattern. Before the fix this produced N of them.
|
// route pattern. Before the fix this produced N of them.
|
||||||
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -249,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
|||||||
// The scrape must not republish the UUIDs it was driven with.
|
// The scrape must not republish the UUIDs it was driven with.
|
||||||
// They are the receiver's only credential.
|
// They are the receiver's only credential.
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
id := strings.TrimPrefix(p, "/webhook/")
|
id := strings.TrimPrefix(p, "/h/")
|
||||||
for label := range labels {
|
for label := range labels {
|
||||||
assert.NotContains(
|
assert.NotContains(
|
||||||
t, label, id,
|
t, label, id,
|
||||||
@@ -353,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
|||||||
if i%2 == 0 {
|
if i%2 == 0 {
|
||||||
paths = append(paths, "/"+id)
|
paths = append(paths, "/"+id)
|
||||||
} else {
|
} else {
|
||||||
paths = append(paths, "/webhook/"+id+"/"+id)
|
paths = append(paths, "/h/"+id+"/"+id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,29 +455,3 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) {
|
|||||||
"the interceptor must still count written bytes",
|
"the interceptor must still count written bytes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built
|
|
||||||
// by NewForTest has a recorder of its own: its Metrics() serves a
|
|
||||||
// request instead of panicking, and a second one does not collide
|
|
||||||
// with the first.
|
|
||||||
func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
log := slog.New(slog.DiscardHandler)
|
|
||||||
cfg := &config.Config{Environment: "prod"}
|
|
||||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(okBody))
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 2 {
|
|
||||||
h := middleware.NewForTest(log, cfg, nil).Metrics()(ok)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, okRoute, nil,
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
httpmetrics "github.com/slok/go-http-metrics/metrics"
|
|
||||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -151,11 +148,10 @@ const (
|
|||||||
type MiddlewareParams struct {
|
type MiddlewareParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Session *session.Session
|
Session *session.Session
|
||||||
Registry *prometheus.Registry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
||||||
@@ -165,14 +161,6 @@ type Middleware struct {
|
|||||||
params *MiddlewareParams
|
params *MiddlewareParams
|
||||||
session *session.Session
|
session *session.Session
|
||||||
|
|
||||||
// metricsRecorder records the inbound HTTP metrics. New builds
|
|
||||||
// it on the registry /metrics serves, NewForTest on a registry
|
|
||||||
// of its own. Either way it is built once per Middleware and
|
|
||||||
// Metrics reuses it, because building it registers its
|
|
||||||
// collectors, and a second registration on the same registry
|
|
||||||
// panics.
|
|
||||||
metricsRecorder httpmetrics.Recorder
|
|
||||||
|
|
||||||
// loginGuard counts failed credential verifications and bounds
|
// loginGuard counts failed credential verifications and bounds
|
||||||
// concurrent password hashing. It is built on first use so that
|
// concurrent password hashing. It is built on first use so that
|
||||||
// every construction path gets one; see guard().
|
// every construction path gets one; see guard().
|
||||||
@@ -191,9 +179,6 @@ func New(
|
|||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
s.session = params.Session
|
s.session = params.Session
|
||||||
s.metricsRecorder = prommetrics.NewRecorder(
|
|
||||||
prommetrics.Config{Registry: params.Registry},
|
|
||||||
)
|
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -272,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
|
|||||||
//
|
//
|
||||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||||
// the outcomes an unauthenticated client drives for free: 404 or 429
|
// the outcomes an unauthenticated client drives for free: 404 or 429
|
||||||
// on any invented /webhook/ path, 303 to the login page on any
|
// on any invented /h/ path, 303 to the login page on any
|
||||||
// invented /user/ path. Logging the concrete URL there lets a flood
|
// invented /user/ path. Logging the concrete URL there lets a flood
|
||||||
// write attacker-chosen text, of attacker-chosen length, into the
|
// write attacker-chosen text, of attacker-chosen length, into the
|
||||||
// operator's log at one line per request. The pattern comes from the
|
// operator's log at one line per request. The pattern comes from the
|
||||||
@@ -575,7 +560,7 @@ func (s *Middleware) MaxBodySize(
|
|||||||
// internal/server/routes.go), so an
|
// internal/server/routes.go), so an
|
||||||
// unauthenticated client reaches it with a path
|
// unauthenticated client reaches it with a path
|
||||||
// of its own choosing and its own length —
|
// of its own choosing and its own length —
|
||||||
// POST /source/<8 KB>/edit with an oversize
|
// POST /hook/<8 KB>/edit with an oversize
|
||||||
// declared Content-Length costs nothing to
|
// declared Content-Length costs nothing to
|
||||||
// send. At WARN, on by default, that is a
|
// send. At WARN, on by default, that is a
|
||||||
// write into the operator's log sized by the
|
// write into the operator's log sized by the
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet, "/sources", nil,
|
http.MethodGet, "/hooks", nil,
|
||||||
)
|
)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const (
|
|||||||
|
|
||||||
// receiverAggregateMultiplier scales the configured
|
// receiverAggregateMultiplier scales the configured
|
||||||
// per-entrypoint receiver limit into the aggregate limit one
|
// per-entrypoint receiver limit into the aggregate limit one
|
||||||
// client IP may spend across the whole /webhook/* route. Ten
|
// client IP may spend across the whole /h/* route. Ten
|
||||||
// entrypoints' worth lets a single sender address drive several
|
// entrypoints' worth lets a single sender address drive several
|
||||||
// entrypoints at their full rate, while still capping what one
|
// entrypoints at their full rate, while still capping what one
|
||||||
// address costs the unauthenticated receiver.
|
// address costs the unauthenticated receiver.
|
||||||
@@ -390,7 +390,7 @@ func (m *Middleware) postRateLimit(
|
|||||||
// It is Config.ReceiverRateLimit requests per minute.
|
// It is Config.ReceiverRateLimit requests per minute.
|
||||||
//
|
//
|
||||||
// That limit alone bounds nothing in aggregate. The route pattern
|
// That limit alone bounds nothing in aggregate. The route pattern
|
||||||
// /webhook/{uuid} matches any single segment, so a client that
|
// /h/{uuid} matches any single segment, so a client that
|
||||||
// invents a fresh path per request mints a fresh bucket per request
|
// invents a fresh path per request mints a fresh bucket per request
|
||||||
// and never refills one — and every such request still reaches the
|
// and never refills one — and every such request still reaches the
|
||||||
// handler's entrypoint lookup before it 404s. The outer limit is
|
// handler's entrypoint lookup before it 404s. The outer limit is
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
// pass.
|
// pass.
|
||||||
for i := range limit {
|
for i := range limit {
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -286,7 +286,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
// The next request over the limit is rejected with a 429
|
// The next request over the limit is rejected with a 429
|
||||||
// carrying a Retry-After header.
|
// carrying a Retry-After header.
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
handler, "9.9.9.9:1234", "/h/uuid-a",
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||||
assert.NotEmpty(
|
assert.NotEmpty(
|
||||||
@@ -296,7 +296,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
|
|
||||||
// The same IP is not limited on a different entrypoint.
|
// The same IP is not limited on a different entrypoint.
|
||||||
w = receiverPost(
|
w = receiverPost(
|
||||||
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
handler, "9.9.9.9:1234", "/h/uuid-b",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -305,7 +305,7 @@ func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|||||||
|
|
||||||
// A different IP is not limited on the same entrypoint.
|
// A different IP is not limited on the same entrypoint.
|
||||||
w = receiverPost(
|
w = receiverPost(
|
||||||
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
handler, "8.8.8.8:1234", "/h/uuid-a",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -322,7 +322,7 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
limit = 2
|
limit = 2
|
||||||
ip = "7.7.7.7:1234"
|
ip = "7.7.7.7:1234"
|
||||||
path = "/webhook/uuid-c"
|
path = "/h/uuid-c"
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := receiverLimitedHandler(t, limit)
|
handler := receiverLimitedHandler(t, limit)
|
||||||
@@ -715,7 +715,7 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
|||||||
// none of them shares a per-entrypoint bucket with another.
|
// none of them shares a per-entrypoint bucket with another.
|
||||||
for i := range aggregate {
|
for i := range aggregate {
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
handler, ip, fmt.Sprintf("/h/invented-%d", i),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
@@ -724,17 +724,17 @@ func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := receiverPost(
|
w := receiverPost(
|
||||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
handler, ip, fmt.Sprintf("/h/invented-%d", aggregate),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusTooManyRequests, w.Code,
|
t, http.StatusTooManyRequests, w.Code,
|
||||||
"a client must not be able to raise its aggregate rate "+
|
"a client must not be able to raise its aggregate rate "+
|
||||||
"against /webhook/* by varying the path",
|
"against /h/* by varying the path",
|
||||||
)
|
)
|
||||||
|
|
||||||
// The aggregate limit is still per client IP: exhausting one
|
// The aggregate limit is still per client IP: exhausting one
|
||||||
// address must not throttle another.
|
// address must not throttle another.
|
||||||
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
|
w = receiverPost(handler, "6.6.6.7:1234", "/h/invented-0")
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, w.Code,
|
t, http.StatusOK, w.Code,
|
||||||
"a different client IP must not be affected",
|
"a different client IP must not be affected",
|
||||||
@@ -771,7 +771,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
|||||||
// limit requests are served; the rest are rejected by the
|
// limit requests are served; the rest are rejected by the
|
||||||
// per-entrypoint limiter but still count against the aggregate.
|
// per-entrypoint limiter but still count against the aggregate.
|
||||||
for i := range aggregate {
|
for i := range aggregate {
|
||||||
w := receiverPost(handler, ip, "/webhook/exhausted")
|
w := receiverPost(handler, ip, "/h/exhausted")
|
||||||
|
|
||||||
want := http.StatusTooManyRequests
|
want := http.StatusTooManyRequests
|
||||||
if i < limit {
|
if i < limit {
|
||||||
@@ -784,7 +784,7 @@ func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
w := receiverPost(handler, ip, "/webhook/never-used")
|
w := receiverPost(handler, ip, "/h/never-used")
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusTooManyRequests, w.Code,
|
t, http.StatusTooManyRequests, w.Code,
|
||||||
"requests rejected per entrypoint must still count "+
|
"requests rejected per entrypoint must still count "+
|
||||||
@@ -823,7 +823,7 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
|||||||
const (
|
const (
|
||||||
limit = 3
|
limit = 3
|
||||||
peer = "203.0.113.10:44444"
|
peer = "203.0.113.10:44444"
|
||||||
path = "/webhook/uuid-d"
|
path = "/h/uuid-d"
|
||||||
)
|
)
|
||||||
|
|
||||||
handler := receiverLimitedHandler(t, limit)
|
handler := receiverLimitedHandler(t, limit)
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
prommetrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewForTest creates a Middleware with the minimum dependencies
|
// NewForTest creates a Middleware with the minimum dependencies
|
||||||
// needed for testing. This bypasses the fx lifecycle.
|
// needed for testing. This bypasses the fx lifecycle.
|
||||||
//
|
|
||||||
// Its metrics recorder writes to a fresh registry of its own, so
|
|
||||||
// Metrics() works on it and two of them never collide.
|
|
||||||
func NewForTest(
|
func NewForTest(
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
@@ -25,8 +20,5 @@ func NewForTest(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
},
|
},
|
||||||
session: sess,
|
session: sess,
|
||||||
metricsRecorder: prommetrics.NewRecorder(
|
|
||||||
prommetrics.Config{Registry: prometheus.NewRegistry()},
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
@@ -164,8 +163,6 @@ func newServerApp(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"sneak.berlin/go/webhooker/static"
|
"sneak.berlin/go/webhooker/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,7 +130,12 @@ func (s *Server) setupRoutes() {
|
|||||||
if s.params.Config.MetricsAuthEnabled() {
|
if s.params.Config.MetricsAuthEnabled() {
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
r.Use(s.mw.MetricsAuth())
|
r.Use(s.mw.MetricsAuth())
|
||||||
r.Get("/metrics", s.h.HandleMetrics())
|
r.Get(
|
||||||
|
"/metrics",
|
||||||
|
http.HandlerFunc(
|
||||||
|
promhttp.Handler().ServeHTTP,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +182,7 @@ func (s *Server) setupUserRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
@@ -188,7 +194,7 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
@@ -199,14 +205,14 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Get("/edit", s.h.HandleSourceEdit())
|
r.Get("/edit", s.h.HandleSourceEdit())
|
||||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
r.Post("/delete", s.h.HandleSourceDelete())
|
r.Post("/delete", s.h.HandleSourceDelete())
|
||||||
r.Get("/logs", s.h.HandleSourceLogs())
|
r.Get("/events", s.h.HandleSourceLogs())
|
||||||
// The log page renders each body only up to its cap, so
|
// The log page renders each body only up to its cap, so
|
||||||
// this is the only route that serves a whole one. It
|
// this is the only route that serves a whole one. It
|
||||||
// belongs to this group for its RequireAuth and
|
// belongs to this group for its RequireAuth and
|
||||||
// NoCache; see HandleEventBodyDownload for the headers
|
// NoCache; see HandleEventBodyDownload for the headers
|
||||||
// that keep the bytes it returns inert.
|
// that keep the bytes it returns inert.
|
||||||
r.Get(
|
r.Get(
|
||||||
"/logs/{eventID}/body",
|
"/events/{eventID}/body",
|
||||||
s.h.HandleEventBodyDownload(),
|
s.h.HandleEventBodyDownload(),
|
||||||
)
|
)
|
||||||
// Replay is the one page action that queues outbound work:
|
// Replay is the one page action that queues outbound work:
|
||||||
@@ -273,7 +279,7 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupWebhookRoutes() {
|
func (s *Server) setupWebhookRoutes() {
|
||||||
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
s.h.HandleWebhook(),
|
s.h.HandleWebhook(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
@@ -114,8 +113,6 @@ func newTestEnvWithConfig(
|
|||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier { return &noopNotifier{} },
|
||||||
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
||||||
metrics.NewRegistry,
|
|
||||||
metrics.New,
|
|
||||||
middleware.New,
|
middleware.New,
|
||||||
delivery.NewGuard,
|
delivery.NewGuard,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
@@ -677,7 +674,7 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
|||||||
|
|
||||||
require.NotNil(t, fresh, "login must set a session cookie")
|
require.NotNil(t, fresh, "login must set a session cookie")
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "/sources",
|
t, "/hooks",
|
||||||
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
env.get("/", []*http.Cookie{fresh}).Header().Get("Location"),
|
||||||
"the new session cookie must authenticate",
|
"the new session cookie must authenticate",
|
||||||
)
|
)
|
||||||
@@ -744,7 +741,7 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /source/{sourceID} group ---
|
// --- /hook/{sourceID} group ---
|
||||||
|
|
||||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||||
// feature the way a user does: render the event log page through
|
// feature the way a user does: render the event log page through
|
||||||
@@ -772,11 +769,11 @@ func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
|||||||
wh := env.seedWebhook(t, userID)
|
wh := env.seedWebhook(t, userID)
|
||||||
env.seedEvent(t, wh.ID, stored)
|
env.seedEvent(t, wh.ID, stored)
|
||||||
|
|
||||||
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
page := env.get("/hook/"+wh.ID+"/events", cookies)
|
||||||
require.Equal(t, http.StatusOK, page.Code)
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
link := regexp.MustCompile(
|
link := regexp.MustCompile(
|
||||||
`href="(/source/[^"]+/body)"`,
|
`href="(/hook/[^"]+/body)"`,
|
||||||
).FindStringSubmatch(page.Body.String())
|
).FindStringSubmatch(page.Body.String())
|
||||||
require.Len(
|
require.Len(
|
||||||
t, link, 2,
|
t, link, 2,
|
||||||
@@ -822,7 +819,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|||||||
const payload = "OWNERS-PAYLOAD-77c1"
|
const payload = "OWNERS-PAYLOAD-77c1"
|
||||||
|
|
||||||
evt := env.seedEvent(t, wh.ID, payload)
|
evt := env.seedEvent(t, wh.ID, payload)
|
||||||
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
path := "/hook/" + wh.ID + "/events/" + evt.ID + "/body"
|
||||||
|
|
||||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
||||||
intruder := env.authCookies(t, intruderID, "intruder")
|
intruder := env.authCookies(t, intruderID, "intruder")
|
||||||
@@ -856,7 +853,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
evt := env.seedEvent(t, wh.ID, `{"replay":"me"}`)
|
||||||
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
|
dlv := env.seedFailedDelivery(t, wh.ID, evt.ID, tgt.ID)
|
||||||
|
|
||||||
path := "/source/" + wh.ID + "/deliveries/" + dlv.ID +
|
path := "/hook/" + wh.ID + "/deliveries/" + dlv.ID +
|
||||||
"/replay"
|
"/replay"
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -882,7 +879,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
// The token and the action URL both come out of the rendered
|
// The token and the action URL both come out of the rendered
|
||||||
// page, so a typo in either the route pattern or the template
|
// page, so a typo in either the route pattern or the template
|
||||||
// fails here.
|
// fails here.
|
||||||
logsPath := "/source/" + wh.ID + "/logs"
|
logsPath := "/hook/" + wh.ID + "/events"
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
token, cookies := env.csrfFrom(t, logsPath, cookies)
|
||||||
|
|
||||||
@@ -890,7 +887,7 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, page.Code)
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
action := regexp.MustCompile(
|
action := regexp.MustCompile(
|
||||||
`action="(/source/[^"]+/replay)"`,
|
`action="(/hook/[^"]+/replay)"`,
|
||||||
).FindStringSubmatch(page.Body.String())
|
).FindStringSubmatch(page.Body.String())
|
||||||
require.Len(
|
require.Len(
|
||||||
t, action, 2,
|
t, action, 2,
|
||||||
@@ -915,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/[^<]+)</code>`).
|
||||||
|
FindStringSubmatch(page.Body.String())
|
||||||
|
require.Len(
|
||||||
|
t, shown, 2, "the webhook page should show the entrypoint URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range limit {
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusOK,
|
||||||
|
env.post(shown[1], url.Values{}, nil).Code,
|
||||||
|
"request %d should reach the receiver", i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusTooManyRequests,
|
||||||
|
env.post(shown[1], url.Values{}, nil).Code,
|
||||||
|
"the receiver rate limit must apply to the entrypoint URL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// metricsConfig is a Config differing from the routing default only
|
// metricsConfig is a Config differing from the routing default only
|
||||||
// in the two /metrics credentials.
|
// in the two /metrics credentials.
|
||||||
func metricsConfig(
|
func metricsConfig(
|
||||||
@@ -1030,46 +1080,3 @@ func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoMetricsRoutersInOneProcess pins
|
|
||||||
// https://git.eeqj.de/sneak/webhooker/issues/227: a second
|
|
||||||
// metrics-enabled router in one process used to panic, because the
|
|
||||||
// HTTP metrics registered on Prometheus's global default registry.
|
|
||||||
// Two routers are built over separate dependency graphs and a third
|
|
||||||
// over the first graph again, and each must still serve the HTTP,
|
|
||||||
// delivery, Go runtime and process series, and the series counting
|
|
||||||
// scrapes of /metrics itself.
|
|
||||||
func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
first := newTestEnvWithConfig(
|
|
||||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
||||||
)
|
|
||||||
second := newTestEnvWithConfig(
|
|
||||||
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
||||||
)
|
|
||||||
third := &testEnv{
|
|
||||||
router: server.NewRouterForTest(
|
|
||||||
first.log.Get(), first.cfg, first.mw, first.hnd,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, env := range []*testEnv{first, second, third} {
|
|
||||||
env.get("/", nil)
|
|
||||||
|
|
||||||
scrape := env.metricsRequest(metricsUser, metricsAuthValue)
|
|
||||||
require.Equal(t, http.StatusOK, scrape.Code)
|
|
||||||
|
|
||||||
for _, series := range []string{
|
|
||||||
"http_request_duration_seconds",
|
|
||||||
"http_response_size_bytes",
|
|
||||||
"http_requests_inflight",
|
|
||||||
"webhooker_events_received_total",
|
|
||||||
"go_goroutines",
|
|
||||||
"process_start_time_seconds",
|
|
||||||
"promhttp_metric_handler_requests_total",
|
|
||||||
} {
|
|
||||||
assert.Contains(t, scrape.Body.String(), series)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
|||||||
//
|
//
|
||||||
// URL is the third such field. NewRequest builds it as
|
// URL is the third such field. NewRequest builds it as
|
||||||
// scheme://host/path (interfaces.go:183), and on the receiver route
|
// scheme://host/path (interfaces.go:183), and on the receiver route
|
||||||
// that path is /webhook/<uuid> in full — a write capability, not an
|
// that path is /h/<uuid> in full — a write capability, not an
|
||||||
// identifier. It is rebuilt here from the chi route pattern, on every
|
// identifier. It is rebuilt here from the chi route pattern, on every
|
||||||
// route, keeping the scheme and the host.
|
// route, keeping the scheme and the host.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
|
|||||||
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
||||||
)
|
)
|
||||||
router.HandleFunc("/pages/login", handler)
|
router.HandleFunc("/pages/login", handler)
|
||||||
router.HandleFunc("/webhook/{uuid}", handler)
|
router.HandleFunc("/h/{uuid}", handler)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
|
|||||||
// concrete path carries the entrypoint capability.
|
// concrete path carries the entrypoint capability.
|
||||||
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
||||||
return sentryRequest(
|
return sentryRequest(
|
||||||
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
|
client, "/h/"+sentryReceiverUUID, "payload=hello",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
|||||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "http://example.com/webhook/{uuid}", event.Request.URL,
|
t, "http://example.com/h/{uuid}", event.Request.URL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
|
|||||||
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
concrete := "https://example.com/webhook/" + sentryReceiverUUID
|
concrete := "https://example.com/h/" + sentryReceiverUUID
|
||||||
|
|
||||||
// A request with no chi routing context on it at all, which is
|
// A request with no chi routing context on it at all, which is
|
||||||
// what an event captured outside the router would carry.
|
// what an event captured outside the router would carry.
|
||||||
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
|||||||
|
|
||||||
event := sentry.NewEvent()
|
event := sentry.NewEvent()
|
||||||
event.Request = &sentry.Request{URL: concrete}
|
event.Request = &sentry.Request{URL: concrete}
|
||||||
event.Transaction = "POST /webhook/" +
|
event.Transaction = "POST /h/" +
|
||||||
sentryReceiverUUID
|
sentryReceiverUUID
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(
|
scrubbed := server.ScrubSentryRequestForTest(
|
||||||
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
|
|||||||
|
|
||||||
event := sentry.NewEvent()
|
event := sentry.NewEvent()
|
||||||
event.Request = &sentry.Request{
|
event.Request = &sentry.Request{
|
||||||
URL: "/webhook/" + sentryReceiverUUID,
|
URL: "/h/" + sentryReceiverUUID,
|
||||||
}
|
}
|
||||||
event.Transaction = "/webhook/" + sentryReceiverUUID
|
event.Transaction = "/h/" + sentryReceiverUUID
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
||||||
require.NotNil(t, scrubbed)
|
require.NotNil(t, scrubbed)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text">Webhooks</a>
|
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
<button type="submit" class="btn-primary text-sm">Add</button>
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||||
@@ -57,20 +57,20 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2 mt-1">
|
<div class="flex items-start gap-2 mt-1">
|
||||||
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
|
||||||
<!-- Hidden until app.js reveals it; without the
|
<!-- Hidden until app.js reveals it; without the
|
||||||
script the URL above stays selectable. -->
|
script the URL above stays selectable. -->
|
||||||
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
@@ -151,14 +151,14 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="alert-error">{{.Error}}</div>
|
<div class="alert-error">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
||||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<input type="hidden" name="page" value="{{$.Page}}">
|
||||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||||
{{if .BodyTruncated}}
|
{{if .BodyTruncated}}
|
||||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{{if .Status.Terminal}}
|
{{if .Status.Terminal}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<input type="hidden" name="page" value="{{$.Page}}">
|
||||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
{{if or .HasPrev .HasNext}}
|
{{if or .HasPrev .HasNext}}
|
||||||
<div class="flex justify-center gap-2 mt-6">
|
<div class="flex justify-center gap-2 mt-6">
|
||||||
{{if .HasPrev}}
|
{{if .HasPrev}}
|
||||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||||
{{if .HasNext}}
|
{{if .HasNext}}
|
||||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
||||||
<a href="/sources/new" class="btn-primary">
|
<a href="/hooks/new" class="btn-primary">
|
||||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{{if .Webhooks}}
|
{{if .Webhooks}}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{{range .Webhooks}}
|
{{range .Webhooks}}
|
||||||
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
|
<a href="/hook/{{.ID}}" class="card-elevated p-6 block">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
||||||
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
||||||
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="alert-error">{{.Error}}</div>
|
<div class="alert-error">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/sources/new" class="space-y-6">
|
<form method="POST" action="/hooks/new" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||||
<a href="/sources" class="btn-secondary">Cancel</a>
|
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
||||||
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user