Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce2116a1e |
@@ -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
|
||||||
@@ -142,7 +142,7 @@ TTY detection, and security headers are always applied.
|
|||||||
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive) | `1h` |
|
||||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
@@ -389,37 +389,41 @@ unlocked.
|
|||||||
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare
|
||||||
address such as `192.168.1.7` is accepted and treated as a single
|
address such as `192.168.1.7` is accepted and treated as a single
|
||||||
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
host), for example `192.168.1.7, 2001:db8::5`. It decides whose
|
||||||
`X-Forwarded-For` header the rate limiters believe, so it should cover
|
`X-Forwarded-For` header the rate limiters believe, so it should name
|
||||||
the addresses of your reverse proxies.
|
the addresses of your reverse proxies and nothing else.
|
||||||
|
|
||||||
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
`X-Forwarded-For` is honoured **only** when the connecting peer is
|
||||||
inside one of these blocks; for every other peer the client identity is
|
inside one of these blocks; for every other peer the client identity is
|
||||||
the connection's own address and the header is ignored. Unset (or
|
the connection's own address and the header is ignored. The default is
|
||||||
empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`,
|
the empty list, which trusts nobody — anything else would let any
|
||||||
`172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default
|
client pick its own rate limit bucket, minting a fresh one per request
|
||||||
entirely. A set but unparseable value aborts startup.
|
or draining someone else's. Set it to the address of your reverse
|
||||||
|
proxy, and to nothing wider. A set but unparseable value aborts
|
||||||
|
startup.
|
||||||
|
|
||||||
If any client can reach webhooker, or the proxy in front of it, from an
|
That default is safe against forged headers, but leaving it unset in
|
||||||
RFC 1918 source address (directly, or through anything that can
|
production has a cost you must know about. Production runs behind a
|
||||||
rewrite source addresses, such as NAT or a published container port),
|
TLS-terminating reverse proxy, so with `TRUSTED_PROXIES` unset every
|
||||||
set `TRUSTED_PROXIES` to the proxy's address alone, or every rate
|
request keys on the proxy's own address and all clients share a single
|
||||||
limit, the webhook receiver's included, can be bypassed by those
|
bucket per limit. The receiver limits become service-wide ceilings,
|
||||||
clients. The address to set is the `remoteIP` field of the
|
and the login endpoint's failure counting collapses onto one key, so a
|
||||||
`http request` log line for a request that came through the proxy.
|
stranger's wrong passwords throttle every other client's wrong
|
||||||
|
passwords.
|
||||||
Behind a proxy the list does not cover, every request keys on the
|
|
||||||
proxy's own address and all clients share a single bucket per limit.
|
|
||||||
The receiver limits become service-wide ceilings, and the login
|
|
||||||
endpoint's failure counting collapses onto one key, so a stranger's
|
|
||||||
wrong passwords throttle every other client's wrong passwords. Set
|
|
||||||
`TRUSTED_PROXIES` to that proxy's address to restore per-client
|
|
||||||
buckets.
|
|
||||||
|
|
||||||
What it cannot do is lock the operator out. The login endpoint
|
What it cannot do is lock the operator out. The login endpoint
|
||||||
verifies credentials **before** it consults any limit and charges only
|
verifies credentials **before** it consults any limit and charges only
|
||||||
failures, so a correct password is never throttled no matter how full
|
failures, so a correct password is never throttled no matter how full
|
||||||
the bucket is. See [Rate Limiting](#rate-limiting).
|
the bucket is. See [Rate Limiting](#rate-limiting).
|
||||||
|
|
||||||
|
The remedy is to set `TRUSTED_PROXIES` to your reverse proxy's
|
||||||
|
address, which restores per-client buckets. webhooker logs a warning
|
||||||
|
at startup whenever `TRUSTED_PROXIES` is empty, in every environment,
|
||||||
|
because behind a proxy every client shares one bucket in `dev` and
|
||||||
|
`prod` alike. The warning is informational when nothing proxies to the
|
||||||
|
process: with no proxy in front, the peer address is the client's own
|
||||||
|
and the buckets are already per-client. See
|
||||||
|
[Rate Limiting](#rate-limiting) for what each limit shares.
|
||||||
|
|
||||||
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
`X-Real-IP` and `True-Client-IP` are **never** read, from any peer.
|
||||||
Reverse proxies append to `X-Forwarded-For` but forward other client
|
Reverse proxies append to `X-Forwarded-For` but forward other client
|
||||||
headers verbatim, so a single-valued header is client-controlled even
|
headers verbatim, so a single-valued header is client-controlled even
|
||||||
@@ -435,10 +439,20 @@ instead, since past such an entry the chain is not the shape assumed
|
|||||||
here. The peer address is likewise used when the header is absent or
|
here. The peer address is likewise used when the header is absent or
|
||||||
every hop in it is a trusted proxy.
|
every hop in it is a trusted proxy.
|
||||||
|
|
||||||
Your proxy must therefore **append** the peer address to
|
Two operator requirements follow:
|
||||||
`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy
|
|
||||||
`option forwardfor`, Caddy and AWS ALB by default), and must append a
|
- Your proxy must **append** the peer address to `X-Forwarded-For`
|
||||||
bare address with no port.
|
(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`,
|
||||||
|
Caddy and AWS ALB by default), and must append a bare address with
|
||||||
|
no port.
|
||||||
|
- List proxy hosts **only**. Any address inside `TRUSTED_PROXIES`
|
||||||
|
chooses its own rate-limit key: its `X-Forwarded-For` is walked, so
|
||||||
|
it can name a different address on every request to get a fresh
|
||||||
|
bucket each time, or name another client's address to drain that
|
||||||
|
client's bucket. Never list a block that also covers clients — a
|
||||||
|
broad `10.0.0.0/8` on a network where clients live in the same range
|
||||||
|
makes all three limits, including the unauthenticated webhook
|
||||||
|
receiver, silently bypassable by every client in the block.
|
||||||
|
|
||||||
#### Sessions
|
#### Sessions
|
||||||
|
|
||||||
@@ -737,15 +751,10 @@ repository's `Dockerfile` and runs it. The app needs:
|
|||||||
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
||||||
- **Environment variables:**
|
- **Environment variables:**
|
||||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||||
- `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to
|
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
||||||
your reverse proxy's address alone if that address is outside
|
network. The `remoteIP` field of the `http request` log line for a
|
||||||
those ranges, or if any client can reach webhooker, or the proxy,
|
request that came through the proxy shows it; the health check's
|
||||||
from an RFC 1918 source address (directly, or through anything
|
own lines show `::1`. See [Trusted proxies](#trusted-proxies).
|
||||||
that can rewrite source addresses, such as NAT or a published
|
|
||||||
container port). The `remoteIP` field of the `http request` log
|
|
||||||
line for a request that came through the proxy shows that
|
|
||||||
address; the health check's own lines show `::1`. See
|
|
||||||
[Trusted proxies](#trusted-proxies).
|
|
||||||
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
- Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets
|
||||||
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
`BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to
|
||||||
`/var/lib/webhooker`.
|
`/var/lib/webhooker`.
|
||||||
@@ -808,16 +817,12 @@ reports.
|
|||||||
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
behind a proxy means the `X-Forwarded-Proto` header. The block below
|
||||||
sets it; without it every request is read as plaintext and cookies
|
sets it; without it every request is read as plaintext and cookies
|
||||||
ship without `Secure`. See [Configuration](#configuration).
|
ship without `Secure`. See [Configuration](#configuration).
|
||||||
3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a
|
3. **Set `TRUSTED_PROXIES` to the proxy's address.** Unset, every rate
|
||||||
proxy it does not cover, every rate limiter keys on the proxy, so
|
limiter keys on the connecting peer, which behind a proxy is the
|
||||||
all clients share one bucket per limit. Unset, the list is the RFC
|
proxy on every request: all clients collapse into one global bucket
|
||||||
1918 ranges, which do not cover a proxy that reaches the binary
|
per limit and the receiver's per-IP limits become service-wide
|
||||||
itself over loopback (the binary bound to `127.0.0.1`). With the
|
ceilings. See [Trusted proxies](#trusted-proxies). List the proxy
|
||||||
image, the address to check is the `remoteIP` field of the
|
and nothing else.
|
||||||
`http request` log line for a request that came through the proxy.
|
|
||||||
If any client can reach webhooker, or the proxy, from an RFC 1918
|
|
||||||
source address, set the list to the proxy's address alone. See
|
|
||||||
[Trusted proxies](#trusted-proxies).
|
|
||||||
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the
|
||||||
port. webhooker's Origin/Referer check compares against the host it
|
port. webhooker's Origin/Referer check compares against the host it
|
||||||
was given, so on any port other than 443 `$host` makes every form
|
was given, so on any port other than 443 `$host` makes every form
|
||||||
@@ -1188,7 +1193,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.
|
||||||
|
|
||||||
@@ -1370,11 +1375,10 @@ It uses:
|
|||||||
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||||
sliding-window rate limiting of the password-change and webhook
|
sliding-window rate limiting of the password-change and webhook
|
||||||
receiver endpoints. The bucket is per client IP only when
|
receiver endpoints. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` covers the reverse proxy (by default it covers the
|
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||||
RFC 1918 private ranges); otherwise every client behind that proxy
|
behind that proxy shares one bucket per limit. The login endpoint
|
||||||
shares one bucket per limit. The login endpoint counts failed
|
counts failed attempts itself instead, so that a correct password is
|
||||||
attempts itself instead, so that a correct password is never
|
never throttled (see [Rate Limiting](#rate-limiting))
|
||||||
throttled (see [Rate Limiting](#rate-limiting))
|
|
||||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||||
`/metrics` behind basic auth
|
`/metrics` behind basic auth
|
||||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||||
@@ -1523,7 +1527,7 @@ the full request and creates an Event.
|
|||||||
| -------------- | ------- | ----------- |
|
| -------------- | ------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `path` | string | Unique bare UUID, generated at creation. The `/webhook/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) |
|
||||||
| `description` | string | Optional description |
|
| `description` | string | Optional description |
|
||||||
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
| `active` | boolean | Whether this entrypoint accepts events (default: true) |
|
||||||
|
|
||||||
@@ -1905,7 +1909,7 @@ runtime, though CGO is required at build time due to the transitive
|
|||||||
```
|
```
|
||||||
External Service
|
External Service
|
||||||
│
|
│
|
||||||
│ POST /webhook/{uuid}
|
│ POST /h/{uuid}
|
||||||
▼
|
▼
|
||||||
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ chi Router │────►│ Middleware │────►│ Webhook │
|
│ chi Router │────►│ Middleware │────►│ Webhook │
|
||||||
@@ -2131,7 +2135,7 @@ The middleware records three more on the same registry:
|
|||||||
Two of those labels are written once per request from bytes the client
|
Two of those labels are written once per request from bytes the client
|
||||||
chose, so both are bounded to something this service registers:
|
chose, so both are bounded to something this service registers:
|
||||||
|
|
||||||
- `handler` is the chi route pattern — `/webhook/{uuid}`, never the
|
- `handler` is the chi route pattern — `/h/{uuid}`, never the
|
||||||
concrete path. A request matching no route carries `(unmatched)`,
|
concrete path. A request matching no route carries `(unmatched)`,
|
||||||
and no entrypoint UUID ever reaches a label.
|
and no entrypoint UUID ever reaches a label.
|
||||||
- `method` is the request method when the router can route it, and
|
- `method` is the request method when the router can route it, and
|
||||||
@@ -2161,7 +2165,7 @@ unpredictable rates, and blanket limits shared with other routes would
|
|||||||
cause legitimate deliveries to be dropped.
|
cause legitimate deliveries to be dropped.
|
||||||
|
|
||||||
The receiver instead has its own dedicated abuse limit, scoped to the
|
The receiver instead has its own dedicated abuse limit, scoped to the
|
||||||
`/webhook/{uuid}` route only and keyed per client IP per request path
|
`/h/{uuid}` route only and keyed per client IP per request path
|
||||||
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
(`httprate.KeyByEndpoint`): one misbehaving sender is throttled without
|
||||||
affecting other senders of the same entrypoint or the same sender's
|
affecting other senders of the same entrypoint or the same sender's
|
||||||
other entrypoints. Keying on the path rather than on the entrypoint
|
other entrypoints. Keying on the path rather than on the entrypoint
|
||||||
@@ -2198,7 +2202,7 @@ log spends. The access log is bounded by neither limit: every request
|
|||||||
is recorded once at `INFO`, served or rejected alike.
|
is recorded once at `INFO`, served or rejected alike.
|
||||||
|
|
||||||
What the access log does bound is the _content_ of those lines. A 3xx
|
What the access log does bound is the _content_ of those lines. A 3xx
|
||||||
or 4xx response logs the chi route pattern — `/webhook/{uuid}`,
|
or 4xx response logs the chi route pattern — `/h/{uuid}`,
|
||||||
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
`/user/{username}//`, or the literal `(unmatched)` when the request hit
|
||||||
no route at all — in place of the concrete URL. Those are the outcomes
|
no route at all — in place of the concrete URL. Those are the outcomes
|
||||||
an unauthenticated client can drive for free: 404 and 429 on any
|
an unauthenticated client can drive for free: 404 and 429 on any
|
||||||
@@ -2232,12 +2236,12 @@ reduces the headers to a fixed allowlist — `Accept`, `Content-Length`,
|
|||||||
|
|
||||||
The same hook rewrites the request URL. The SDK builds it as
|
The same hook rewrites the request URL. The SDK builds it as
|
||||||
`scheme://host/path` from the concrete path, which on the receiver
|
`scheme://host/path` from the concrete path, which on the receiver
|
||||||
route is `/webhook/<uuid>` in full — and that UUID is a write
|
route is `/h/<uuid>` in full — and that UUID is a write
|
||||||
capability, not an identifier: anyone holding it can post events this
|
capability, not an identifier: anyone holding it can post events this
|
||||||
service accepts and its targets then deliver. A tracker has its own
|
service accepts and its targets then deliver. A tracker has its own
|
||||||
retention, access control and deletion policy, so the rule the access
|
retention, access control and deletion policy, so the rule the access
|
||||||
log follows above does not carry across that boundary. What is sent is
|
log follows above does not carry across that boundary. What is sent is
|
||||||
the chi route pattern instead: `http://host/webhook/{uuid}`.
|
the chi route pattern instead: `http://host/h/{uuid}`.
|
||||||
|
|
||||||
The scheme and the host are kept, and everything else in the URL is
|
The scheme and the host are kept, and everything else in the URL is
|
||||||
discarded rather than edited, so a future SDK version that starts
|
discarded rather than edited, so a future SDK version that starts
|
||||||
@@ -2281,8 +2285,8 @@ fallback is never the concrete path. The path becomes the literal
|
|||||||
rewrite cannot parse into a scheme is withheld whole. A transaction
|
rewrite cannot parse into a scheme is withheld whole. A transaction
|
||||||
event additionally carries the SDK's own `METHOD /path` name, built
|
event additionally carries the SDK's own `METHOD /path` name, built
|
||||||
from the concrete path as well; it is rewritten on the same terms, to
|
from the concrete path as well; it is rewritten on the same terms, to
|
||||||
`POST /webhook/{uuid}` where the pattern is known and `POST
|
`POST /h/{uuid}` where the pattern is known and `POST /(redacted)`
|
||||||
/(redacted)` where it is not.
|
where it is not.
|
||||||
|
|
||||||
The headers are an allowlist for the same reason the rules above are
|
The headers are an allowlist for the same reason the rules above are
|
||||||
unconditional: the SDK's own filter removes four names and passes
|
unconditional: the SDK's own filter removes four names and passes
|
||||||
@@ -2417,7 +2421,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
|
||||||
@@ -2545,44 +2549,47 @@ the tree is checked out: four checkouts have reported 3,959, 3,961,
|
|||||||
client-supplied field was cut, and that the shipped chain's stack
|
client-supplied field was cut, and that the shipped chain's stack
|
||||||
arrived uncut — never the numbers.
|
arrived uncut — never the numbers.
|
||||||
|
|
||||||
Every limiter here — receiver, login, password change, delivery replay
|
Every limiter here — receiver, login, and password change — identifies
|
||||||
and event resubmit — identifies the client the same way, through one
|
the client the same way, through one shared key function: the
|
||||||
shared key function: the connection's own address, unless the peer is
|
connection's own address, unless the peer is listed in
|
||||||
inside `TRUSTED_PROXIES`, in which case the forwarded client address is
|
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
||||||
used instead. That address becomes a bucket by family: IPv4 keys on
|
instead. That address becomes a bucket by family: IPv4 keys on the full
|
||||||
the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||||
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||||
let one subscriber rotate source addresses and mint a fresh bucket per
|
let one subscriber rotate source addresses and mint a fresh bucket per
|
||||||
request, evading these limits at the network layer without spoofing
|
request, evading these limits at the network layer without spoofing
|
||||||
anything; the cost is that distinct clients inside one `/64` share a
|
anything; the cost is that distinct clients inside one `/64` share a
|
||||||
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
||||||
they carry. See [Trusted proxies](#trusted-proxies). When that variable
|
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||||
does not cover the reverse proxy, a client behind it shares one bucket
|
variable set, a client behind a reverse proxy shares one bucket with
|
||||||
with every other client behind the same proxy. Set `TRUSTED_PROXIES` to
|
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
||||||
the proxy's address to get per-client limits back. What the shared bucket
|
proxy's address to get per-client limits back. What the shared bucket
|
||||||
costs is not the same for every limiter, and the two cases pull in
|
costs is not the same for every limiter, and the two cases pull in
|
||||||
opposite directions:
|
opposite directions:
|
||||||
|
|
||||||
- For the **receiver** limits it costs throughput, which is the safe
|
- For the **receiver** limits it costs throughput, which is the safe
|
||||||
direction to be wrong in: sharing can only make a limit bind sooner,
|
direction to be wrong in: sharing can only make a limit bind sooner,
|
||||||
never let a sender past it. It matters more for the aggregate limit
|
never let a sender past it. It matters more for the aggregate limit
|
||||||
than for the per-entrypoint one: with every request keyed on the
|
than for the per-entrypoint one: with `TRUSTED_PROXIES` unset behind
|
||||||
proxy, the aggregate limit becomes a service-wide ceiling of 1200
|
the reverse proxy a production deployment is required to run behind,
|
||||||
requests per minute across all senders and all entrypoints, where the
|
every request keys on the proxy, so the aggregate limit becomes a
|
||||||
per-entrypoint limit's capacity still grows with the number of
|
service-wide ceiling of 1200 requests per minute across all senders
|
||||||
entrypoints.
|
and all entrypoints, where the per-entrypoint limit's capacity still
|
||||||
|
grows with the number of entrypoints. Any deployment with more than a
|
||||||
|
handful of busy entrypoints must set `TRUSTED_PROXIES`.
|
||||||
- For the **login and password-change** limits it costs precision, not
|
- For the **login and password-change** limits it costs precision, not
|
||||||
availability. Login failures from every client land in one counter,
|
availability. Login failures from every client land in one counter,
|
||||||
so a stranger's wrong passwords make the operator's own wrong
|
so a stranger's wrong passwords make the operator's own wrong
|
||||||
passwords answer `429` sooner; the operator's _correct_ password is
|
passwords answer `429` sooner; the operator's _correct_ password is
|
||||||
never affected, because it is never counted.
|
never affected, because it is never counted. Production deployments
|
||||||
|
should still set `TRUSTED_PROXIES`; webhooker warns at startup
|
||||||
|
whenever it is empty, in any environment.
|
||||||
|
|
||||||
#### The login endpoint
|
#### The login endpoint
|
||||||
|
|
||||||
The login `POST` is the one endpoint with no pre-emptive limiter in
|
The login `POST` is the one endpoint with no pre-emptive limiter in
|
||||||
front of it, and that is deliberate. A limiter that spends budget on
|
front of it, and that is deliberate. A limiter that spends budget on
|
||||||
arrival is a lockout wherever clients share one bucket, as they do
|
arrival is a lockout in this deployment shape: sharing one bucket, a
|
||||||
behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a
|
|
||||||
stranger sending five POSTs a minute — about 0.08 requests per second,
|
stranger sending five POSTs a minute — about 0.08 requests per second,
|
||||||
from anywhere — keeps it permanently full, and the operator has no
|
from anywhere — keeps it permanently full, and the operator has no
|
||||||
second administrative path. So the handler inverts the order:
|
second administrative path. So the handler inverts the order:
|
||||||
@@ -2679,10 +2686,8 @@ re-fills both verification slots on its first two requests. The
|
|||||||
remedies are to block the source at the reverse proxy, or to
|
remedies are to block the source at the reverse proxy, or to
|
||||||
rate-limit `POST /pages/login` there — the one place a limit can be
|
rate-limit `POST /pages/login` there — the one place a limit can be
|
||||||
applied without reintroducing the lockout, because the proxy sees the
|
applied without reintroducing the lockout, because the proxy sees the
|
||||||
real client address. `TRUSTED_PROXIES` does not stop the saturation.
|
real client address. Setting `TRUSTED_PROXIES` does not stop the
|
||||||
The flood's source is in the proxy's access log: webhooker's own logs
|
saturation, but it makes the source visible in the failure logs.
|
||||||
record the proxy's address, not the client's (see
|
|
||||||
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)).
|
|
||||||
|
|
||||||
Finer-grained per-webhook rate limits (configured in the web UI and
|
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||||
enforced in the webhook handler) can layer on top of this env-level
|
enforced in the webhook handler) can layer on top of this env-level
|
||||||
@@ -2694,10 +2699,10 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------------------- | ----------- |
|
| ------ | --------------------------- | ----------- |
|
||||||
| `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) |
|
| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) |
|
||||||
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) |
|
||||||
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` |
|
||||||
| `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) |
|
||||||
|
|
||||||
#### Authentication Endpoints
|
#### Authentication Endpoints
|
||||||
|
|
||||||
@@ -2713,25 +2718,25 @@ abuse limit later; they are tracked as future work.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/sources` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/sources/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/sources/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
| `GET` | `/source/{id}` | Webhook detail view |
|
| `GET` | `/hook/{id}` | Webhook detail view |
|
||||||
| `GET` | `/source/{id}/edit` | Edit webhook form |
|
| `GET` | `/hook/{id}/edit` | Edit webhook form |
|
||||||
| `POST` | `/source/{id}/edit` | Edit webhook submission |
|
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/source/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/source/{id}/logs` | Webhook event logs |
|
| `GET` | `/hook/{id}/events` | Full Event Log |
|
||||||
| `GET` | `/source/{id}/logs/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
|
||||||
| `POST` | `/source/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/source/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/source/{id}/entrypoints` | Add entrypoint to webhook |
|
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint |
|
||||||
| `POST` | `/source/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint |
|
||||||
| `POST` | `/source/{id}/targets` | Add target to webhook |
|
| `POST` | `/hook/{id}/targets` | Add target to webhook |
|
||||||
| `GET` | `/source/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/edit` | Edit target submission |
|
| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/delete` | Delete a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target |
|
||||||
| `POST` | `/source/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target |
|
||||||
|
|
||||||
#### Infrastructure Endpoints
|
#### Infrastructure Endpoints
|
||||||
|
|
||||||
@@ -2932,8 +2937,8 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -2957,7 +2962,7 @@ Those same four route groups then apply **CSRF** and **NoCache**
|
|||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
`/user/{username}/password` and **ReceiverRateLimit** on
|
`/user/{username}/password` and **ReceiverRateLimit** on
|
||||||
`/webhook/{uuid}`. There is deliberately none on `/pages/login` — that
|
`/h/{uuid}`. There is deliberately none on `/pages/login` — that
|
||||||
endpoint counts failures inside the handler, after the credential
|
endpoint counts failures inside the handler, after the credential
|
||||||
check, see [The login endpoint](#the-login-endpoint).
|
check, see [The login endpoint](#the-login-endpoint).
|
||||||
|
|
||||||
@@ -2998,8 +3003,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
|
||||||
@@ -3040,9 +3045,10 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
It runs behind session auth, so only a client already holding a
|
It runs behind session auth, so only a client already holding a
|
||||||
valid session reaches it, and an operator throttled out of changing
|
valid session reaches it, and an operator throttled out of changing
|
||||||
a password can still log in. The bucket is per client IP only when
|
a password can still log in. The bucket is per client IP only when
|
||||||
`TRUSTED_PROXIES` covers the reverse proxy; otherwise every client
|
`TRUSTED_PROXIES` names the reverse proxy; unset, every client
|
||||||
shares one bucket, which costs precision rather than availability
|
shares one bucket, which costs precision rather than availability
|
||||||
(see [Rate Limiting](#rate-limiting))
|
(see [Rate Limiting](#rate-limiting)). webhooker warns at startup
|
||||||
|
whenever `TRUSTED_PROXIES` is empty
|
||||||
- Prometheus metrics behind basic auth
|
- Prometheus metrics behind basic auth
|
||||||
- Static assets embedded in binary (no filesystem access needed at
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
runtime)
|
runtime)
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ point of the branch.
|
|||||||
- 2026-03-05 security headers middleware, session regeneration on
|
- 2026-03-05 security headers middleware, session regeneration on
|
||||||
login, request body size limits (#41)
|
login, request body size limits (#41)
|
||||||
- 2026-03-04 tests for delivery, middleware, and session packages
|
- 2026-03-04 tests for delivery, middleware, and session packages
|
||||||
(#32); removed the build-architecture global (#31)
|
(#32); removed globals.Buildarch (#31)
|
||||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
||||||
delivery engine with bounded worker pool and circuit breaker,
|
delivery engine with bounded worker pool and circuit breaker,
|
||||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
parallel fan-out, per-webhook event databases, management UI (#16)
|
||||||
|
|||||||
+60
-22
@@ -75,11 +75,6 @@ const (
|
|||||||
// internet-exposed endpoint.
|
// internet-exposed endpoint.
|
||||||
defaultReceiverRateLimit = 120
|
defaultReceiverRateLimit = 120
|
||||||
|
|
||||||
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
|
|
||||||
// RFC 1918 private ranges, which a reverse proxy reaching the
|
|
||||||
// process over a Docker network or a private LAN connects from.
|
|
||||||
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
||||||
|
|
||||||
// maxPort is the highest valid TCP port number. The lower
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
// bound (at least 1) is enforced by envPositiveInt.
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
maxPort = 65535
|
maxPort = 65535
|
||||||
@@ -177,14 +172,13 @@ type Config struct {
|
|||||||
|
|
||||||
// TrustedProxies is the set of networks whose members are
|
// TrustedProxies is the set of networks whose members are
|
||||||
// allowed to speak for the client with X-Forwarded-For, the
|
// allowed to speak for the client with X-Forwarded-For, the
|
||||||
// only forwarded header read. Unless TRUSTED_PROXIES is set it
|
// only forwarded header read. It is empty unless
|
||||||
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
|
// TRUSTED_PROXIES is set, and empty means no peer is
|
||||||
// value replaces them. If any client can reach the process, or
|
// trusted: forwarded headers are then ignored entirely and
|
||||||
// the proxy in front of it, from an RFC 1918 source address
|
// clients are identified by the connection's own address.
|
||||||
// (directly, or through anything that can rewrite source
|
// Members can choose their own rate-limit key, so this must
|
||||||
// addresses, such as NAT or a published container port), it
|
// name proxy hosts only, never a block that also covers
|
||||||
// must be set to the proxy's address alone, or every rate limit
|
// clients.
|
||||||
// can be bypassed by those clients.
|
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
|
|
||||||
// AllowedEgressCIDRs is the set of networks a delivery target
|
// AllowedEgressCIDRs is the set of networks a delivery target
|
||||||
@@ -466,15 +460,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
|
|||||||
|
|
||||||
// envPrefixList returns the value of the named environment variable
|
// envPrefixList returns the value of the named environment variable
|
||||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||||
// allowed). An unset, empty, or blank value is read as defaultValue
|
// allowed). An unset, empty, or blank value yields an empty list. A
|
||||||
// instead. A set value containing an unparseable entry is a hard
|
// set value containing an unparseable entry is a hard error naming
|
||||||
// error naming the key and the bad entry, so startup fails loudly
|
// the key and the bad entry, so startup fails loudly rather than
|
||||||
// rather than silently running with a list the operator did not
|
// silently running with a list the operator did not intend.
|
||||||
// intend.
|
func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||||
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
|
|
||||||
v := strings.TrimSpace(os.Getenv(key))
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
if v == "" {
|
if v == "" {
|
||||||
v = defaultValue
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var prefixes []netip.Prefix
|
var prefixes []netip.Prefix
|
||||||
@@ -688,12 +681,12 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
|
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
|
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -767,6 +760,50 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnSharedRateLimitBucket logs a startup warning whenever
|
||||||
|
// TRUSTED_PROXIES is empty, in any environment.
|
||||||
|
//
|
||||||
|
// With no trusted proxies every rate limiter keys on the connecting
|
||||||
|
// peer's address. Whether that is harmless or dangerous depends on
|
||||||
|
// what is in front of the process, which this code cannot observe:
|
||||||
|
// with nothing in front, the peer is the client and the limits are
|
||||||
|
// per-client as intended; behind a reverse proxy the peer is the proxy
|
||||||
|
// for every request, so all clients share one bucket per limiter.
|
||||||
|
//
|
||||||
|
// The login endpoint no longer spends budget on arrival — it verifies
|
||||||
|
// credentials first and charges only failures — so a shared bucket
|
||||||
|
// cannot deny the operator a correct password. What it does collapse
|
||||||
|
// is the failure counting: one client's wrong passwords throttle
|
||||||
|
// everyone else's wrong passwords, and the receiver's limits become
|
||||||
|
// service-wide ceilings.
|
||||||
|
//
|
||||||
|
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT:
|
||||||
|
// behind a proxy every client shares one bucket in dev and prod alike.
|
||||||
|
//
|
||||||
|
// The default of trusting nobody is deliberate — trusting forwarded
|
||||||
|
// headers from arbitrary peers lets any client choose its own bucket —
|
||||||
|
// so this warns rather than failing startup or changing the key.
|
||||||
|
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
|
||||||
|
if len(c.TrustedProxies) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn(
|
||||||
|
"TRUSTED_PROXIES is empty: every rate limit keys on the "+
|
||||||
|
"connecting peer's address. With nothing proxying to "+
|
||||||
|
"this process that is the client itself and the limits "+
|
||||||
|
"are per-client as intended. Behind a reverse proxy the "+
|
||||||
|
"peer is the proxy on every request, so all clients "+
|
||||||
|
"share one bucket per limit: the receiver limits become "+
|
||||||
|
"service-wide ceilings, and one client's failed logins "+
|
||||||
|
"throttle every other client's failed logins — a "+
|
||||||
|
"correct password still gets in. If anything proxies to "+
|
||||||
|
"this process, set TRUSTED_PROXIES to its address.",
|
||||||
|
"environment", c.Environment,
|
||||||
|
"trustedProxies", len(c.TrustedProxies),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a Config by reading environment variables.
|
// New creates a Config by reading environment variables.
|
||||||
//
|
//
|
||||||
//nolint:revive // lc parameter is required by fx even if unused.
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
@@ -812,6 +849,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
"hasMetricsAuth", s.MetricsAuthEnabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
s.warnSharedRateLimitBucket(log)
|
||||||
s.warnEgressAllowlist(log)
|
s.warnEgressAllowlist(log)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
+101
-14
@@ -551,11 +551,6 @@ func testReceiverRateLimitSuccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTrustedProxies(t *testing.T) {
|
func TestTrustedProxies(t *testing.T) {
|
||||||
// Unset, the RFC 1918 private ranges are trusted, so a reverse
|
|
||||||
// proxy on a Docker network or a private LAN is covered without
|
|
||||||
// configuration.
|
|
||||||
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
@@ -564,21 +559,18 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
expected []string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
// The default must be "trust nobody": an empty list
|
||||||
|
// means forwarded headers are ignored, never that
|
||||||
|
// every peer may speak for the client.
|
||||||
name: caseUnsetUsesDefault,
|
name: caseUnsetUsesDefault,
|
||||||
set: false,
|
set: false,
|
||||||
expected: defaultProxies,
|
expected: []string{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "blank value uses default",
|
name: "blank value trusts nothing",
|
||||||
set: true,
|
set: true,
|
||||||
value: " ",
|
value: " ",
|
||||||
expected: defaultProxies,
|
expected: []string{},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "set value replaces the default entirely",
|
|
||||||
set: true,
|
|
||||||
value: "203.0.113.7",
|
|
||||||
expected: []string{"203.0.113.7/32"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: caseValidValueParsed,
|
name: caseValidValueParsed,
|
||||||
@@ -853,6 +845,101 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSharedRateLimitBucketWarning covers the startup warning that
|
||||||
|
// tells an operator a deployment behind a reverse proxy shares one
|
||||||
|
// rate-limit bucket between every client, which turns the receiver
|
||||||
|
// limits into service-wide ceilings and collapses login failure
|
||||||
|
// counting. It must fire whenever TRUSTED_PROXIES is empty, in any
|
||||||
|
// environment, because behind a proxy every client shares one bucket
|
||||||
|
// in dev and prod alike. It stays quiet once proxies are named.
|
||||||
|
func TestSharedRateLimitBucketWarning(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
environment string
|
||||||
|
trustedProxies string
|
||||||
|
expectWarning bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prod without trusted proxies warns",
|
||||||
|
environment: config.EnvironmentProd,
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prod with trusted proxies is quiet",
|
||||||
|
environment: config.EnvironmentProd,
|
||||||
|
trustedProxies: cidrPrivateV4,
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev without trusted proxies warns",
|
||||||
|
environment: config.EnvironmentDev,
|
||||||
|
expectWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev with trusted proxies is quiet",
|
||||||
|
environment: config.EnvironmentDev,
|
||||||
|
trustedProxies: cidrPrivateV4,
|
||||||
|
expectWarning: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
|
||||||
|
|
||||||
|
if tt.trustedProxies == "" {
|
||||||
|
require.NoError(
|
||||||
|
t, os.Unsetenv("TRUSTED_PROXIES"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
log := slog.New(slog.NewJSONHandler(
|
||||||
|
&buf, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
config.WarnSharedRateLimitBucketForTest(log),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tt.expectWarning {
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
|
||||||
|
assert.Contains(t, logged, `"level":"WARN"`)
|
||||||
|
assert.Contains(t, logged, "TRUSTED_PROXIES")
|
||||||
|
assert.Contains(t, logged, "share one bucket")
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "throttle every other client's failed logins",
|
||||||
|
)
|
||||||
|
// The warning must not claim a lockout the login
|
||||||
|
// endpoint no longer permits: credentials are verified
|
||||||
|
// before any budget is spent.
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "a correct password still gets in",
|
||||||
|
)
|
||||||
|
// The text must stay accurate for a developer with
|
||||||
|
// nothing in front of the process, where an empty
|
||||||
|
// list costs nothing.
|
||||||
|
assert.Contains(
|
||||||
|
t, logged, "nothing proxying to this process",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// metricsEnv describes what one subtest below puts in the
|
// metricsEnv describes what one subtest below puts in the
|
||||||
// environment for a single METRICS_ variable. A variable that is
|
// environment for a single METRICS_ variable. A variable that is
|
||||||
// set to the empty string and one that is not set at all are
|
// set to the empty string and one that is not set at all are
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ import "log/slog"
|
|||||||
// the external config_test package so each helper can be covered by
|
// the external config_test package so each helper can be covered by
|
||||||
// its own table-driven test without weakening the package API.
|
// its own table-driven test without weakening the package API.
|
||||||
|
|
||||||
|
// WarnSharedRateLimitBucketForTest loads a Config from the current
|
||||||
|
// environment and emits its startup warnings to log. The real logger
|
||||||
|
// writes to stdout, so this lets the warning's firing condition be
|
||||||
|
// asserted against a handler the test controls.
|
||||||
|
func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
|
||||||
|
c, err := loadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.warnSharedRateLimitBucket(log)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// WarnEgressAllowlistForTest loads a Config from the current
|
// WarnEgressAllowlistForTest loads a Config from the current
|
||||||
// environment and emits its egress-allowlist startup warning to
|
// environment and emits its egress-allowlist startup warning to
|
||||||
// log, so a test can assert both that the warning fires only when
|
// log, so a test can assert both that the warning fires only when
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,10 +103,9 @@ func (h *Handlers) renderLoginError(
|
|||||||
// The credential check runs BEFORE any rate-limit budget is
|
// The credential check runs BEFORE any rate-limit budget is
|
||||||
// consulted, and only a failed check spends budget. That is what
|
// consulted, and only a failed check spends budget. That is what
|
||||||
// keeps the single administrative path reachable: behind the reverse
|
// keeps the single administrative path reachable: behind the reverse
|
||||||
// proxy this deployment requires, when TRUSTED_PROXIES does not cover
|
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
||||||
// it, every client shares one bucket, so a limiter spent on arrival
|
// client shares one bucket, so a limiter spent on arrival lets any
|
||||||
// lets any stranger deny the operator's own correct password
|
// stranger deny the operator's own correct password indefinitely.
|
||||||
// indefinitely.
|
|
||||||
//
|
//
|
||||||
// Verifying first means every login POST costs an Argon2id hash, so
|
// Verifying first means every login POST costs an Argon2id hash, so
|
||||||
// the work is taken under a bounded number of verification slots.
|
// the work is taken under a bounded number of verification slots.
|
||||||
@@ -166,7 +165,11 @@ func (h *Handlers) authenticateUser(
|
|||||||
|
|
||||||
valid, err := database.VerifyPassword(password, user.Password)
|
valid, err := database.VerifyPassword(password, user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.log.Error("failed to verify password", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -238,14 +241,24 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
) error {
|
) error {
|
||||||
oldSess, err := h.session.Get(r)
|
oldSess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.log.Error("failed to get session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to regenerate session", err)
|
h.log.Error(
|
||||||
|
"failed to regenerate session", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -254,7 +267,11 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
|
|
||||||
err = h.session.Save(r, w, sess)
|
err = h.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to save session", err)
|
h.log.Error("failed to save session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
|
|
||||||
// sharedProxyPeer is the whole point of this file. Production is
|
// sharedProxyPeer is the whole point of this file. Production is
|
||||||
// required to run behind a TLS-terminating reverse proxy, and
|
// required to run behind a TLS-terminating reverse proxy, and
|
||||||
// when TRUSTED_PROXIES does not cover it every client — attacker
|
// TRUSTED_PROXIES defaults to empty, so every client — attacker
|
||||||
// and operator alike — reaches the process from the proxy's
|
// and operator alike — reaches the process from the proxy's
|
||||||
// address and shares one rate-limit bucket. Both parties in
|
// address and shares one rate-limit bucket. Both parties in
|
||||||
// these tests therefore use the same RemoteAddr.
|
// these tests therefore use the same RemoteAddr.
|
||||||
@@ -115,11 +115,11 @@ func floodFailures(
|
|||||||
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
|
||||||
//
|
//
|
||||||
// The attacker and the operator share one rate-limit bucket, because
|
// The attacker and the operator share one rate-limit bucket, because
|
||||||
// behind the mandated reverse proxy, when TRUSTED_PROXIES does not
|
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
|
||||||
// cover it, every client keys on the proxy's address. The attacker
|
// client keys on the proxy's address. The attacker floods the
|
||||||
// floods the operator's own username — a single-admin product has a
|
// operator's own username — a single-admin product has a predictable
|
||||||
// predictable one — far past the failure limit. The operator must
|
// one — far past the failure limit. The operator must still be able
|
||||||
// still be able to log in with the correct password.
|
// to log in with the correct password.
|
||||||
//
|
//
|
||||||
// This fails if credentials stop being verified ahead of the limiter.
|
// This fails if credentials stop being verified ahead of the limiter.
|
||||||
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,14 +124,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,7 +173,7 @@ func (h *Handlers) loadReplaySource(
|
|||||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -193,7 +195,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count in-flight deliveries", err,
|
w, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -210,7 +212,7 @@ func (h *Handlers) queueReplay(
|
|||||||
err = webhookDB.
|
err = webhookDB.
|
||||||
First(&event, "id = ?", original.EventID).Error
|
First(&event, "id = ?", original.EventID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event for replay", err)
|
h.serverError(w, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,7 +222,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to create replay delivery", err,
|
w, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -360,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"`)
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
|
||||||
// which cannot render answers with the status it was reporting, as
|
|
||||||
// plain text, and is not attempted again: a page whose own render
|
|
||||||
// fails reaches the error page, and the error page failing as well
|
|
||||||
// ends there with the 500.
|
|
||||||
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// .Status is an int, so asking it for a field fails the render.
|
|
||||||
failing := `{{.Status.Missing}}`
|
|
||||||
h.AddTemplateForTest("error.html", template.Must(
|
|
||||||
template.New("error").Parse(failing),
|
|
||||||
))
|
|
||||||
h.AddTemplateForTest("failing.html", template.Must(
|
|
||||||
template.New("failing").Parse(`{{.Data.Missing}}`),
|
|
||||||
))
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "Not Found\n", w.Body.String())
|
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to read event body", err)
|
h.serverError(w, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// alphabet rather than from the request.
|
// alphabet rather than from the request.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
webhookDB, webhook.ID, eventID.String(),
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event to resubmit", err)
|
h.serverError(w, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// A miss is a 404 whether the event was reaped, belongs to
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
// another webhook, or never existed.
|
// another webhook, or never existed.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
// inactive one is skipped rather than refused.
|
// inactive one is skipped rather than refused.
|
||||||
targets, err := h.loadActiveTargets(webhook.ID)
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to query targets", err)
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to store resubmitted event", err)
|
h.serverError(w, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ func (h *Handlers) finishResubmit(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code resubmitOutcomeCode,
|
code resubmitOutcomeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/source/" + webhook.ID + "/logs?" +
|
dest := "/hook/" + webhook.ID + "/events?" +
|
||||||
resubmitOutcomeParam + "=" + string(code)
|
resubmitOutcomeParam + "=" + string(code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
// The page is read from the form rather than the query string:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func postResubmit(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/events/"+eventID+"/resubmit",
|
"/hook/"+webhookID+"/events/"+eventID+"/resubmit",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
),
|
),
|
||||||
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"a resubmit must not be refused while an earlier "+
|
"a resubmit must not be refused while an earlier "+
|
||||||
"one is in flight",
|
"one is in flight",
|
||||||
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=queued",
|
"/hook/"+wh.ID+"/events?resubmit=queued",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
"an inactive target is skipped, not an error",
|
"an inactive target is skipped, not an error",
|
||||||
)
|
)
|
||||||
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
|
|||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"/source/"+wh.ID+"/logs?resubmit=no-targets",
|
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -598,7 +598,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, body,
|
t, body,
|
||||||
"/source/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
"/hook/"+wh.ID+"/events/"+original.ID+"/resubmit",
|
||||||
"the log must offer the resubmit action per event",
|
"the log must offer the resubmit action per event",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
@@ -65,20 +63,12 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
page int,
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, newRequestForTest(), webhook, nil, page,
|
w, webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequestForTest is the request the helpers here pass on for
|
|
||||||
// callers that have none: it is used only to render the error page.
|
|
||||||
func newRequestForTest() *http.Request {
|
|
||||||
return httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
@@ -132,5 +122,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
return s.buildDatabaseTargetConfig(w, expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("{}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ func New(
|
|||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -147,15 +146,6 @@ func New(
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleErrorPage returns a handler that answers every request with
|
|
||||||
// the error page for status. The router uses it for unknown paths and
|
|
||||||
// the CSRF middleware for a refused form.
|
|
||||||
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.renderError(w, r, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
@@ -173,76 +163,15 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverError logs an error and answers with the 500 error page.
|
// serverError logs an error and sends a 500 response.
|
||||||
func (s *Handlers) serverError(
|
func (s *Handlers) serverError(
|
||||||
w http.ResponseWriter, r *http.Request, msg string, err error,
|
w http.ResponseWriter, msg string, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error(msg, "error", err)
|
s.log.Error(msg, "error", err)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
}
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
// renderError answers with status and the error page: the normal
|
)
|
||||||
// layout, one fixed line explaining the status, and a link back to the
|
|
||||||
// webhook list, or to sign-in when nobody is signed in.
|
|
||||||
//
|
|
||||||
// It renders the page itself rather than through renderTemplate,
|
|
||||||
// whose own failure comes here. If the error page cannot render
|
|
||||||
// either, the answer is the same status in plain text: never a second
|
|
||||||
// attempt, and never a different status.
|
|
||||||
func (s *Handlers) renderError(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
status int,
|
|
||||||
) {
|
|
||||||
// The page names the signed-in user, and some error pages are
|
|
||||||
// served outside the routes where NoCache runs.
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
|
|
||||||
data := s.pageData(r, map[string]any{
|
|
||||||
"Status": status,
|
|
||||||
"StatusText": http.StatusText(status),
|
|
||||||
"Message": errorPageText(status),
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
err := s.templates["error.html"].Execute(&buf, data)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to render error page", "error", err)
|
|
||||||
http.Error(w, http.StatusText(status), status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
|
|
||||||
_, err = buf.WriteTo(w)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to write error page", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// errorPageText is the line the error page shows for status. It is
|
|
||||||
// fixed per status, so the page tells the reader no more than the
|
|
||||||
// plain-text answers it replaced did.
|
|
||||||
func errorPageText(status int) string {
|
|
||||||
switch status {
|
|
||||||
case http.StatusBadRequest:
|
|
||||||
return "The request could not be read."
|
|
||||||
case http.StatusForbidden:
|
|
||||||
return "The request was refused. If it came from a form " +
|
|
||||||
"left open for a long time, reload the page and try " +
|
|
||||||
"again."
|
|
||||||
case http.StatusNotFound:
|
|
||||||
return "There is nothing here. It may have been deleted, " +
|
|
||||||
"or the address may be wrong."
|
|
||||||
case http.StatusServiceUnavailable:
|
|
||||||
return "The server is busy. Please try again in a moment."
|
|
||||||
default: // http.StatusInternalServerError
|
|
||||||
return "Something went wrong on the server. Please try " +
|
|
||||||
"again."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information for templates
|
// UserInfo represents user information for templates
|
||||||
@@ -295,17 +224,14 @@ func (s *Handlers) renderTemplate(
|
|||||||
"template not found",
|
"template not found",
|
||||||
"template", pageTemplate,
|
"template", pageTemplate,
|
||||||
)
|
)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageData adds the fields the shared layout renders to a page's own
|
|
||||||
// data.
|
|
||||||
func (s *Handlers) pageData(r *http.Request, data any) any {
|
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -319,16 +245,19 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
|
s.executeTemplate(w, tmpl, m)
|
||||||
|
|
||||||
return m
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return templateDataWrapper{
|
wrapper := templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.executeTemplate(w, tmpl, wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes to
|
// executeTemplate renders the template into a buffer and writes to
|
||||||
@@ -341,7 +270,6 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
|||||||
// this reason.
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
) {
|
) {
|
||||||
@@ -352,7 +280,10 @@ func (s *Handlers) executeTemplate(
|
|||||||
s.log.Error(
|
s.log.Error(
|
||||||
"failed to execute template", "error", err,
|
"failed to execute template", "error", err,
|
||||||
)
|
)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func TestHandleIndex_Authenticated(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "/sources", w2.Header().Get("Location"),
|
t, "/hooks", w2.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
t, http.StatusInternalServerError, w.Code,
|
t, http.StatusInternalServerError, w.Code,
|
||||||
"a failed render must report a 500",
|
"a failed render must report a 500",
|
||||||
)
|
)
|
||||||
assert.NotContains(
|
assert.Equal(
|
||||||
t, w.Body.String(), partialPageMarker,
|
t, "Internal server error\n", w.Body.String(),
|
||||||
"the response must carry no part of the aborted page",
|
"the response must carry no part of the aborted page",
|
||||||
)
|
)
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "500 Internal Server Error",
|
|
||||||
"a failed render must answer with the error page",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
|
r.Context(),
|
||||||
w,
|
w,
|
||||||
r,
|
|
||||||
sessionUsername,
|
sessionUsername,
|
||||||
// PostFormValue, not FormValue: the credential must
|
// PostFormValue, not FormValue: the credential must
|
||||||
// come from the body, never from the query string.
|
// come from the body, never from the query string.
|
||||||
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
// applyPasswordChange verifies the current password and, on success,
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
// persists a fresh hash for the user, reusing the same helpers that
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
// bootstrap the admin user. It returns the success and error messages
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
// to display on the profile page. On an internal failure it writes the
|
// to display on the profile page. On an internal failure it writes a
|
||||||
// error page itself and returns handled=false, signalling the caller
|
// 500 response itself and returns handled=false, signalling the caller
|
||||||
// to stop without re-rendering the page.
|
// to stop without re-rendering the page.
|
||||||
func (h *Handlers) applyPasswordChange(
|
func (h *Handlers) applyPasswordChange(
|
||||||
|
ctx context.Context,
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
username, currentPassword, newPassword, confirmPassword string,
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
// This endpoint verifies one password and hashes another, at
|
// This endpoint verifies one password and hashes another, at
|
||||||
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||||
// this path outside it would leave a hole in it. The slot is held
|
// this path outside it would leave a hole in it. The slot is held
|
||||||
// across both hashes.
|
// across both hashes.
|
||||||
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
release, ok := h.mw.BeginPasswordVerification(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Warn("password verification capacity exhausted")
|
h.log.Warn("password verification capacity exhausted")
|
||||||
h.renderError(w, r, http.StatusServiceUnavailable)
|
http.Error(
|
||||||
|
w,
|
||||||
|
"The server is busy verifying credentials. "+
|
||||||
|
"Please try again.",
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
).First(&user).Error
|
).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to load user for password change", err,
|
w, "failed to load user for password change", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
currentPassword, user.Password,
|
currentPassword, user.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.serverError(w, "failed to verify password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
|
|
||||||
hashedPassword, err := database.HashPassword(newPassword)
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to hash new password", err)
|
h.serverError(w, "failed to hash new password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
"password", hashedPassword,
|
"password", hashedPassword,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update password", err)
|
h.serverError(w, "failed to update password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
// unexpected retrieval error.
|
// unexpected retrieval error.
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.serverError(w, "failed to get session", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
h.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
h.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow users to act on their own profile.
|
// Only allow users to act on their own profile.
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
h.renderError(w, r, http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
var sess *session.Session
|
var sess *session.Session
|
||||||
|
|
||||||
var h *handlers.Handlers
|
app := newTestApp(t, &log, &cfg, &sess)
|
||||||
|
|
||||||
app := newTestApp(t, &log, &cfg, &sess, &h)
|
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -141,7 +139,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Route("/user/{username}", func(r chi.Router) {
|
router.Route("/user/{username}", func(r chi.Router) {
|
||||||
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(mw.CSRF())
|
||||||
r.Use(mw.RequireAuth())
|
r.Use(mw.RequireAuth())
|
||||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
handlerReached = true
|
handlerReached = true
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
|||||||
body := strings.Repeat("é", 1024)
|
body := strings.Repeat("é", 1024)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(), http.MethodPost, "/webhook/x",
|
context.Background(), http.MethodPost, "/h/x",
|
||||||
strings.NewReader(body),
|
strings.NewReader(body),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -267,7 +267,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -323,7 +323,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -337,7 +337,7 @@ func TestHandleSourceDelete_FailedDeleteKeepsEverything(
|
|||||||
)
|
)
|
||||||
assert.Empty(
|
assert.Empty(
|
||||||
t, w.Header().Get("Location"),
|
t, w.Header().Get("Location"),
|
||||||
"a failed deletion must not redirect to /sources",
|
"a failed deletion must not redirect to /hooks",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
@@ -402,7 +402,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/delete",
|
"/hook/"+wh.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{paramSourceID: wh.ID},
|
map[string]string{paramSourceID: wh.ID},
|
||||||
)
|
)
|
||||||
@@ -411,7 +411,7 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
|
|||||||
h.HandleSourceDelete().ServeHTTP(w, req)
|
h.HandleSourceDelete().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/sources", w.Header().Get("Location"))
|
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
@@ -465,7 +465,7 @@ func TestHandleTargetDelete_EvictsWhenLastDatabaseTargetGone(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
@@ -515,7 +515,7 @@ func TestHandleTargetDelete_KeepsWriterWhenDatabaseTargetRemains(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+doomed.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
@@ -563,7 +563,7 @@ func TestHandleTargetDelete_KeepsWriterWhenOtherTypeDeleted(
|
|||||||
)
|
)
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+wh.ID+"/targets/"+other.ID+"/delete",
|
"/hook/"+wh.ID+"/targets/"+other.ID+"/delete",
|
||||||
cookies,
|
cookies,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
paramSourceID: wh.ID,
|
paramSourceID: wh.ID,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (f *baseURLFixture) entrypointURL(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+f.webhook,
|
"/hook/"+f.webhook,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
req.Host = host
|
req.Host = host
|
||||||
@@ -213,7 +213,7 @@ func TestSourceDetailBaseURL_ForwardedProtoSpellings(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
tc.scheme+"://"+host+"/webhook/"+fixture.path,
|
tc.scheme+"://"+host+"/h/"+fixture.path,
|
||||||
fixture.entrypointURL(
|
fixture.entrypointURL(
|
||||||
t, host, forwardedProto(tc.header),
|
t, host, forwardedProto(tc.header),
|
||||||
),
|
),
|
||||||
@@ -244,7 +244,7 @@ func TestSourceDetailBaseURL_DirectTLSBeatsPlaintextHeader(
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://"+host+"/webhook/"+fixture.path,
|
"https://"+host+"/h/"+fixture.path,
|
||||||
got,
|
got,
|
||||||
"a connection this process terminated with TLS "+
|
"a connection this process terminated with TLS "+
|
||||||
"outranks a header claiming plaintext",
|
"outranks a header claiming plaintext",
|
||||||
@@ -272,7 +272,7 @@ func TestSourceDetailBaseURL_KeepsHostAuthority(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
"https://"+host+"/webhook/"+fixture.path,
|
"https://"+host+"/h/"+fixture.path,
|
||||||
fixture.entrypointURL(
|
fixture.entrypointURL(
|
||||||
t, host, forwardedProto("HTTPS"),
|
t, host, forwardedProto("HTTPS"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func serveSourceDetailPage(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+webhookID,
|
"/hook/"+webhookID,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func deleteTargetThroughHandler(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := postRequest(
|
req := postRequest(
|
||||||
"/source/"+webhookID+"/targets/"+targetID+"/delete",
|
"/hook/"+webhookID+"/targets/"+targetID+"/delete",
|
||||||
authenticatedCookies(
|
authenticatedCookies(
|
||||||
t, sess, deleteTestUserID, deleteTestUsername,
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func renderSourceLogsPageWithQuery(
|
|||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
"/source/"+webhookID+"/logs"+query,
|
"/hook/"+webhookID+"/events"+query,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
"user_id = ?", userID,
|
"user_id = ?", userID,
|
||||||
).Order("created_at DESC").Find(&webhooks).Error
|
).Order("created_at DESC").Find(&webhooks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to list webhooks", err)
|
h.log.Error(
|
||||||
|
"failed to list webhooks", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,7 +249,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,7 +311,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
err := h.commitWebhook(webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create webhook", err)
|
h.serverError(w, "failed to create webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -322,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +388,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -412,7 +420,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -421,7 +429,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load recent events", err)
|
h.serverError(w, "failed to load recent events", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -474,7 +482,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -509,7 +517,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -518,7 +526,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -572,13 +582,13 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
|
|
||||||
err := h.db.DB().Save(webhook).Error
|
err := h.db.DB().Save(webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update webhook", err)
|
h.serverError(w, "failed to update webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,7 +612,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -629,7 +639,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// be removed by hand; deleted history cannot be recovered.
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
err := h.commitWebhookDeletion(&webhook)
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to delete webhook", err)
|
h.serverError(w, "failed to delete webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -655,13 +665,13 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// redirecting as though everything succeeded: the file
|
// redirecting as though everything succeeded: the file
|
||||||
// needs removing by hand, and the logged error names it.
|
// needs removing by hand, and the logged error names it.
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to delete webhook event database", err,
|
w, "failed to delete webhook event database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
@@ -799,7 +809,7 @@ func (h *Handlers) ownedWebhook(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return database.Webhook{}, false
|
return database.Webhook{}, false
|
||||||
}
|
}
|
||||||
@@ -821,7 +831,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
// Without the map every delivery renders through a
|
// Without the map every delivery renders through a
|
||||||
// zero redactor, so failing the page is the only
|
// zero redactor, so failing the page is the only
|
||||||
// safe answer.
|
// safe answer.
|
||||||
h.serverError(w, r, "failed to load targets", err)
|
h.serverError(w, "failed to load targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -829,7 +839,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
page := h.parsePage(r)
|
page := h.parsePage(r)
|
||||||
|
|
||||||
evts, total, ok := h.loadEventsWithDeliveries(
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
w, r, webhook, targets, page,
|
w, webhook, targets, page,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -939,7 +949,6 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
// caller must then render nothing further.
|
// caller must then render nothing further.
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
@@ -953,7 +962,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to get webhook database", err,
|
w, "failed to get webhook database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -990,7 +999,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to load delivery attempts", err,
|
w, "failed to load delivery attempts", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -999,7 +1008,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count event resubmissions", err,
|
w, "failed to count event resubmissions", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1222,7 +1231,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1240,9 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1247,13 +1258,13 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
|
|
||||||
err = h.db.DB().Create(entrypoint).Error
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create entrypoint", err)
|
h.serverError(w, "failed to create entrypoint", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1289,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1298,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1307,7 +1320,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
//
|
//
|
||||||
// Every field here is read with PostFormValue, not FormValue.
|
// Every field here is read with PostFormValue, not FormValue.
|
||||||
// FormValue falls back to the query string, which would let
|
// FormValue falls back to the query string, which would let
|
||||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
// configure a target from a value the request line carries — and
|
// configure a target from a value the request line carries — and
|
||||||
// the request line, unlike the body, is what logs, proxies,
|
// the request line, unlike the body, is what logs, proxies,
|
||||||
// Referer headers and error trackers record.
|
// Referer headers and error trackers record.
|
||||||
@@ -1358,13 +1371,13 @@ func (h *Handlers) processTargetCreate(
|
|||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create target", err)
|
h.serverError(w, "failed to create target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,7 +1435,7 @@ type targetFormInput struct {
|
|||||||
//
|
//
|
||||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||||
// falls back to the query string, which would let
|
// falls back to the query string, which would let
|
||||||
// `POST /source/{id}/targets?url=https://hooks.slack.com/...`
|
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
||||||
// configure a target from a value the request line carries — and the
|
// configure a target from a value the request line carries — and the
|
||||||
// request line, unlike the body, is what logs, proxies, Referer
|
// request line, unlike the body, is what logs, proxies, Referer
|
||||||
// headers and error trackers record. The headers field is under the
|
// headers and error trackers record. The headers field is under the
|
||||||
@@ -1452,7 +1465,7 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
@@ -1502,7 +1515,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -1524,7 +1537,7 @@ func (h *Handlers) buildSlackTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
return marshalTargetConfig(w, delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1578,14 +1591,16 @@ func (h *Handlers) validateTargetURL(
|
|||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage,
|
||||||
// writing a 500 itself if it cannot.
|
// writing a 500 itself if it cannot.
|
||||||
func (h *Handlers) marshalTargetConfig(
|
func marshalTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
cfg any,
|
cfg any,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to encode target config", err)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1601,7 +1616,6 @@ func (h *Handlers) marshalTargetConfig(
|
|||||||
// expiry yields an empty config (the keep-forever default).
|
// expiry yields an empty config (the keep-forever default).
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
func (h *Handlers) buildDatabaseTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
@@ -1620,8 +1634,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(
|
return marshalTargetConfig(
|
||||||
w, r, map[string]any{"expiry": expiry},
|
w, map[string]any{"expiry": expiry},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1675,7 +1689,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1685,7 +1699,11 @@ func (h *Handlers) deleteChildResource(
|
|||||||
childID, webhook.ID,
|
childID, webhook.ID,
|
||||||
).Delete(model)
|
).Delete(model)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
h.serverError(w, r, errMsg, result.Error)
|
h.log.Error(errMsg, "error", result.Error)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1696,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/source/"+webhook.ID,
|
"/hook/"+webhook.ID,
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1775,21 +1793,25 @@ func (h *Handlers) toggleChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = toggleFn(webhook.ID, childID)
|
err = toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, errMsg, err)
|
h.log.Error(errMsg, "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -88,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,13 +157,13 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
|
|
||||||
err = h.db.DB().Save(target).Error
|
err = h.db.DB().Save(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update target", err)
|
h.serverError(w, "failed to update target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +220,7 @@ func (h *Handlers) ownedTarget(
|
|||||||
chi.URLParam(r, "targetID"), webhook.ID,
|
chi.URLParam(r, "targetID"), webhook.ID,
|
||||||
).First(&target).Error
|
).First(&target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return database.Webhook{}, nil, false
|
return database.Webhook{}, nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`,
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
|
|
||||||
headersJSON, err := json.Marshal(r.Header)
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to serialize headers", err)
|
h.serverError(w, "failed to serialize headers", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to query targets", err)
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to store webhook event", err)
|
h.serverError(w, "failed to store webhook event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiverError logs an error and answers the sender with a plain-text
|
|
||||||
// 500. The receiver's answers are for programs, so it never sends the
|
|
||||||
// error page the web UI uses.
|
|
||||||
func (h *Handlers) receiverError(
|
|
||||||
w http.ResponseWriter, msg string, err error,
|
|
||||||
) {
|
|
||||||
h.log.Error(msg, "error", err)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventSource carries the fields a new event is built from. The
|
// eventSource carries the fields a new event is built from. The
|
||||||
// receiver fills it from the live request; the resubmit handler fills
|
// receiver fills it from the live request; the resubmit handler fills
|
||||||
// it from a stored event. Both then go through createAndFanOut, so an
|
// it from a stored event. Both then go through createAndFanOut, so an
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestTruncate_LeavesShortValuesAlone(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, s := range []string{
|
for _, s := range []string{
|
||||||
"", "GET", "/source/abc/edit", "Mozilla/5.0 (X11)",
|
"", "GET", "/hook/abc/edit", "Mozilla/5.0 (X11)",
|
||||||
} {
|
} {
|
||||||
assert.Equal(t, s, logfield.Truncate(s, budget))
|
assert.Equal(t, s, logfield.Truncate(s, budget))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux {
|
|||||||
)
|
)
|
||||||
|
|
||||||
router.HandleFunc(
|
router.HandleFunc(
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Stands in for the real handler: an unknown entrypoint
|
// Stands in for the real handler: an unknown entrypoint
|
||||||
// UUID 404s, a known one succeeds.
|
// UUID 404s, a known one succeeds.
|
||||||
@@ -271,11 +271,11 @@ func TestAccessLog_InventedReceiverPathsLogRoutePattern(t *testing.T) {
|
|||||||
assertFloodIsBounded(
|
assertFloodIsBounded(
|
||||||
t,
|
t,
|
||||||
func(i int) string {
|
func(i int) string {
|
||||||
return "/webhook/" + attackerMarker +
|
return "/h/" + attackerMarker +
|
||||||
strings.Repeat("x", i) + "?q=" + attackerMarker
|
strings.Repeat("x", i) + "?q=" + attackerMarker
|
||||||
},
|
},
|
||||||
http.StatusNotFound,
|
http.StatusNotFound,
|
||||||
"/webhook/{uuid}",
|
"/h/{uuid}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,10 +346,10 @@ type sizeCase struct {
|
|||||||
func lineSizeCases() map[string]sizeCase {
|
func lineSizeCases() map[string]sizeCase {
|
||||||
cases := map[string]sizeCase{
|
cases := map[string]sizeCase{
|
||||||
"oversized path segment": {
|
"oversized path segment": {
|
||||||
target: "/webhook/" + attackerMarker +
|
target: "/h/" + attackerMarker +
|
||||||
strings.Repeat("x", oversizedSegmentBytes),
|
strings.Repeat("x", oversizedSegmentBytes),
|
||||||
wantStatus: http.StatusNotFound,
|
wantStatus: http.StatusNotFound,
|
||||||
wantURL: "/webhook/{uuid}",
|
wantURL: "/h/{uuid}",
|
||||||
bound: maxLineBytes,
|
bound: maxLineBytes,
|
||||||
},
|
},
|
||||||
// /.well-known/healthcheck answers 200 to anyone and has no
|
// /.well-known/healthcheck answers 200 to anyone and has no
|
||||||
@@ -605,14 +605,14 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery(
|
|||||||
router := accessLogRouter(m)
|
router := accessLogRouter(m)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusOK, get(t, router, "/webhook/known?src=ci"),
|
t, http.StatusOK, get(t, router, "/h/known?src=ci"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// The path resolved against a stored entrypoint, so it stays. The
|
// The path resolved against a stored entrypoint, so it stays. The
|
||||||
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
// query never does: see TestAccessLog_UnauthenticatedSuccess...
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "/webhook/known?(redacted)", entries[0]["url"])
|
assert.Equal(t, "/h/known?(redacted)", entries[0]["url"])
|
||||||
assert.NotContains(t, buf.String(), "src=ci")
|
assert.NotContains(t, buf.String(), "src=ci")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +640,7 @@ func TestAccessLog_RetainsEveryOtherField(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
http.StatusNotFound,
|
http.StatusNotFound,
|
||||||
get(t, router, "/webhook/"+attackerMarker),
|
get(t, router, "/h/"+attackerMarker),
|
||||||
)
|
)
|
||||||
|
|
||||||
entries := accessLogEntries(t, buf)
|
entries := accessLogEntries(t, buf)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
// token are logged and answered by forbidden, which must write the 403.
|
// token receive a 403 Forbidden response.
|
||||||
//
|
//
|
||||||
// The middleware detects the client-facing transport protocol
|
// The middleware detects the client-facing transport protocol
|
||||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||||
@@ -36,14 +36,12 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||||
// csrf.Secure option is set at creation time, not per-request.
|
// csrf.Secure option is set at creation time, not per-request.
|
||||||
func (m *Middleware) CSRF(
|
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||||
forbidden http.Handler,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 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
|
||||||
@@ -59,7 +57,7 @@ func (m *Middleware) CSRF(
|
|||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"reason", csrf.FailureReason(r),
|
"reason", csrf.FailureReason(r),
|
||||||
)
|
)
|
||||||
forbidden.ServeHTTP(w, r)
|
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
key := m.session.GetKey()
|
key := m.session.GetKey()
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ import (
|
|||||||
// csrfCookieName is the gorilla/csrf cookie name.
|
// csrfCookieName is the gorilla/csrf cookie name.
|
||||||
const csrfCookieName = "_gorilla_csrf"
|
const csrfCookieName = "_gorilla_csrf"
|
||||||
|
|
||||||
// forbidden stands in for the error page the server hands CSRF to
|
|
||||||
// answer a refused request with.
|
|
||||||
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfGetToken performs a GET request through the CSRF middleware
|
// csrfGetToken performs a GET request through the CSRF middleware
|
||||||
// and returns the token and cookies.
|
// and returns the token and cookies.
|
||||||
func csrfGetToken(
|
func csrfGetToken(
|
||||||
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
|||||||
|
|
||||||
var gotToken string
|
var gotToken string
|
||||||
|
|
||||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
handler := m.CSRF()(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, r *http.Request) {
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
gotToken = middleware.CSRFToken(r)
|
gotToken = middleware.CSRFToken(r)
|
||||||
},
|
},
|
||||||
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
|||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
|
|
||||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
handler := m.CSRF()(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
called = true
|
called = true
|
||||||
},
|
},
|
||||||
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
newReq := func(method string) *http.Request {
|
newReq := func(method string) *http.Request {
|
||||||
r := httptest.NewRequestWithContext(
|
r := httptest.NewRequestWithContext(
|
||||||
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
|
|||||||
) http.Handler {
|
) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return m.CSRF(http.HandlerFunc(forbidden))(
|
return m.CSRF()(unreachable(t))
|
||||||
unreachable(t),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
@@ -385,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(
|
||||||
@@ -436,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,
|
||||||
)
|
)
|
||||||
@@ -501,7 +499,7 @@ func TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog(
|
|||||||
http.StatusRequestEntityTooLarge,
|
http.StatusRequestEntityTooLarge,
|
||||||
postOversize(
|
postOversize(
|
||||||
h,
|
h,
|
||||||
"/source/"+segment(i)+"/edit",
|
"/hook/"+segment(i)+"/edit",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ type failureWindow struct {
|
|||||||
//
|
//
|
||||||
// A limiter that spends budget on arrival cannot protect a
|
// A limiter that spends budget on arrival cannot protect a
|
||||||
// single-admin product: behind the reverse proxy the deployment
|
// single-admin product: behind the reverse proxy the deployment
|
||||||
// requires, when TRUSTED_PROXIES does not cover it, every client
|
// requires, with TRUSTED_PROXIES unset, every client keys on the
|
||||||
// keys on the proxy, so a stranger trickling five POSTs a minute
|
// proxy, so a stranger trickling five POSTs a minute keeps the one
|
||||||
// keeps the one bucket full and the operator's own correct password
|
// bucket full and the operator's own correct password is answered 429
|
||||||
// is answered 429 forever. There is no second administrative path.
|
// forever. There is no second administrative path.
|
||||||
//
|
//
|
||||||
// So budget is spent only by a FAILED verification. A correct
|
// So budget is spent only by a FAILED verification. A correct
|
||||||
// password is never throttled, whatever the counters say, which is
|
// password is never throttled, whatever the counters say, which is
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const unmatchedMethod = unmatchedRoute
|
|||||||
//
|
//
|
||||||
// The pattern is what bounds the label's domain to the routes the
|
// The pattern is what bounds the label's domain to the routes the
|
||||||
// service registers. The path does not bound it at all — every byte
|
// service registers. The path does not bound it at all — every byte
|
||||||
// after /webhook/ is client-chosen, so labelling by path lets any
|
// after /h/ is client-chosen, so labelling by path lets any
|
||||||
// unauthenticated client mint permanent series at will, and publishes
|
// unauthenticated client mint permanent series at will, and publishes
|
||||||
// the entrypoint UUID (the receiver's only credential) in the scrape
|
// the entrypoint UUID (the receiver's only credential) in the scrape
|
||||||
// while doing it.
|
// while doing it.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func realMethods() []string {
|
|||||||
// dimension varying, so any series growth a probe produces is the
|
// dimension varying, so any series growth a probe produces is the
|
||||||
// method label's and nothing else's.
|
// method label's and nothing else's.
|
||||||
func methodProbePath() string {
|
func methodProbePath() string {
|
||||||
return "/webhook/" + uuid.NewString()
|
return "/h/" + uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
// inventedMethods returns n distinct RFC 9110 method tokens that no
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
|
|
||||||
// receiverRoutePattern is the one handler label every receiver
|
// receiverRoutePattern is the one handler label every receiver
|
||||||
// request must produce, however the client varies the path.
|
// request must produce, however the client varies the path.
|
||||||
receiverRoutePattern = "/webhook/{uuid}"
|
receiverRoutePattern = "/h/{uuid}"
|
||||||
|
|
||||||
// okRoute is a static route used to pin that the response-writer
|
// okRoute is a static route used to pin that the response-writer
|
||||||
// interceptor still reports status and size after the handler id
|
// interceptor still reports status and size after the handler id
|
||||||
@@ -143,13 +143,13 @@ func drivePaths(
|
|||||||
return drive(t, h, probes)
|
return drive(t, h, probes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiverPaths returns n distinct /webhook/ paths, each naming a
|
// receiverPaths returns n distinct /h/ paths, each naming a
|
||||||
// fresh UUID exactly as an unauthenticated flood would.
|
// fresh UUID exactly as an unauthenticated flood would.
|
||||||
func receiverPaths(n int) []string {
|
func receiverPaths(n int) []string {
|
||||||
paths := make([]string, 0, n)
|
paths := make([]string, 0, n)
|
||||||
|
|
||||||
for range n {
|
for range n {
|
||||||
paths = append(paths, "/webhook/"+uuid.NewString())
|
paths = append(paths, "/h/"+uuid.NewString())
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
@@ -220,7 +220,7 @@ func keys(set map[string]struct{}) []string {
|
|||||||
|
|
||||||
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
// TestMetrics_DistinctReceiverPathsMintOneLabelSet is the direct
|
||||||
// assertion the issue asks for: N requests to N distinct
|
// assertion the issue asks for: N requests to N distinct
|
||||||
// /webhook/<uuid> paths must produce exactly ONE handler label, the
|
// /h/<uuid> paths must produce exactly ONE handler label, the
|
||||||
// route pattern. Before the fix this produced N of them.
|
// route pattern. Before the fix this produced N of them.
|
||||||
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -250,7 +250,7 @@ func TestMetrics_DistinctReceiverPathsMintOneLabelSet(t *testing.T) {
|
|||||||
// The scrape must not republish the UUIDs it was driven with.
|
// The scrape must not republish the UUIDs it was driven with.
|
||||||
// They are the receiver's only credential.
|
// They are the receiver's only credential.
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
id := strings.TrimPrefix(p, "/webhook/")
|
id := strings.TrimPrefix(p, "/h/")
|
||||||
for label := range labels {
|
for label := range labels {
|
||||||
assert.NotContains(
|
assert.NotContains(
|
||||||
t, label, id,
|
t, label, id,
|
||||||
@@ -354,7 +354,7 @@ func TestMetrics_UnmatchedPathsCollapseToTheSentinel(t *testing.T) {
|
|||||||
if i%2 == 0 {
|
if i%2 == 0 {
|
||||||
paths = append(paths, "/"+id)
|
paths = append(paths, "/"+id)
|
||||||
} else {
|
} else {
|
||||||
paths = append(paths, "/webhook/"+id+"/"+id)
|
paths = append(paths, "/h/"+id+"/"+id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ func concreteLogURL(r *http.Request) string {
|
|||||||
//
|
//
|
||||||
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
// 3xx and 4xx responses get the chi route pattern instead. Those are
|
||||||
// the outcomes an unauthenticated client drives for free: 404 or 429
|
// the outcomes an unauthenticated client drives for free: 404 or 429
|
||||||
// on any invented /webhook/ path, 303 to the login page on any
|
// on any invented /h/ path, 303 to the login page on any
|
||||||
// invented /user/ path. Logging the concrete URL there lets a flood
|
// invented /user/ path. Logging the concrete URL there lets a flood
|
||||||
// write attacker-chosen text, of attacker-chosen length, into the
|
// write attacker-chosen text, of attacker-chosen length, into the
|
||||||
// operator's log at one line per request. The pattern comes from the
|
// operator's log at one line per request. The pattern comes from the
|
||||||
@@ -560,7 +560,7 @@ func (s *Middleware) MaxBodySize(
|
|||||||
// internal/server/routes.go), so an
|
// internal/server/routes.go), so an
|
||||||
// unauthenticated client reaches it with a path
|
// unauthenticated client reaches it with a path
|
||||||
// of its own choosing and its own length —
|
// of its own choosing and its own length —
|
||||||
// POST /source/<8 KB>/edit with an oversize
|
// POST /hook/<8 KB>/edit with an oversize
|
||||||
// declared Content-Length costs nothing to
|
// declared Content-Length costs nothing to
|
||||||
// send. At WARN, on by default, that is a
|
// send. At WARN, on by default, that is a
|
||||||
// write into the operator's log sized by the
|
// write into the operator's log sized by the
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet, "/sources", nil,
|
http.MethodGet, "/hooks", nil,
|
||||||
)
|
)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const (
|
|||||||
|
|
||||||
// receiverAggregateMultiplier scales the configured
|
// receiverAggregateMultiplier scales the configured
|
||||||
// per-entrypoint receiver limit into the aggregate limit one
|
// per-entrypoint receiver limit into the aggregate limit one
|
||||||
// client IP may spend across the whole /webhook/* route. Ten
|
// client IP may spend across the whole /h/* route. Ten
|
||||||
// entrypoints' worth lets a single sender address drive several
|
// entrypoints' worth lets a single sender address drive several
|
||||||
// entrypoints at their full rate, while still capping what one
|
// entrypoints at their full rate, while still capping what one
|
||||||
// address costs the unauthenticated receiver.
|
// address costs the unauthenticated receiver.
|
||||||
@@ -123,8 +123,9 @@ func bucketKey(addr netip.Addr) string {
|
|||||||
return prefix.String()
|
return prefix.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTrustedProxy reports whether addr belongs to a network in
|
// isTrustedProxy reports whether addr belongs to a network the
|
||||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
||||||
|
// so by default nothing is trusted.
|
||||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||||
for _, prefix := range m.params.Config.TrustedProxies {
|
for _, prefix := range m.params.Config.TrustedProxies {
|
||||||
if prefix.Contains(addr) {
|
if prefix.Contains(addr) {
|
||||||
@@ -389,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)
|
||||||
@@ -384,8 +384,8 @@ const (
|
|||||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||||
// tests configure, and trustedPeer an address inside it. A
|
// tests configure, and trustedPeer an address inside it. A
|
||||||
// production deployment is required to run behind a reverse
|
// production deployment is required to run behind a reverse
|
||||||
// proxy that TRUSTED_PROXIES covers, either by the default or by
|
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||||
// a set value, so this is the shape the bucketing has to hold in.
|
// bucketing has to hold in.
|
||||||
trustedProxyCIDR = "10.0.0.0/8"
|
trustedProxyCIDR = "10.0.0.0/8"
|
||||||
trustedPeer = "10.0.0.1:44444"
|
trustedPeer = "10.0.0.1:44444"
|
||||||
)
|
)
|
||||||
@@ -426,8 +426,8 @@ func assertSharedBucket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
||||||
// this gating exists for: from a peer that is not a trusted
|
// this gating exists for: with no trusted proxies configured (the
|
||||||
// proxy, a client that rotates a forwarded header on every
|
// default), a client that rotates a forwarded header on every
|
||||||
// request must stay in one bucket. If forwarded headers were
|
// request must stay in one bucket. If forwarded headers were
|
||||||
// trusted unconditionally, each spoofed value would mint a fresh
|
// trusted unconditionally, each spoofed value would mint a fresh
|
||||||
// bucket and the limit would stop no one.
|
// bucket and the limit would stop no one.
|
||||||
@@ -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)
|
||||||
@@ -1097,9 +1097,8 @@ func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
|||||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||||
// names forwarded as its client in X-Forwarded-For. That is the
|
// names forwarded as its client in X-Forwarded-For. That is the
|
||||||
// production path: a deployment is required to run behind a reverse
|
// production path: a deployment is required to run behind a reverse
|
||||||
// proxy that TRUSTED_PROXIES covers, either by the default or by a
|
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||||
// set value, so the forwarded address, not the peer, is what the
|
// peer, is what the limiters bucket on there.
|
||||||
// limiters bucket on there.
|
|
||||||
func forwardedKeyFor(
|
func forwardedKeyFor(
|
||||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||||
) string {
|
) string {
|
||||||
@@ -1179,9 +1178,9 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// Every existing test of this fallback uses an IPv4 proxy, where
|
// Every existing test of this fallback uses an IPv4 proxy, where
|
||||||
// bucketKey is the identity function, so replacing the call with
|
// bucketKey is the identity function, so replacing the call with
|
||||||
// peer.String() leaves the whole suite green. Only addresses inside
|
// peer.String() leaves the whole suite green. Only operator-listed
|
||||||
// TRUSTED_PROXIES reach this line and the fallback is fail-closed, so
|
// addresses reach this line and the fallback is fail-closed, so this
|
||||||
// this pins behaviour rather than fixing a defect.
|
// pins behaviour rather than fixing a defect.
|
||||||
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
|
|
||||||
// Recoverer returns middleware that turns a handler panic into one
|
// Recoverer returns middleware that turns a handler panic into one
|
||||||
// structured ERROR record and a 500, rather than a dropped
|
// structured ERROR record and a 500, rather than a dropped
|
||||||
// connection. The 500 is page when page is not nil, and plain text
|
// connection.
|
||||||
// when it is nil or when page panics before writing anything.
|
|
||||||
//
|
//
|
||||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||||
@@ -139,9 +138,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
// set before panicking, because a request that failed must not hand
|
// set before panicking, because a request that failed must not hand
|
||||||
// the client a credential; every other header is left to http.Error.
|
// the client a credential; every other header is left to http.Error.
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||||
func (s *Middleware) Recoverer(
|
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||||
page http.Handler,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
@@ -174,14 +171,6 @@ func (s *Middleware) Recoverer(
|
|||||||
|
|
||||||
rw.Header().Del("Set-Cookie")
|
rw.Header().Del("Set-Cookie")
|
||||||
|
|
||||||
if page != nil {
|
|
||||||
s.servePage(rw, r, page)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rw.committed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(
|
http.Error(
|
||||||
rw,
|
rw,
|
||||||
http.StatusText(
|
http.StatusText(
|
||||||
@@ -196,27 +185,6 @@ func (s *Middleware) Recoverer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// servePage answers with page. A panic in page itself is logged and
|
|
||||||
// recovered here, so the Recoverer can still send its plain 500.
|
|
||||||
func (s *Middleware) servePage(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
page http.Handler,
|
|
||||||
) {
|
|
||||||
defer func() {
|
|
||||||
rvr := recover()
|
|
||||||
if rvr != nil {
|
|
||||||
s.log.Error("error page panic",
|
|
||||||
"panic", logfield.Truncate(
|
|
||||||
fmt.Sprint(rvr), maxPanicValueBytes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
page.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logPanic writes the record. Every field it can grow is truncated to
|
// logPanic writes the record. Every field it can grow is truncated to
|
||||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
|||||||
// Logging outside so the recovered 500 is the status it records.
|
// Logging outside so the recovered 500 is the status it records.
|
||||||
router.Use(chimw.RequestID)
|
router.Use(chimw.RequestID)
|
||||||
router.Use(m.Logging())
|
router.Use(m.Logging())
|
||||||
router.Use(m.Recoverer(nil))
|
router.Use(m.Recoverer())
|
||||||
router.Get("/probe", handler)
|
router.Get("/probe", handler)
|
||||||
|
|
||||||
serverErrors := new(bytes.Buffer)
|
serverErrors := new(bytes.Buffer)
|
||||||
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
|
|
||||||
m, _ := capturingMiddleware(t)
|
m, _ := capturingMiddleware(t)
|
||||||
|
|
||||||
handler := m.Recoverer(nil)(http.HandlerFunc(
|
handler := m.Recoverer()(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte("chunk"))
|
_, _ = w.Write([]byte("chunk"))
|
||||||
|
|
||||||
@@ -672,59 +672,3 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, "chunk", string(body))
|
assert.Equal(t, "chunk", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
|
||||||
// the panic is logged as before, and the 500 is that page.
|
|
||||||
func TestRecovererAnswersWithThePage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, logs := capturingMiddleware(t)
|
|
||||||
|
|
||||||
page := http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte("the error page"))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
|
||||||
w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "the error page", w.Body.String())
|
|
||||||
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
|
||||||
assert.Contains(t, logs.String(), panicMarker)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
|
||||||
// before writing anything: both panics are logged, and the client
|
|
||||||
// still gets the plain 500.
|
|
||||||
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, logs := capturingMiddleware(t)
|
|
||||||
|
|
||||||
const pagePanic = "QQERRORPAGEPANICQQ"
|
|
||||||
|
|
||||||
page := http.HandlerFunc(
|
|
||||||
func(http.ResponseWriter, *http.Request) {
|
|
||||||
panic(pagePanic)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
|
||||||
w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
assert.Contains(t, logs.String(), panicMarker)
|
|
||||||
assert.Contains(t, logs.String(), pagePanic)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The link back the error page offers: to the webhook list for a
|
|
||||||
// signed-in user, to sign-in for anyone else.
|
|
||||||
const (
|
|
||||||
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
|
|
||||||
`Back to webhooks</a>`
|
|
||||||
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
|
||||||
`Sign in</a>`
|
|
||||||
)
|
|
||||||
|
|
||||||
// assertErrorPage checks that w is the error page for status, in the
|
|
||||||
// normal layout, offering link.
|
|
||||||
func assertErrorPage(
|
|
||||||
t *testing.T,
|
|
||||||
w *httptest.ResponseRecorder,
|
|
||||||
status int,
|
|
||||||
link string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
assert.Equal(t, status, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
|
||||||
)
|
|
||||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
|
||||||
assert.Contains(t, body, `<nav class="app-bar"`)
|
|
||||||
assert.Contains(
|
|
||||||
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
|
||||||
)
|
|
||||||
assert.Contains(t, body, link)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
require.NoError(t, env.db.DB().Delete(wh).Error)
|
|
||||||
|
|
||||||
w := env.get("/source/"+wh.ID, cookies)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_DeletedTarget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
|
||||||
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
|
||||||
|
|
||||||
w := env.get(
|
|
||||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.get("/no-such-page", nil),
|
|
||||||
http.StatusNotFound, backToSignIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Outside every route group there is no form token, so the
|
|
||||||
// page leaves out the logout form rather than offer one that
|
|
||||||
// would be refused.
|
|
||||||
w := env.get("/no-such-page", cookies)
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
|
||||||
|
|
||||||
// Inside a route group the page has a token, and logout works.
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", "someone")
|
|
||||||
form.Set("password", "irrelevant")
|
|
||||||
form.Set("csrf_token", "not-a-token")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.post("/pages/login", form, nil),
|
|
||||||
http.StatusForbidden, backToSignIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
|
|
||||||
edit := url.Values{}
|
|
||||||
edit.Set("name", "renamed")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
|
|
||||||
http.StatusForbidden, backToWebhooks,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
|
||||||
// admin page route group through the real router, with error
|
|
||||||
// tracking on: the client gets the 500 error page, and the tracker
|
|
||||||
// still gets the panic, once. The same panic outside the admin page
|
|
||||||
// route groups keeps the plain 500.
|
|
||||||
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
transport := &captureTransport{}
|
|
||||||
|
|
||||||
opts := server.SentryClientOptionsForTest(
|
|
||||||
"https://public@sentry.invalid/1", "webhooker-test",
|
|
||||||
)
|
|
||||||
opts.Transport = transport
|
|
||||||
|
|
||||||
client, err := sentry.NewClient(opts)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
sentry.SetHubOnContext(
|
|
||||||
context.Background(),
|
|
||||||
sentry.NewHub(client, sentry.NewScope()),
|
|
||||||
),
|
|
||||||
http.MethodGet, path, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
w := serve(
|
|
||||||
server.NewRouterWithPageProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
true, panicProbeHandler,
|
|
||||||
),
|
|
||||||
server.PageProbePattern,
|
|
||||||
)
|
|
||||||
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
|
||||||
|
|
||||||
w = serve(
|
|
||||||
server.NewRouterWithProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
true, panicProbeHandler,
|
|
||||||
),
|
|
||||||
server.ProbePattern,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
|
|
||||||
require.Len(t, transport.events, 2)
|
|
||||||
|
|
||||||
for _, event := range transport.events {
|
|
||||||
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
|
||||||
// the web UI only: a sender posting to an entrypoint that does not
|
|
||||||
// exist still gets the plain-text answer.
|
|
||||||
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// newTestEnv leaves the receiver rate limit at zero, which
|
|
||||||
// refuses every request before it reaches the receiver.
|
|
||||||
env := newTestEnvWithConfig(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
ReceiverRateLimit: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "404 page not found\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
@@ -102,39 +101,3 @@ func NewRouterWithProbeForTest(
|
|||||||
|
|
||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
|
||||||
// probe: inside the /pages route group, the admin page group a
|
|
||||||
// request reaches without signing in.
|
|
||||||
const PageProbePattern = "/pages/probe"
|
|
||||||
|
|
||||||
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
|
||||||
// probe added to the /pages route group once SetupRoutes has built
|
|
||||||
// it, so the probe runs behind that group's own middleware exactly as
|
|
||||||
// the group's real routes do.
|
|
||||||
func NewRouterWithPageProbeForTest(
|
|
||||||
log *slog.Logger,
|
|
||||||
cfg *config.Config,
|
|
||||||
mw *middleware.Middleware,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sentryEnabled bool,
|
|
||||||
probe http.HandlerFunc,
|
|
||||||
) http.Handler {
|
|
||||||
s := &Server{
|
|
||||||
log: log,
|
|
||||||
mw: mw,
|
|
||||||
h: h,
|
|
||||||
params: ServerParams{Config: cfg},
|
|
||||||
}
|
|
||||||
s.sentryEnabled.Store(sentryEnabled)
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
for _, route := range s.router.Routes() {
|
|
||||||
pages, ok := route.SubRoutes.(chi.Router)
|
|
||||||
if ok && route.Pattern == "/pages/*" {
|
|
||||||
pages.Get("/probe", probe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.router
|
|
||||||
}
|
|
||||||
|
|||||||
+22
-53
@@ -16,8 +16,8 @@ import (
|
|||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
||||||
// its first middleware after the recoverer, ahead of both CSRF and
|
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
||||||
// RequireAuth. Both orderings are deliberate.
|
// orderings are deliberate.
|
||||||
//
|
//
|
||||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||||
// be installed before anything reads the body, or the parse runs
|
// be installed before anything reads the body, or the parse runs
|
||||||
@@ -46,14 +46,6 @@ const requestTimeout = 60 * time.Second
|
|||||||
// server's router.
|
// server's router.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
// An unknown path gets the error page. Registered before the
|
|
||||||
// global middleware, because chi wraps a not-found handler in the
|
|
||||||
// middleware already on its router, which would then run twice.
|
|
||||||
// The route groups below wrap it in their own middleware the same
|
|
||||||
// way; running theirs twice is harmless.
|
|
||||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
|
||||||
|
|
||||||
s.setupGlobalMiddleware()
|
s.setupGlobalMiddleware()
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
}
|
}
|
||||||
@@ -77,33 +69,23 @@ func (s *Server) setupGlobalMiddleware() {
|
|||||||
// Panic recovery, deliberately here rather than first. It has to
|
// Panic recovery, deliberately here rather than first. It has to
|
||||||
// run inside every middleware that observes the response, so the
|
// run inside every middleware that observes the response, so the
|
||||||
// 500 it writes is the status the access log records and the
|
// 500 it writes is the status the access log records and the
|
||||||
// metrics count, and outside the sentryhttp handler, whose
|
// metrics count, and outside the sentryhttp handler below, whose
|
||||||
// Repanic option needs something further out to catch what it
|
// Repanic option needs something further out to catch what it
|
||||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||||
// until it was measured: on a current Go release it crashes
|
// until it was measured: on a current Go release it crashes
|
||||||
// inside its stack pretty-printer instead of recovering, so the
|
// inside its stack pretty-printer instead of recovering, so the
|
||||||
// connection dropped and the original panic was never reported.
|
// connection dropped and the original panic was never reported.
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||||
s.recoverPanics(s.router, nil)
|
s.router.Use(s.mw.Recoverer())
|
||||||
}
|
|
||||||
|
|
||||||
// recoverPanics installs on r the recoverer, answering a panic with
|
|
||||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
|
||||||
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
|
||||||
// bubble up to the recoverer.
|
|
||||||
//
|
|
||||||
// Each admin page route group installs its own, with the error page,
|
|
||||||
// as its first middleware. A panic there is logged, reported and
|
|
||||||
// answered inside the group and never reaches the global recoverer,
|
|
||||||
// which keeps the plain 500 for every other route.
|
|
||||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
|
||||||
r.Use(s.mw.Recoverer(page))
|
|
||||||
|
|
||||||
|
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||||
|
// true so panics still bubble up to the Recoverer middleware
|
||||||
|
// registered immediately above.
|
||||||
if s.sentryEnabled.Load() {
|
if s.sentryEnabled.Load() {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
Repanic: true,
|
Repanic: true,
|
||||||
})
|
})
|
||||||
r.Use(sentryHandler.Handle)
|
s.router.Use(sentryHandler.Handle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,22 +147,18 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupPageRoutes() {
|
func (s *Server) setupPageRoutes() {
|
||||||
s.router.Route("/pages", func(r chi.Router) {
|
s.router.Route("/pages", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// 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))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
// the reverse proxy production requires, when TRUSTED_PROXIES
|
// the reverse proxy production requires, with TRUSTED_PROXIES
|
||||||
// does not cover it, every client shares one bucket, so a
|
// unset, every client shares one bucket, so a limiter spent
|
||||||
// limiter spent on arrival lets any stranger deny the operator
|
// on arrival lets any stranger deny the operator the only
|
||||||
// the only administrative path. The handler verifies
|
// administrative path. The handler verifies credentials first
|
||||||
// credentials first and charges only failures; see
|
// and charges only failures; see Handlers.authenticateUser.
|
||||||
// Handlers.authenticateUser.
|
|
||||||
r.Get("/login", s.h.HandleLoginPage())
|
r.Get("/login", s.h.HandleLoginPage())
|
||||||
r.Post("/login", s.h.HandleLoginSubmit())
|
r.Post("/login", s.h.HandleLoginSubmit())
|
||||||
|
|
||||||
@@ -190,13 +168,10 @@ func (s *Server) setupPageRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupUserRoutes() {
|
func (s *Server) setupUserRoutes() {
|
||||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// 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))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
@@ -207,14 +182,11 @@ 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) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// 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))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceList())
|
r.Get("/", s.h.HandleSourceList())
|
||||||
@@ -222,28 +194,25 @@ 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) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// 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))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail())
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
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:
|
||||||
@@ -310,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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -674,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",
|
||||||
)
|
)
|
||||||
@@ -741,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
|
||||||
@@ -769,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,
|
||||||
@@ -819,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")
|
||||||
@@ -853,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(
|
||||||
@@ -879,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)
|
||||||
|
|
||||||
@@ -887,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,
|
||||||
@@ -912,6 +912,59 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- /h/{uuid} receiver ---
|
||||||
|
|
||||||
|
// TestReceiver_EntrypointURLIsRateLimited takes the entrypoint URL
|
||||||
|
// the webhook page shows and posts to it through the production
|
||||||
|
// router until the receiver rate limit refuses it. The URL has to
|
||||||
|
// reach the receiver, and the limit has to apply to it.
|
||||||
|
func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 2
|
||||||
|
|
||||||
|
env := newTestEnvWithConfig(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
ReceiverRateLimit: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "receiver", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "receiver")
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
||||||
|
&database.Entrypoint{
|
||||||
|
WebhookID: wh.ID,
|
||||||
|
Path: "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60",
|
||||||
|
Active: true,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
page := env.get("/hook/"+wh.ID, cookies)
|
||||||
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
|
shown := regexp.MustCompile(`(/h/[^<]+)</code>`).
|
||||||
|
FindStringSubmatch(page.Body.String())
|
||||||
|
require.Len(
|
||||||
|
t, shown, 2, "the webhook page should show the entrypoint URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range limit {
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusOK,
|
||||||
|
env.post(shown[1], url.Values{}, nil).Code,
|
||||||
|
"request %d should reach the receiver", i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusTooManyRequests,
|
||||||
|
env.post(shown[1], url.Values{}, nil).Code,
|
||||||
|
"the receiver rate limit must apply to the entrypoint URL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// metricsConfig is a Config differing from the routing default only
|
// metricsConfig is a Config differing from the routing default only
|
||||||
// in the two /metrics credentials.
|
// in the two /metrics credentials.
|
||||||
func metricsConfig(
|
func metricsConfig(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
|||||||
//
|
//
|
||||||
// URL is the third such field. NewRequest builds it as
|
// URL is the third such field. NewRequest builds it as
|
||||||
// scheme://host/path (interfaces.go:183), and on the receiver route
|
// scheme://host/path (interfaces.go:183), and on the receiver route
|
||||||
// that path is /webhook/<uuid> in full — a write capability, not an
|
// that path is /h/<uuid> in full — a write capability, not an
|
||||||
// identifier. It is rebuilt here from the chi route pattern, on every
|
// identifier. It is rebuilt here from the chi route pattern, on every
|
||||||
// route, keeping the scheme and the host.
|
// route, keeping the scheme and the host.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ func (c sentryCase) router() http.Handler {
|
|||||||
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle,
|
||||||
)
|
)
|
||||||
router.HandleFunc("/pages/login", handler)
|
router.HandleFunc("/pages/login", handler)
|
||||||
router.HandleFunc("/webhook/{uuid}", handler)
|
router.HandleFunc("/h/{uuid}", handler)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ func sentryLoginRequest(client *sentry.Client) *http.Request {
|
|||||||
// concrete path carries the entrypoint capability.
|
// concrete path carries the entrypoint capability.
|
||||||
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
func sentryReceiverRequest(client *sentry.Client) *http.Request {
|
||||||
return sentryRequest(
|
return sentryRequest(
|
||||||
client, "/webhook/"+sentryReceiverUUID, "payload=hello",
|
client, "/h/"+sentryReceiverUUID, "payload=hello",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern(
|
|||||||
t, marshalEvent(t, event), sentryReceiverUUID,
|
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, "http://example.com/webhook/{uuid}", event.Request.URL,
|
t, "http://example.com/h/{uuid}", event.Request.URL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook(
|
|||||||
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
concrete := "https://example.com/webhook/" + sentryReceiverUUID
|
concrete := "https://example.com/h/" + sentryReceiverUUID
|
||||||
|
|
||||||
// A request with no chi routing context on it at all, which is
|
// A request with no chi routing context on it at all, which is
|
||||||
// what an event captured outside the router would carry.
|
// what an event captured outside the router would carry.
|
||||||
@@ -426,7 +426,7 @@ func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) {
|
|||||||
|
|
||||||
event := sentry.NewEvent()
|
event := sentry.NewEvent()
|
||||||
event.Request = &sentry.Request{URL: concrete}
|
event.Request = &sentry.Request{URL: concrete}
|
||||||
event.Transaction = "POST /webhook/" +
|
event.Transaction = "POST /h/" +
|
||||||
sentryReceiverUUID
|
sentryReceiverUUID
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(
|
scrubbed := server.ScrubSentryRequestForTest(
|
||||||
@@ -459,9 +459,9 @@ func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) {
|
|||||||
|
|
||||||
event := sentry.NewEvent()
|
event := sentry.NewEvent()
|
||||||
event.Request = &sentry.Request{
|
event.Request = &sentry.Request{
|
||||||
URL: "/webhook/" + sentryReceiverUUID,
|
URL: "/h/" + sentryReceiverUUID,
|
||||||
}
|
}
|
||||||
event.Transaction = "/webhook/" + sentryReceiverUUID
|
event.Transaction = "/h/" + sentryReceiverUUID
|
||||||
|
|
||||||
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
scrubbed := server.ScrubSentryRequestForTest(event, nil)
|
||||||
require.NotNil(t, scrubbed)
|
require.NotNil(t, scrubbed)
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
|
||||||
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
|
||||||
{{if .User}}
|
|
||||||
<a href="/sources" class="btn-secondary">Back to webhooks</a>
|
|
||||||
{{else}}
|
|
||||||
<a href="/pages/login" class="btn-primary">Sign in</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -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"/>
|
||||||
@@ -24,14 +24,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{.User.Username}}
|
{{.User.Username}}
|
||||||
</a>
|
</a>
|
||||||
{{/* An error page can be served before a form token is issued,
|
|
||||||
and a logout without one is refused. */}}
|
|
||||||
{{if .CSRFToken}}
|
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/pages/login" class="btn-primary">Login</a>
|
<a href="/pages/login" class="btn-primary">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -42,14 +38,12 @@
|
|||||||
<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>
|
||||||
{{if .CSRFToken}}
|
|
||||||
<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}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
||||||
@@ -14,9 +14,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
<button type="submit" class="btn-primary text-sm">Add</button>
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||||
@@ -57,20 +57,20 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2 mt-1">
|
<div class="flex items-start gap-2 mt-1">
|
||||||
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
|
||||||
<!-- Hidden until app.js reveals it; without the
|
<!-- Hidden until app.js reveals it; without the
|
||||||
script the URL above stays selectable. -->
|
script the URL above stays selectable. -->
|
||||||
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
@@ -151,14 +151,14 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="alert-error">{{.Error}}</div>
|
<div class="alert-error">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
<div class="flex justify-between items-center mt-2">
|
<div class="flex justify-between items-center mt-2">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
|
||||||
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<input type="hidden" name="page" value="{{$.Page}}">
|
||||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||||
{{if .BodyTruncated}}
|
{{if .BodyTruncated}}
|
||||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{{if .Status.Terminal}}
|
{{if .Status.Terminal}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="page" value="{{$.Page}}">
|
<input type="hidden" name="page" value="{{$.Page}}">
|
||||||
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
{{if or .HasPrev .HasNext}}
|
{{if or .HasPrev .HasNext}}
|
||||||
<div class="flex justify-center gap-2 mt-6">
|
<div class="flex justify-center gap-2 mt-6">
|
||||||
{{if .HasPrev}}
|
{{if .HasPrev}}
|
||||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||||
{{if .HasNext}}
|
{{if .HasNext}}
|
||||||
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
|
||||||
<a href="/sources/new" class="btn-primary">
|
<a href="/hooks/new" class="btn-primary">
|
||||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{{if .Webhooks}}
|
{{if .Webhooks}}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{{range .Webhooks}}
|
{{range .Webhooks}}
|
||||||
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
|
<a href="/hook/{{.ID}}" class="card-elevated p-6 block">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
|
||||||
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
|
||||||
<a href="/sources/new" class="btn-primary">Create Webhook</a>
|
<a href="/hooks/new" class="btn-primary">Create Webhook</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="alert-error">{{.Error}}</div>
|
<div class="alert-error">{{.Error}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/sources/new" class="space-y-6">
|
<form method="POST" action="/hooks/new" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Create Webhook</button>
|
<button type="submit" class="btn-primary">Create Webhook</button>
|
||||||
<a href="/sources" class="btn-secondary">Cancel</a>
|
<a href="/hooks" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-2xl mx-auto px-6 py-8">
|
<div class="max-w-2xl mx-auto px-6 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">← Back to {{.Webhook.Name}}</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
||||||
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets/{{.Target.ID}}/edit" class="space-y-6">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user