Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b07157aa15 | ||
|
|
5b1d283d06 | ||
|
|
b78abdc9da | ||
|
|
c23ffbac65 | ||
|
|
2ac4d4d793 |
Binary file not shown.
Binary file not shown.
+1
-1
@@ -26,7 +26,7 @@ COPY . .
|
|||||||
# Dockerfile.lint, including --network=none (see its header for why).
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Browser test image, built by script/test-browser (make test-browser). It
|
||||||
|
# runs the test in internal/server that loads the pages in a headless
|
||||||
|
# browser under the real Content-Security-Policy. That test is built only
|
||||||
|
# with the browser build tag, so make test leaves it out. Here the browser
|
||||||
|
# comes from a digest-pinned image, and if it is missing the test fails.
|
||||||
|
|
||||||
|
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
|
||||||
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# The test binary embeds the templates and static files, so the browser
|
||||||
|
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
|
||||||
|
# script/test.
|
||||||
|
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
||||||
|
|
||||||
|
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
||||||
|
# browser is on PATH as headless-shell, where the test's browser library
|
||||||
|
# looks for it.
|
||||||
|
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
|
||||||
|
|
||||||
|
COPY --from=build /browser.test /browser.test
|
||||||
|
|
||||||
|
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
|
||||||
+3
-1
@@ -34,4 +34,6 @@ COPY . .
|
|||||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
# disable a setting without a word. `config verify` is what catches that.
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
# --build-tags browser also lints the browser test, which is built only with
|
||||||
|
# that tag (make test-browser).
|
||||||
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -33,6 +33,9 @@ assets:
|
|||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
|
test-browser:
|
||||||
|
@script/test-browser
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ before deploying one.
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- Docker (for linting, for the test stage of the CI gate, and for
|
- Docker (for linting, for the browser test, for the test stage of the
|
||||||
containerized deployment)
|
CI gate, and for containerized deployment)
|
||||||
|
|
||||||
golangci-lint is not a prerequisite and must not be installed on the
|
golangci-lint is not a prerequisite and must not be installed on the
|
||||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||||
@@ -58,6 +58,7 @@ make fmt # Format code (gofmt + goimports)
|
|||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
|
make test-browser # Run the browser test in Docker (Dockerfile.browser)
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker (version-stamped)
|
make build # Build binary to bin/webhooker (version-stamped)
|
||||||
make version # Print the version this checkout would stamp
|
make version # Print the version this checkout would stamp
|
||||||
@@ -145,11 +146,6 @@ TTY detection, and security headers are always applied.
|
|||||||
| `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. 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) |
|
||||||
| `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) |
|
||||||
|
|
||||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
|
||||||
every one of these with the value the running server loaded. It is
|
|
||||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
|
||||||
set or not set, never their values.
|
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
|
|
||||||
By default every delivery target must resolve to a public address. The
|
By default every delivery target must resolve to a public address. The
|
||||||
@@ -1245,7 +1241,7 @@ What that means for an operator:
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow. Ten of the Makefile's seventeen targets are thin
|
development workflow. Eleven of the Makefile's eighteen targets are thin
|
||||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||||
`version` are inline commands with no script behind them, though `build`,
|
`version` are inline commands with no script behind them, though `build`,
|
||||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||||
@@ -1266,6 +1262,8 @@ We provide:
|
|||||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
[Third-party browser assets](#third-party-browser-assets))
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
|
- `script/test-browser` — run the browser test in Docker (see
|
||||||
|
[Third-party browser assets](#third-party-browser-assets))
|
||||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
@@ -1286,9 +1284,30 @@ We provide:
|
|||||||
|
|
||||||
## Third-party browser assets
|
## Third-party browser assets
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
The web UI serves one third-party script, Alpine.js, in its CSP build: the npm
|
||||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which
|
||||||
publishes it. It is a dependency, not this repo's build output, so
|
the standard `alpinejs` build needs to run the expressions written in the
|
||||||
|
markup. The CSP build runs no expressions, so every Alpine directive in
|
||||||
|
`templates/` only names a property or method of a component registered in
|
||||||
|
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
|
||||||
|
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||||
|
|
||||||
|
A browser test in `internal/server` loads the webhook page and the event log
|
||||||
|
under the real policy and checks that: both add forms stay hidden until Add is
|
||||||
|
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
||||||
|
what it submits, also after leaving the page and going back to it, when the
|
||||||
|
browser restores the choice; an event expands and collapses, and so do a
|
||||||
|
delivery's attempts inside it; and at phone width the menu button opens and
|
||||||
|
closes the mobile menu. It also fails if the browser reports a console warning
|
||||||
|
or error, an uncaught exception, or anything the policy refused. It is not part
|
||||||
|
of `make test`, `make check` or the image build (its file is built only with the
|
||||||
|
`browser` build tag). Run it with `make test-browser` after changing
|
||||||
|
`templates/` or `static/js/`: that builds `Dockerfile.browser`, which runs the
|
||||||
|
test in a digest-pinned headless browser image, so the host needs no browser.
|
||||||
|
|
||||||
|
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||||
|
byte as the npm registry publishes it. It is a dependency, not this repo's
|
||||||
|
build output, so
|
||||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||||
`vendor/` directory as its module vendor directory.
|
`vendor/` directory as its module vendor directory.
|
||||||
@@ -1301,10 +1320,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and
|
|||||||
`.dockerignore` keeps any host copy out of the build context.
|
`.dockerignore` keeps any host copy out of the build context.
|
||||||
|
|
||||||
To move to a new version: download
|
To move to a new version: download
|
||||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
|
||||||
against the `dist.integrity` hash listed at
|
against the `dist.integrity` hash listed at
|
||||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
||||||
with it, update its file name in `script/assets`, and run `make check`.
|
`3p/` with it as `alpinejs-csp-<version>.tgz`, update its file name in
|
||||||
|
`script/assets`, and run `make check`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -2785,7 +2805,6 @@ returns to the page that was asked for.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `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` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
|
||||||
| `GET` | `/hooks` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/hooks/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/hooks/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
@@ -2837,7 +2856,7 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
|||||||
```
|
```
|
||||||
webhooker/
|
webhooker/
|
||||||
├── 3p/
|
├── 3p/
|
||||||
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets
|
||||||
├── cmd/webhooker/
|
├── cmd/webhooker/
|
||||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||||
├── internal/
|
├── internal/
|
||||||
@@ -2899,7 +2918,6 @@ webhooker/
|
|||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
│ │ ├── settings.go # Read-only Settings page handler
|
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
@@ -2932,13 +2950,14 @@ webhooker/
|
|||||||
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
|
||||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||||
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
|
||||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
|
||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
├── Dockerfile.lint # Lint-only image built by script/lint
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Makefile # 10 of 17 targets shim script/; 7 are inline
|
├── Dockerfile.browser # Browser test image built by script/test-browser
|
||||||
|
├── Makefile # 11 of 18 targets shim script/; 7 are inline
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
```
|
```
|
||||||
@@ -3009,14 +3028,14 @@ 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.
|
||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/settings`,
|
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hooks`, `/hook/*`) starts with its own **Recoverer** and, if
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
||||||
`SENTRY_DSN` is set, its own **Sentry** error reporting. That Recoverer answers a panic
|
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
||||||
with the `500` error page in the normal layout; the global one keeps
|
with the `500` error page in the normal layout; the global one keeps
|
||||||
the plain-text `500` for every other route.
|
the plain-text `500` for every other route.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hooks`, `/hook/*`) 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
|
||||||
@@ -3035,7 +3054,7 @@ declared length. A chunked request, or
|
|||||||
one that lies about its length, is hard-capped by
|
one that lies about its length, is hard-capped by
|
||||||
`http.MaxBytesReader` and fails downstream at form-parse time.
|
`http.MaxBytesReader` and fails downstream at form-parse time.
|
||||||
|
|
||||||
Those same five route groups then apply **CSRF** and **NoCache**
|
Those same four route groups then apply **CSRF** and **NoCache**
|
||||||
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
||||||
`/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
|
||||||
@@ -3081,12 +3100,12 @@ 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`, `/hooks`, `/hook`,
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||||
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
|
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||||
POSTs) and `/api` (stateless API). The middleware detects TLS
|
`/api` (stateless API). The middleware detects TLS per-request through
|
||||||
per-request through `internal/reqtls.IsTLS` — the same predicate the
|
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||||
session cookie uses — to set appropriate cookie security flags and
|
to set appropriate cookie security flags and Origin/Referer validation
|
||||||
Origin/Referer validation mode
|
mode
|
||||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||||
about an inbound request is verified; possession of the UUID
|
about an inbound request is verified; possession of the UUID
|
||||||
authorises submission, and no shared secret or signature check will
|
authorises submission, and no shared secret or signature check will
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ duplicate. That is deliberate — the alternative is a silent lost
|
|||||||
delivery — and the README says so under Rationale. It is not a defect
|
delivery — and the README says so under Rationale. It is not a defect
|
||||||
to re-file.
|
to re-file.
|
||||||
|
|
||||||
One caveat on reading a green check: a docs-only commit deliberately
|
|
||||||
replays from the layer cache
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
|
|
||||||
such a commit evidences a replay rather than an executed run. A code
|
|
||||||
commit invalidates the `COPY` layer and genuinely executes.
|
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the rest of the open 1.0.0 milestone
|
Clear the rest of the open 1.0.0 milestone
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
|
||||||
|
github.com/chromedp/chromedp v0.16.0
|
||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
@@ -29,7 +31,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -50,7 +57,7 @@ require (
|
|||||||
go.uber.org/zap v1.23.0 // indirect
|
go.uber.org/zap v1.23.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
|
||||||
|
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
|
||||||
|
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
|
||||||
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -23,6 +29,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
|||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -55,12 +69,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -111,8 +129,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/lifecycle"
|
"sneak.berlin/go/webhooker/internal/lifecycle"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
@@ -146,6 +147,7 @@ type EngineParams struct {
|
|||||||
|
|
||||||
DB *database.Database
|
DB *database.Database
|
||||||
DBManager *database.WebhookDBManager
|
DBManager *database.WebhookDBManager
|
||||||
|
Globals *globals.Globals
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
SSRFGuard *Guard
|
SSRFGuard *Guard
|
||||||
Metrics *metrics.Set
|
Metrics *metrics.Set
|
||||||
@@ -168,6 +170,10 @@ type Engine struct {
|
|||||||
retryCh chan Task
|
retryCh chan Task
|
||||||
workers int
|
workers int
|
||||||
|
|
||||||
|
// version is the running build's version, the one the web UI
|
||||||
|
// footer shows. userAgent puts it on every outbound request.
|
||||||
|
version string
|
||||||
|
|
||||||
// mtr is the delivery metric set. Production wires the one
|
// mtr is the delivery metric set. Production wires the one
|
||||||
// registered on the registry /metrics serves; a test can
|
// registered on the registry /metrics serves; a test can
|
||||||
// substitute a set registered on a registry it holds, so it can
|
// substitute a set registered on a registry it holds, so it can
|
||||||
@@ -205,6 +211,7 @@ func New(
|
|||||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
retryCh: make(chan Task, retryChannelSize),
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
workers: defaultWorkers,
|
workers: defaultWorkers,
|
||||||
|
version: params.Globals.Version,
|
||||||
mtr: params.Metrics,
|
mtr: params.Metrics,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +308,13 @@ func (e *Engine) ScheduleRetry(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userAgent is the User-Agent header of every http and slack
|
||||||
|
// delivery request: the program name and the running build's
|
||||||
|
// version.
|
||||||
|
func (e *Engine) userAgent() string {
|
||||||
|
return "webhooker/" + e.version
|
||||||
|
}
|
||||||
|
|
||||||
// registerHooks wires the engine's start and stop into the fx
|
// registerHooks wires the engine's start and stop into the fx
|
||||||
// lifecycle. The start hook's context is deliberately ignored
|
// lifecycle. The start hook's context is deliberately ignored
|
||||||
// (see start for why the worker pool must not inherit it); the
|
// (see start for why the worker pool must not inherit it); the
|
||||||
|
|||||||
@@ -1247,11 +1247,6 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
testContentType,
|
testContentType,
|
||||||
receivedHeaders.Get("Content-Type"),
|
receivedHeaders.Get("Content-Type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
"webhooker/1.0",
|
|
||||||
receivedHeaders.Get("User-Agent"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event's stored inbound headers carry the same Content-Type the
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
@@ -1320,6 +1315,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
ContentType: tc.event,
|
ContentType: tc.event,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
"webhooker/dev",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ func ExportApplyRequestHeaders(
|
|||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
cfg *HTTPTargetConfig,
|
cfg *HTTPTargetConfig,
|
||||||
|
userAgent string,
|
||||||
) []string {
|
) []string {
|
||||||
return applyRequestHeaders(req, event, cfg)
|
return applyRequestHeaders(req, event, cfg, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportTruncate exposes truncate for testing.
|
// ExportTruncate exposes truncate for testing.
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
|||||||
"Content-Type": testContentType,
|
"Content-Type": testContentType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"webhooker/dev",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
|
|||||||
@@ -442,7 +442,9 @@ func (t *httpTarget) doHTTPRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
originScoped := applyRequestHeaders(req, event, cfg)
|
originScoped := applyRequestHeaders(
|
||||||
|
req, event, cfg, t.eng.userAgent(),
|
||||||
|
)
|
||||||
|
|
||||||
client := t.clientForRequest(cfg, originScoped)
|
client := t.clientForRequest(cfg, originScoped)
|
||||||
|
|
||||||
@@ -562,10 +564,13 @@ func isForwardableHeader(name string) bool {
|
|||||||
// Content-Type goes out once: a Content-Type configured on the target
|
// Content-Type goes out once: a Content-Type configured on the target
|
||||||
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
||||||
// Content-Type in the event's headers is never forwarded.
|
// Content-Type in the event's headers is never forwarded.
|
||||||
|
//
|
||||||
|
// userAgent is set last, over any configured or inbound User-Agent.
|
||||||
func applyRequestHeaders(
|
func applyRequestHeaders(
|
||||||
req *http.Request,
|
req *http.Request,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
cfg *HTTPTargetConfig,
|
cfg *HTTPTargetConfig,
|
||||||
|
userAgent string,
|
||||||
) []string {
|
) []string {
|
||||||
if event.ContentType != "" {
|
if event.ContentType != "" {
|
||||||
req.Header.Set(
|
req.Header.Set(
|
||||||
@@ -580,7 +585,7 @@ func applyRequestHeaders(
|
|||||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
// A Content-Type configured on the target describes the body
|
// A Content-Type configured on the target describes the body
|
||||||
// being sent rather than the sender. A 307/308 preserves the
|
// being sent rather than the sender. A 307/308 preserves the
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
req.Header.Set("User-Agent", t.eng.userAgent())
|
||||||
|
|
||||||
resp, doErr := executeHTTPRequest(t.client, req)
|
resp, doErr := executeHTTPRequest(t.client, req)
|
||||||
durationMs := time.Since(start).Milliseconds()
|
durationMs := time.Since(start).Milliseconds()
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both the http and the slack target send webhooker/ and the version
|
||||||
|
// in Globals, the value the web UI footer shows. A User-Agent
|
||||||
|
// configured on the target or carried in by the sender does not
|
||||||
|
// replace it.
|
||||||
|
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const want = "webhooker/1.2.3-test"
|
||||||
|
|
||||||
|
userAgents := make(chan string, 1)
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userAgents <- r.Header.Get("User-Agent")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
g := &globals.Globals{Version: "1.2.3-test"}
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
e := delivery.New(lc, delivery.EngineParams{
|
||||||
|
Globals: g,
|
||||||
|
Logger: log,
|
||||||
|
// httptest listens on loopback, which the default guard
|
||||||
|
// refuses.
|
||||||
|
SSRFGuard: delivery.NewTestGuard(
|
||||||
|
netip.MustParsePrefix("127.0.0.0/8"),
|
||||||
|
),
|
||||||
|
Metrics: metrics.New(prometheus.NewRegistry()),
|
||||||
|
})
|
||||||
|
|
||||||
|
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
||||||
|
context.Background(),
|
||||||
|
&delivery.HTTPTargetConfig{
|
||||||
|
URL: ts.URL,
|
||||||
|
Headers: map[string]string{"User-Agent": "configured/1"},
|
||||||
|
},
|
||||||
|
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, statusCode)
|
||||||
|
require.Len(t, userAgents, 1, "the http target sent no request")
|
||||||
|
assert.Equal(t, want, <-userAgents, "http target")
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
slackCfg, err := json.Marshal(
|
||||||
|
delivery.SlackTargetConfig{WebhookURL: ts.URL},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := seedEvent(t, db, `{"action":"test"}`)
|
||||||
|
dlv := seedDelivery(
|
||||||
|
t, db, event.ID, targetID, database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
|
||||||
|
dlv, event, targetID, "test-slack", string(slackCfg),
|
||||||
|
))
|
||||||
|
require.Len(t, userAgents, 1, "the slack target sent no request")
|
||||||
|
assert.Equal(t, want, <-userAgents, "slack target")
|
||||||
|
}
|
||||||
@@ -333,7 +333,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
http.Redirect(
|
||||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
w, r, withNotice("/pages/login", signedOut),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,72 +11,37 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// replayOutcomeParam is the query parameter the replay POST redirects
|
// The outcomes of a replay POST, as the notice codes its redirect
|
||||||
// with and the event log page reads its banner from.
|
// carries. noticeFor holds the line each one shows.
|
||||||
const replayOutcomeParam = "replay"
|
|
||||||
|
|
||||||
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
|
||||||
// carries one of these fixed codes rather than a message, so nothing a
|
|
||||||
// client submits can reach the rendered page through it.
|
|
||||||
type replayOutcomeCode string
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// replayQueued reports that a new delivery was created and handed
|
// replayQueued reports that a new delivery was created and handed
|
||||||
// to the delivery engine.
|
// to the delivery engine.
|
||||||
replayQueued replayOutcomeCode = "queued"
|
replayQueued noticeCode = "replay-queued"
|
||||||
|
|
||||||
// replayTargetDeleted reports a target that once existed and has
|
// replayTargetDeleted reports a target that once existed and has
|
||||||
// since been deleted. Deletes are soft and deliveries carry no
|
// since been deleted. Deletes are soft and deliveries carry no
|
||||||
// foreign key to the target row, so the history survives its
|
// foreign key to the target row, so the history survives its
|
||||||
// target and this is the ordinary case for an old event.
|
// target and this is the ordinary case for an old event.
|
||||||
replayTargetDeleted replayOutcomeCode = "target-deleted"
|
replayTargetDeleted noticeCode = "replay-target-deleted"
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
// all, deleted or otherwise.
|
// all, deleted or otherwise.
|
||||||
replayTargetMissing replayOutcomeCode = "target-missing"
|
replayTargetMissing noticeCode = "replay-target-missing"
|
||||||
|
|
||||||
// replayTargetInactive reports a target the operator has
|
// replayTargetInactive reports a target the operator has
|
||||||
// deactivated. A deactivated target receives no new deliveries, so
|
// deactivated. A deactivated target receives no new deliveries, so
|
||||||
// a replay to it would be a delivery they switched off.
|
// a replay to it would be a delivery they switched off.
|
||||||
replayTargetInactive replayOutcomeCode = "target-inactive"
|
replayTargetInactive noticeCode = "replay-target-inactive"
|
||||||
|
|
||||||
// replayNotTerminal reports a delivery the engine has not finished
|
// replayNotTerminal reports a delivery the engine has not finished
|
||||||
// with.
|
// with.
|
||||||
replayNotTerminal replayOutcomeCode = "not-terminal"
|
replayNotTerminal noticeCode = "replay-not-terminal"
|
||||||
|
|
||||||
// replayInFlight reports that an earlier replay of this event to
|
// replayInFlight reports that an earlier replay of this event to
|
||||||
// this target is still running.
|
// this target is still running.
|
||||||
replayInFlight replayOutcomeCode = "in-flight"
|
replayInFlight noticeCode = "replay-in-flight"
|
||||||
)
|
)
|
||||||
|
|
||||||
// replayOutcome returns the banner the event log page shows for an
|
|
||||||
// outcome code, and whether the replay was queued. An unrecognised
|
|
||||||
// code yields no banner.
|
|
||||||
func replayOutcome(code string) (string, bool) {
|
|
||||||
switch replayOutcomeCode(code) {
|
|
||||||
case replayQueued:
|
|
||||||
return "Replay queued: a new delivery was created against " +
|
|
||||||
"the target's current configuration.", true
|
|
||||||
case replayTargetDeleted:
|
|
||||||
return "Not replayed: the target this delivery was for has " +
|
|
||||||
"been deleted. Recreate the target, then replay.", false
|
|
||||||
case replayTargetMissing:
|
|
||||||
return "Not replayed: the target this delivery was for no " +
|
|
||||||
"longer exists.", false
|
|
||||||
case replayTargetInactive:
|
|
||||||
return "Not replayed: the target this delivery was for is " +
|
|
||||||
"deactivated. Activate it, then replay.", false
|
|
||||||
case replayNotTerminal:
|
|
||||||
return "Not replayed: this delivery has not finished yet.",
|
|
||||||
false
|
|
||||||
case replayInFlight:
|
|
||||||
return "Not replayed: a delivery of this event to this " +
|
|
||||||
"target is already in flight.", false
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
// HandleDeliveryReplay re-sends a finished delivery's event to its
|
||||||
// target.
|
// target.
|
||||||
//
|
//
|
||||||
@@ -140,14 +105,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !original.Status.Terminal() {
|
if !original.Status.Terminal() {
|
||||||
h.finishReplay(w, r, webhook, replayNotTerminal)
|
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||||
if target == nil {
|
if target == nil {
|
||||||
h.finishReplay(w, r, webhook, code)
|
redirectToEventLog(w, r, webhook, code)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -200,7 +165,7 @@ func (h *Handlers) queueReplay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if inFlight > 0 {
|
if inFlight > 0 {
|
||||||
h.finishReplay(w, r, webhook, replayInFlight)
|
redirectToEventLog(w, r, webhook, replayInFlight)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,7 +203,7 @@ func (h *Handlers) queueReplay(
|
|||||||
"delivery_id", task.DeliveryID,
|
"delivery_id", task.DeliveryID,
|
||||||
)
|
)
|
||||||
|
|
||||||
h.finishReplay(w, r, webhook, replayQueued)
|
redirectToEventLog(w, r, webhook, replayQueued)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replayTarget loads the delivery's target as it stands now.
|
// replayTarget loads the delivery's target as it stands now.
|
||||||
@@ -251,7 +216,7 @@ func (h *Handlers) queueReplay(
|
|||||||
// with the returned code saying why.
|
// with the returned code saying why.
|
||||||
func (h *Handlers) replayTarget(
|
func (h *Handlers) replayTarget(
|
||||||
webhookID, targetID string,
|
webhookID, targetID string,
|
||||||
) (*database.Target, replayOutcomeCode) {
|
) (*database.Target, noticeCode) {
|
||||||
var target database.Target
|
var target database.Target
|
||||||
|
|
||||||
err := h.db.DB().Unscoped().Where(
|
err := h.db.DB().Unscoped().Where(
|
||||||
@@ -361,17 +326,16 @@ func replayBody(body string) *string {
|
|||||||
return &body
|
return &body
|
||||||
}
|
}
|
||||||
|
|
||||||
// finishReplay redirects back to the event log the replay was
|
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||||
// triggered from, carrying the outcome code the page turns into a
|
// log it was triggered from, carrying the outcome as its notice and
|
||||||
// banner and the page number the form submitted.
|
// the page number the form submitted.
|
||||||
func (h *Handlers) finishReplay(
|
func redirectToEventLog(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code replayOutcomeCode,
|
code noticeCode,
|
||||||
) {
|
) {
|
||||||
dest := "/hook/" + webhook.ID + "/events?" +
|
dest := withNotice("/hook/"+webhook.ID+"/events", 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:
|
||||||
// this is a POST, and its query string is what logs and Referer
|
// this is a POST, and its query string is what logs and Referer
|
||||||
|
|||||||
@@ -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,
|
||||||
"/hook/"+wh.ID+"/events?replay=queued",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?replay=target-deleted",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?replay=target-missing",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?replay=queued",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?replay=in-flight",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?replay=not-terminal",
|
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
|
||||||
pending.Header().Get("Location"),
|
pending.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
assert.Contains(t, body, ">Replay<")
|
assert.Contains(t, body, ">Replay<")
|
||||||
|
|
||||||
refused := renderSourceLogsPageWithQuery(
|
refused := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?replay=target-deleted",
|
t, h, sess, wh.ID, "?notice=replay-target-deleted",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
t, h, sess, wh.ID, "?replay=made-up",
|
t, h, sess, wh.ID, "?notice=made-up",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.NotContains(t, unknown, "alert-error")
|
assert.NotContains(t, unknown, "alert-error")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -11,43 +10,19 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resubmitOutcomeParam is the query parameter the resubmit POST
|
// The outcomes of a resubmit POST, as the notice codes its redirect
|
||||||
// redirects with and the event log page reads its banner from.
|
// carries. noticeFor holds the line each one shows.
|
||||||
const resubmitOutcomeParam = "resubmit"
|
|
||||||
|
|
||||||
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
|
|
||||||
// carries one of these fixed codes rather than a message, so nothing a
|
|
||||||
// client submits can reach the rendered page through it.
|
|
||||||
type resubmitOutcomeCode string
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// resubmitQueued reports that a new event was stored and its
|
// resubmitQueued reports that a new event was stored and its
|
||||||
// deliveries handed to the delivery engine.
|
// deliveries handed to the delivery engine.
|
||||||
resubmitQueued resubmitOutcomeCode = "queued"
|
resubmitQueued noticeCode = "resubmit-queued"
|
||||||
|
|
||||||
// resubmitNoTargets reports a source with no active targets. The
|
// resubmitNoTargets reports a source with no active targets. The
|
||||||
// new event is stored either way, exactly as a received event
|
// new event is stored either way, exactly as a received event
|
||||||
// with no targets is.
|
// with no targets is.
|
||||||
resubmitNoTargets resubmitOutcomeCode = "no-targets"
|
resubmitNoTargets noticeCode = "resubmit-no-targets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resubmitOutcome returns the banner the event log page shows for an
|
|
||||||
// outcome code, and whether the resubmit was queued. An unrecognised
|
|
||||||
// code yields no banner.
|
|
||||||
func resubmitOutcome(code string) (string, bool) {
|
|
||||||
switch resubmitOutcomeCode(code) {
|
|
||||||
case resubmitQueued:
|
|
||||||
return "Resubmitted: a new event was created from the stored " +
|
|
||||||
"one and queued to every active target.", true
|
|
||||||
case resubmitNoTargets:
|
|
||||||
return "Resubmitted: a new event was created, but this " +
|
|
||||||
"source has no active targets, so nothing was queued.",
|
|
||||||
true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resubmitSource is the stored event a resubmit copies. Its body is
|
// resubmitSource is the stored event a resubmit copies. Its body is
|
||||||
// read as bytes rather than as a string so the copy is byte-identical
|
// read as bytes rather than as a string so the copy is byte-identical
|
||||||
// to what was received, whatever the payload's encoding.
|
// to what was received, whatever the payload's encoding.
|
||||||
@@ -245,29 +220,5 @@ func (h *Handlers) queueResubmit(
|
|||||||
code = resubmitNoTargets
|
code = resubmitNoTargets
|
||||||
}
|
}
|
||||||
|
|
||||||
h.finishResubmit(w, r, webhook, code)
|
redirectToEventLog(w, r, webhook, code)
|
||||||
}
|
|
||||||
|
|
||||||
// finishResubmit redirects back to the event log the resubmit was
|
|
||||||
// triggered from, carrying the outcome code the page turns into a
|
|
||||||
// banner and the page number the form submitted.
|
|
||||||
func (h *Handlers) finishResubmit(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
webhook database.Webhook,
|
|
||||||
code resubmitOutcomeCode,
|
|
||||||
) {
|
|
||||||
dest := "/hook/" + webhook.ID + "/events?" +
|
|
||||||
resubmitOutcomeParam + "=" + string(code)
|
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
|
||||||
// this is a POST, and its query string is what logs and Referer
|
|
||||||
// headers record.
|
|
||||||
if page := pageOrFirst(
|
|
||||||
r.PostFormValue("page"),
|
|
||||||
); page > 1 {
|
|
||||||
dest += "&page=" + strconv.Itoa(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?resubmit=queued",
|
"/hook/"+wh.ID+"/events?notice=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,
|
||||||
"/hook/"+wh.ID+"/events?resubmit=no-targets",
|
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
|
||||||
w.Header().Get("Location"),
|
w.Header().Get("Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -58,7 +57,6 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -99,10 +97,10 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, and navbar templates, and with any further files
|
// base, htmlheader, navbar and notice templates, and with any further
|
||||||
// the page includes. The page file must be listed first so that its
|
// files the page includes. The page file must be listed first so that
|
||||||
// root action ({{template "base" .}}) becomes the template set's entry
|
// its root action ({{template "base" .}}) becomes the template set's
|
||||||
// point.
|
// entry point.
|
||||||
func parsePageTemplate(
|
func parsePageTemplate(
|
||||||
pageFile string, included ...string,
|
pageFile string, included ...string,
|
||||||
) *template.Template {
|
) *template.Template {
|
||||||
@@ -111,6 +109,7 @@ func parsePageTemplate(
|
|||||||
"base.html",
|
"base.html",
|
||||||
"htmlheader.html",
|
"htmlheader.html",
|
||||||
"navbar.html",
|
"navbar.html",
|
||||||
|
"notice.html",
|
||||||
}, included...)
|
}, included...)
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
@@ -141,7 +140,6 @@ func New(
|
|||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"settings.html": parsePageTemplate("settings.html"),
|
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
@@ -212,11 +210,13 @@ func (s *Handlers) renderError(
|
|||||||
// served outside the routes where NoCache runs.
|
// served outside the routes where NoCache runs.
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
// No notice: one would say an action worked above a page saying
|
||||||
|
// the request failed.
|
||||||
data := s.pageData(r, map[string]any{
|
data := s.pageData(r, map[string]any{
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"StatusText": http.StatusText(status),
|
"StatusText": http.StatusText(status),
|
||||||
"Message": errorPageText(status),
|
"Message": errorPageText(status),
|
||||||
})
|
}, nil)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
@@ -270,6 +270,7 @@ type templateDataWrapper struct {
|
|||||||
User *UserInfo
|
User *UserInfo
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Version string
|
Version string
|
||||||
|
Notice *notice
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,12 +315,15 @@ func (s *Handlers) renderTemplate(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageData adds the fields the shared layout renders to a page's own
|
// pageData adds the fields the shared layout renders to a page's own
|
||||||
// data.
|
// data. The layout shows the notice, when there is one, above the
|
||||||
func (s *Handlers) pageData(r *http.Request, data any) any {
|
// page.
|
||||||
|
func (s *Handlers) pageData(
|
||||||
|
r *http.Request, data any, pageNotice *notice,
|
||||||
|
) any {
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -333,6 +337,7 @@ 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
|
||||||
|
m["Notice"] = pageNotice
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@@ -341,6 +346,7 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
|||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
Notice: pageNotice,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,25 +84,16 @@ func newTestApp(
|
|||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newTestAppWithConfig(
|
|
||||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
|
||||||
func newTestAppWithConfig(
|
|
||||||
t *testing.T,
|
|
||||||
cfg *config.Config,
|
|
||||||
targets ...any,
|
|
||||||
) *fxtest.App {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
func() *config.Config { return cfg },
|
func() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
},
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// noticeParam is the query parameter an action's redirect carries its
|
||||||
|
// notice code in.
|
||||||
|
const noticeParam = "notice"
|
||||||
|
|
||||||
|
// noticeCode names one of the fixed lines noticeFor knows. An action
|
||||||
|
// redirects with the code rather than the line, so nothing a client
|
||||||
|
// puts in the URL reaches the page: a code noticeFor does not know
|
||||||
|
// shows nothing.
|
||||||
|
type noticeCode string
|
||||||
|
|
||||||
|
// The codes of the actions on the webhook pages and of signing out.
|
||||||
|
// Replay's codes, with the reasons a replay can be refused, and
|
||||||
|
// resubmit's codes are defined beside those actions.
|
||||||
|
const (
|
||||||
|
webhookCreated noticeCode = "webhook-created"
|
||||||
|
webhookSaved noticeCode = "webhook-saved"
|
||||||
|
webhookDeleted noticeCode = "webhook-deleted"
|
||||||
|
entrypointAdded noticeCode = "entrypoint-added"
|
||||||
|
entrypointDeleted noticeCode = "entrypoint-deleted"
|
||||||
|
entrypointActivated noticeCode = "entrypoint-activated"
|
||||||
|
entrypointDeactivated noticeCode = "entrypoint-deactivated"
|
||||||
|
targetAdded noticeCode = "target-added"
|
||||||
|
targetSaved noticeCode = "target-saved"
|
||||||
|
targetDeleted noticeCode = "target-deleted"
|
||||||
|
targetActivated noticeCode = "target-activated"
|
||||||
|
targetDeactivated noticeCode = "target-deactivated"
|
||||||
|
signedOut noticeCode = "signed-out"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notice is the line templates/notice.html shows above a page to say
|
||||||
|
// what an action did.
|
||||||
|
type notice struct {
|
||||||
|
Text string
|
||||||
|
|
||||||
|
// Failed shows the line as an error: the action was refused.
|
||||||
|
Failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// noticeFor returns the notice the request's URL names, or nil when it
|
||||||
|
// names none or an unknown code.
|
||||||
|
func noticeFor(r *http.Request) *notice {
|
||||||
|
n, ok := map[noticeCode]notice{
|
||||||
|
webhookCreated: {Text: "Webhook created."},
|
||||||
|
webhookSaved: {Text: "Webhook saved."},
|
||||||
|
webhookDeleted: {Text: "Webhook deleted."},
|
||||||
|
entrypointAdded: {Text: "Entrypoint added."},
|
||||||
|
entrypointDeleted: {Text: "Entrypoint deleted."},
|
||||||
|
entrypointActivated: {Text: "Entrypoint activated."},
|
||||||
|
entrypointDeactivated: {Text: "Entrypoint deactivated."},
|
||||||
|
targetAdded: {Text: "Target added."},
|
||||||
|
targetSaved: {Text: "Target saved."},
|
||||||
|
targetDeleted: {Text: "Target deleted."},
|
||||||
|
targetActivated: {Text: "Target activated."},
|
||||||
|
targetDeactivated: {Text: "Target deactivated."},
|
||||||
|
signedOut: {Text: "Signed out."},
|
||||||
|
|
||||||
|
replayQueued: {
|
||||||
|
Text: "Replay queued: a new delivery was created " +
|
||||||
|
"against the target's current configuration.",
|
||||||
|
},
|
||||||
|
replayTargetDeleted: {
|
||||||
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
|
"has been deleted. Recreate the target, then replay.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayTargetMissing: {
|
||||||
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
|
"no longer exists.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayTargetInactive: {
|
||||||
|
Text: "Not replayed: the target this delivery was for " +
|
||||||
|
"is deactivated. Activate it, then replay.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayNotTerminal: {
|
||||||
|
Text: "Not replayed: this delivery has not finished yet.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
replayInFlight: {
|
||||||
|
Text: "Not replayed: a delivery of this event to this " +
|
||||||
|
"target is already in flight.",
|
||||||
|
Failed: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
resubmitQueued: {
|
||||||
|
Text: "Resubmitted: a new event was created from the " +
|
||||||
|
"stored one and queued to every active target.",
|
||||||
|
},
|
||||||
|
resubmitNoTargets: {
|
||||||
|
Text: "Resubmitted: a new event was created, but this " +
|
||||||
|
"source has no active targets, so nothing was queued.",
|
||||||
|
},
|
||||||
|
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// withNotice returns path with code added as its notice.
|
||||||
|
func withNotice(path string, code noticeCode) string {
|
||||||
|
return path + "?" + noticeParam + "=" + string(code)
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// notSet is what the Settings page shows for a value that is empty.
|
|
||||||
const notSet = "not set"
|
|
||||||
|
|
||||||
// settingRow is one line of the Settings page: an environment
|
|
||||||
// variable, what it controls, and the value the server loaded for it.
|
|
||||||
type settingRow struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSettings returns a handler for the read-only Settings page,
|
|
||||||
// which lists the configuration the server started with.
|
|
||||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
|
||||||
"Settings": settingRows(h.params.Config),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingRows lists every field of cfg under the environment variable
|
|
||||||
// it is read from, in the order of the README's configuration table.
|
|
||||||
// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values
|
|
||||||
// never reach the page: only whether they are set.
|
|
||||||
func settingRows(cfg *config.Config) []settingRow {
|
|
||||||
metricsUsername := cfg.MetricsUsername
|
|
||||||
if metricsUsername == "" {
|
|
||||||
metricsUsername = notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return []settingRow{
|
|
||||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
|
||||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
|
||||||
{
|
|
||||||
"BIND_ADDRESS",
|
|
||||||
"IP address the HTTP listener binds",
|
|
||||||
cfg.BindAddress,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"DATA_DIR",
|
|
||||||
"Directory for all SQLite databases",
|
|
||||||
cfg.DataDir,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"DEBUG",
|
|
||||||
"Enable debug logging",
|
|
||||||
strconv.FormatBool(cfg.Debug),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"MAINTENANCE_MODE",
|
|
||||||
"Report maintenanceMode: true in the healthcheck JSON. " +
|
|
||||||
"It does not change how any request is served",
|
|
||||||
strconv.FormatBool(cfg.MaintenanceMode),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_USERNAME",
|
|
||||||
"Basic auth username for /metrics",
|
|
||||||
metricsUsername,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_PASSWORD",
|
|
||||||
"Basic auth password for /metrics",
|
|
||||||
setOrNotSet(cfg.MetricsPassword),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SENTRY_DSN",
|
|
||||||
"Error reporting DSN. Unset leaves error reporting off",
|
|
||||||
setOrNotSet(cfg.SentryDSN),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
|
||||||
"How often the retention reaper and archive sweeper run",
|
|
||||||
cfg.RetentionSweepInterval.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SESSION_IDLE_TIMEOUT",
|
|
||||||
"Idle session timeout. Zero or negative disables idle " +
|
|
||||||
"expiry",
|
|
||||||
cfg.SessionIdleTimeout.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RECEIVER_RATE_LIMIT",
|
|
||||||
"Receiver requests per minute per IP per entrypoint " +
|
|
||||||
"(10x that per IP across the route)",
|
|
||||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"TRUSTED_PROXIES",
|
|
||||||
"CIDRs whose forwarded headers are trusted",
|
|
||||||
cidrList(cfg.TrustedProxies),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ALLOWED_EGRESS_CIDRS",
|
|
||||||
"CIDRs that delivery targets may reach despite the " +
|
|
||||||
"SSRF blocklist",
|
|
||||||
cidrList(cfg.AllowedEgressCIDRs),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
|
||||||
// has a value, never the value itself.
|
|
||||||
func setOrNotSet(value string) string {
|
|
||||||
if value == "" {
|
|
||||||
return notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return "set"
|
|
||||||
}
|
|
||||||
|
|
||||||
// cidrList renders a CIDR list setting for the Settings page.
|
|
||||||
func cidrList(prefixes []netip.Prefix) string {
|
|
||||||
if len(prefixes) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
|
||||||
// and returns the value it shows for each variable name, plus the
|
|
||||||
// whole page.
|
|
||||||
func settingsShown(
|
|
||||||
t *testing.T, cfg *config.Config,
|
|
||||||
) (map[string]string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/settings", nil,
|
|
||||||
)
|
|
||||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleSettings().ServeHTTP(w, req)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
row := regexp.MustCompile(
|
|
||||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
|
||||||
)
|
|
||||||
|
|
||||||
shown := map[string]string{}
|
|
||||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
|
||||||
shown[match[1]] = html.UnescapeString(match[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
return shown, body
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
|
|
||||||
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
|
|
||||||
// of the three set in one of the content tests, so each row is
|
|
||||||
// checked against its own field.
|
|
||||||
cfg := &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Debug: true,
|
|
||||||
MaintenanceMode: false,
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
MetricsUsername: "scraper",
|
|
||||||
MetricsPassword: "",
|
|
||||||
Port: 9123,
|
|
||||||
SentryDSN: "",
|
|
||||||
BindAddress: "192.0.2.10",
|
|
||||||
RetentionSweepInterval: 17 * time.Minute,
|
|
||||||
SessionIdleTimeout: 3 * time.Hour,
|
|
||||||
ReceiverRateLimit: 77,
|
|
||||||
TrustedProxies: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.1.0.0/16"),
|
|
||||||
},
|
|
||||||
AllowedEgressCIDRs: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("192.168.5.0/24"),
|
|
||||||
netip.MustParsePrefix("fd00::/8"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, cfg)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
|
||||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
|
||||||
"PORT": "9123",
|
|
||||||
"BIND_ADDRESS": "192.0.2.10",
|
|
||||||
"DATA_DIR": cfg.DataDir,
|
|
||||||
"DEBUG": "true",
|
|
||||||
"MAINTENANCE_MODE": "false",
|
|
||||||
"METRICS_USERNAME": "scraper",
|
|
||||||
"METRICS_PASSWORD": "not set",
|
|
||||||
"SENTRY_DSN": "not set",
|
|
||||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
|
||||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
|
||||||
"RECEIVER_RATE_LIMIT": "77",
|
|
||||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
|
||||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
|
||||||
}, shown)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, body, `href="/settings"`,
|
|
||||||
"the navigation bar links to the page",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const metricsPassword = "metrics-password-1f9a"
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
MetricsPassword: metricsPassword,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, metricsPassword)
|
|
||||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
|
||||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
sentryKey = "dsnkey7c2e"
|
|
||||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
|
||||||
)
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
SentryDSN: sentryDSN,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, sentryKey)
|
|
||||||
}
|
|
||||||
@@ -411,7 +411,9 @@ 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, "/hooks", w.Header().Get("Location"))
|
assert.Equal(
|
||||||
|
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(0),
|
t, int64(0),
|
||||||
|
|||||||
@@ -241,3 +241,37 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
assert.Contains(t, body, "(unavailable)")
|
assert.Contains(t, body, "(unavailable)")
|
||||||
assert.NotContains(t, body, "beak")
|
assert.NotContains(t, body, "beak")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
|
||||||
|
// page's maximum width at 108rem (1728 px), half again the 72rem of
|
||||||
|
// max-w-6xl that the webhook list and the event log use, so an
|
||||||
|
// entrypoint URL fits on one line in a 1920-pixel window; and the
|
||||||
|
// wrapping of its title row, so the buttons beside the title do not
|
||||||
|
// push a phone-width window into scrolling sideways.
|
||||||
|
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
|
||||||
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +580,8 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
|
||||||
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +664,9 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
|
||||||
@@ -841,24 +845,9 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
totalPages++
|
totalPages++
|
||||||
}
|
}
|
||||||
|
|
||||||
// The banner a replay or resubmit POST redirected back
|
|
||||||
// with. The message comes from a fixed set keyed by the
|
|
||||||
// outcome code, never from the query string itself.
|
|
||||||
replayMsg, replayOK := replayOutcome(
|
|
||||||
r.URL.Query().Get(replayOutcomeParam),
|
|
||||||
)
|
|
||||||
|
|
||||||
resubmitMsg, resubmitOK := resubmitOutcome(
|
|
||||||
r.URL.Query().Get(resubmitOutcomeParam),
|
|
||||||
)
|
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
tmplKeyWebhook: &webhook,
|
tmplKeyWebhook: &webhook,
|
||||||
"Events": evts,
|
"Events": evts,
|
||||||
"ReplayMessage": replayMsg,
|
|
||||||
"ReplayQueued": replayOK,
|
|
||||||
"ResubmitMessage": resubmitMsg,
|
|
||||||
"ResubmitQueued": resubmitOK,
|
|
||||||
"Page": page,
|
"Page": page,
|
||||||
"TotalPages": totalPages,
|
"TotalPages": totalPages,
|
||||||
"TotalEvents": total,
|
"TotalEvents": total,
|
||||||
@@ -1254,7 +1243,8 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
||||||
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1365,7 +1355,8 @@ func (h *Handlers) processTargetCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
|
||||||
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1643,6 +1634,7 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
|||||||
"entrypointID", &database.Entrypoint{},
|
"entrypointID", &database.Entrypoint{},
|
||||||
"failed to delete entrypoint",
|
"failed to delete entrypoint",
|
||||||
nil,
|
nil,
|
||||||
|
entrypointDeleted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1655,18 +1647,21 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
|
|||||||
"targetID", &database.Target{},
|
"targetID", &database.Target{},
|
||||||
"failed to delete target",
|
"failed to delete target",
|
||||||
h.evictArchiveWriterIfUnused,
|
h.evictArchiveWriterIfUnused,
|
||||||
|
targetDeleted,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteChildResource returns a handler that deletes a child
|
// deleteChildResource returns a handler that deletes a child
|
||||||
// resource (entrypoint or target) belonging to a webhook. The
|
// resource (entrypoint or target) belonging to a webhook. The
|
||||||
// optional afterDelete hook runs with the webhook's id once the
|
// optional afterDelete hook runs with the webhook's id once the
|
||||||
// delete has succeeded, before the redirect.
|
// delete has succeeded, before the redirect, which carries done as
|
||||||
|
// its notice.
|
||||||
func (h *Handlers) deleteChildResource(
|
func (h *Handlers) deleteChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
model any,
|
model any,
|
||||||
errMsg string,
|
errMsg string,
|
||||||
afterDelete func(webhookID string),
|
afterDelete func(webhookID string),
|
||||||
|
done noticeCode,
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1708,7 +1703,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/hook/"+webhook.ID,
|
withNotice("/hook/"+webhook.ID, done),
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1719,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||||
return h.toggleChildResource(
|
return h.toggleChildResource(
|
||||||
"entrypointID",
|
"entrypointID",
|
||||||
func(webhookID, childID string) error {
|
func(webhookID, childID string) (bool, error) {
|
||||||
var ep database.Entrypoint
|
var ep database.Entrypoint
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1727,14 +1722,15 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
childID, webhookID,
|
childID, webhookID,
|
||||||
).First(&ep).Error
|
).First(&ep).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ep.Active = !ep.Active
|
ep.Active = !ep.Active
|
||||||
|
|
||||||
return h.db.DB().Save(&ep).Error
|
return ep.Active, h.db.DB().Save(&ep).Error
|
||||||
},
|
},
|
||||||
"failed to toggle entrypoint",
|
"failed to toggle entrypoint",
|
||||||
|
entrypointActivated, entrypointDeactivated,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1742,7 +1738,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
|||||||
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
||||||
return h.toggleChildResource(
|
return h.toggleChildResource(
|
||||||
"targetID",
|
"targetID",
|
||||||
func(webhookID, childID string) error {
|
func(webhookID, childID string) (bool, error) {
|
||||||
var tgt database.Target
|
var tgt database.Target
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
@@ -1750,23 +1746,27 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
|
|||||||
childID, webhookID,
|
childID, webhookID,
|
||||||
).First(&tgt).Error
|
).First(&tgt).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tgt.Active = !tgt.Active
|
tgt.Active = !tgt.Active
|
||||||
|
|
||||||
return h.db.DB().Save(&tgt).Error
|
return tgt.Active, h.db.DB().Save(&tgt).Error
|
||||||
},
|
},
|
||||||
"failed to toggle target",
|
"failed to toggle target",
|
||||||
|
targetActivated, targetDeactivated,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggleChildResource returns a handler that toggles the active
|
// toggleChildResource returns a handler that toggles the active
|
||||||
// state of a child resource belonging to a webhook.
|
// state of a child resource belonging to a webhook. toggleFn returns
|
||||||
|
// the new state, and the redirect carries activated or deactivated as
|
||||||
|
// its notice to match.
|
||||||
func (h *Handlers) toggleChildResource(
|
func (h *Handlers) toggleChildResource(
|
||||||
idParam string,
|
idParam string,
|
||||||
toggleFn func(webhookID, childID string) error,
|
toggleFn func(webhookID, childID string) (bool, error),
|
||||||
errMsg string,
|
errMsg string,
|
||||||
|
activated, deactivated noticeCode,
|
||||||
) http.HandlerFunc {
|
) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
@@ -1792,16 +1792,21 @@ func (h *Handlers) toggleChildResource(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = toggleFn(webhook.ID, childID)
|
active, err := toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, errMsg, err)
|
h.serverError(w, r, errMsg, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
done := deactivated
|
||||||
|
if active {
|
||||||
|
done = activated
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r,
|
w, r,
|
||||||
"/hook/"+webhook.ID,
|
withNotice("/hook/"+webhook.ID, done),
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
|
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
|
||||||
|
http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
//go:build browser
|
||||||
|
|
||||||
|
// This test needs a headless browser, so it is built only with the
|
||||||
|
// browser build tag: `make test` leaves it out, and `make test-browser`
|
||||||
|
// runs it in the browser image that Dockerfile.browser pins.
|
||||||
|
|
||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/log"
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// browserTimeout bounds everything one test does in the browser.
|
||||||
|
browserTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
// settleTimeout bounds the wait for an element to show or hide.
|
||||||
|
settleTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// The window size of a phone, narrow enough that the pages show
|
||||||
|
// the mobile menu button instead of the navigation links.
|
||||||
|
phoneWidth = 390
|
||||||
|
phoneHeight = 844
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||||
|
// event log in a headless browser, served by the real router and so
|
||||||
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
|
// Alpine.js directives work.
|
||||||
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, problems := startBrowser(t)
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
srv := httptest.NewServer(env.router)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "browser", "browser-password")
|
||||||
|
webhook := env.seedWebhook(t, userID)
|
||||||
|
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
||||||
|
target := env.seedTarget(t, webhook.ID)
|
||||||
|
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
||||||
|
|
||||||
|
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
||||||
|
&database.DeliveryResult{
|
||||||
|
DeliveryID: dlv.ID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
||||||
|
))
|
||||||
|
|
||||||
|
page := srv.URL + "/hook/" + webhook.ID
|
||||||
|
|
||||||
|
checkAddForms(ctx, t, page)
|
||||||
|
checkTargetType(ctx, t, page+"/events")
|
||||||
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||||
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBrowser starts a headless browser for one test. It returns the
|
||||||
|
// context that drives it, and a function listing what the browser
|
||||||
|
// reported going wrong on its pages: console warnings and errors,
|
||||||
|
// which is how Alpine.js reports an expression it cannot run; uncaught
|
||||||
|
// exceptions; and every entry in the browser's own security log, which
|
||||||
|
// is where it reports each script, style, image or request the
|
||||||
|
// Content-Security-Policy refused.
|
||||||
|
//
|
||||||
|
// The browser library finds the browser on PATH. Without one the first
|
||||||
|
// chromedp.Run fails, and with it the test.
|
||||||
|
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
||||||
|
t.Context(),
|
||||||
|
append(
|
||||||
|
chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
// Dockerfile.browser runs the test as root, where the
|
||||||
|
// browser's sandbox cannot start.
|
||||||
|
chromedp.NoSandbox,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
t.Cleanup(cancelAlloc)
|
||||||
|
|
||||||
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
||||||
|
t.Cleanup(cancelTimeout)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
problems []string
|
||||||
|
)
|
||||||
|
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
var problem string
|
||||||
|
|
||||||
|
switch ev := ev.(type) {
|
||||||
|
case *runtime.EventConsoleAPICalled:
|
||||||
|
if ev.Type != runtime.APITypeWarning &&
|
||||||
|
ev.Type != runtime.APITypeError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]string, 0, len(ev.Args))
|
||||||
|
for _, arg := range ev.Args {
|
||||||
|
args = append(args, string(arg.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
problem = strings.Join(args, " ")
|
||||||
|
case *runtime.EventExceptionThrown:
|
||||||
|
problem = ev.ExceptionDetails.Error()
|
||||||
|
case *log.EventEntryAdded:
|
||||||
|
if ev.Entry.Source != log.SourceSecurity {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
problem = ev.Entry.Text
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
problems = append(problems, problem)
|
||||||
|
})
|
||||||
|
|
||||||
|
return ctx, func() []string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
return slices.Clone(problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCookies gives the browser the cookies for the server at base.
|
||||||
|
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
||||||
|
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
for _, c := range cookies {
|
||||||
|
err := network.SetCookie(c.Name, c.Value).
|
||||||
|
WithURL(base).
|
||||||
|
Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPage opens url and waits for Alpine.js to start, which it does
|
||||||
|
// by removing every x-cloak attribute. Until then x-cloak hides the
|
||||||
|
// elements Alpine would hide, so a check made earlier proves nothing.
|
||||||
|
func loadPage(url string) chromedp.Tasks {
|
||||||
|
return chromedp.Tasks{
|
||||||
|
chromedp.Navigate(url),
|
||||||
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shown waits up to settleTimeout for the elements matching a CSS
|
||||||
|
// selector or an XPath expression to be rendered, and reports whether
|
||||||
|
// they were. The wait is needed because Alpine.js shows an element on
|
||||||
|
// the next animation frame, not at once.
|
||||||
|
func shown(ctx context.Context, selector string) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return chromedp.Run(
|
||||||
|
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
|
||||||
|
) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hidden is shown's opposite: it waits for the elements to be hidden.
|
||||||
|
func hidden(ctx context.Context, selector string) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return chromedp.Run(
|
||||||
|
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
|
||||||
|
) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// click clicks the element matching an XPath expression.
|
||||||
|
func click(ctx context.Context, t *testing.T, xpath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAddForms loads a webhook page and checks that each section's add
|
||||||
|
// form stays hidden until the Add button beside its heading is clicked.
|
||||||
|
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
sections := []struct{ heading, form string }{
|
||||||
|
{"Entrypoints", `form[action$="/entrypoints"]`},
|
||||||
|
{"Targets", `form[action$="/targets"]`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range sections {
|
||||||
|
assert.Truef(
|
||||||
|
t, hidden(ctx, s.form),
|
||||||
|
"%s: the add form shows before Add is clicked", s.heading,
|
||||||
|
)
|
||||||
|
|
||||||
|
click(ctx, t, `//h2[text()="`+s.heading+
|
||||||
|
`"]/following-sibling::button`)
|
||||||
|
|
||||||
|
assert.Truef(
|
||||||
|
t, shown(ctx, s.form),
|
||||||
|
"%s: the add form stays hidden when Add is clicked", s.heading,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTargetType chooses Slack in the open add target form and checks
|
||||||
|
// what the form would then submit: one url field, the Slack one, and
|
||||||
|
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
||||||
|
//
|
||||||
|
// It then opens the page at elsewhere and goes back. The browser loads
|
||||||
|
// the webhook page again and restores the form as it was left, Slack
|
||||||
|
// chosen, without a change event; the form must again show and submit
|
||||||
|
// Slack's fields, not the HTTP ones.
|
||||||
|
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const (
|
||||||
|
chooseSlack = `(() => {
|
||||||
|
const type = document.querySelector('select[name="type"]');
|
||||||
|
type.value = "slack";
|
||||||
|
type.dispatchEvent(new Event("change"));
|
||||||
|
})()`
|
||||||
|
chosen = `document.querySelector('select[name="type"]').value`
|
||||||
|
howLoaded = `performance.getEntriesByType("navigation")[0].type`
|
||||||
|
submitted = `[...new FormData(
|
||||||
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
||||||
|
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
|
||||||
|
httpURL = `input[placeholder="https://example.com/webhook"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
slackFields := strings.Fields("csrf_token name type max_retries url")
|
||||||
|
|
||||||
|
var fields []string
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.Evaluate(chooseSlack, nil),
|
||||||
|
chromedp.Evaluate(submitted, &fields),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, slackFields, fields,
|
||||||
|
"with Slack chosen, the HTTP fields must not be submitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
var loaded, restored string
|
||||||
|
|
||||||
|
// Going back waits for the load event, after which the browser has
|
||||||
|
// restored the form.
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
loadPage(elsewhere),
|
||||||
|
chromedp.NavigateBack(),
|
||||||
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
||||||
|
chromedp.Evaluate(howLoaded, &loaded),
|
||||||
|
chromedp.Evaluate(chosen, &restored),
|
||||||
|
))
|
||||||
|
|
||||||
|
// A page the browser kept in memory and showed again as it was
|
||||||
|
// would prove nothing here.
|
||||||
|
require.Equal(
|
||||||
|
t, "back_forward", loaded,
|
||||||
|
"going back, the browser did not load the page again",
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t, "slack", restored,
|
||||||
|
"going back, the browser did not restore the chosen type",
|
||||||
|
)
|
||||||
|
|
||||||
|
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, slackURL),
|
||||||
|
"going back with Slack chosen, the Slack fields are not shown")
|
||||||
|
assert.True(t, hidden(ctx, httpURL),
|
||||||
|
"going back with Slack chosen, the HTTP fields are shown")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Evaluate(submitted, &fields),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, slackFields, fields,
|
||||||
|
"going back with Slack chosen, the HTTP fields must not be submitted",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEventLog loads the event log and checks that clicking an event's
|
||||||
|
// row expands it, that in there clicking its delivery shows the
|
||||||
|
// delivery's attempts and clicking again hides them, and that clicking
|
||||||
|
// the event's row again collapses it.
|
||||||
|
func checkEventLog(
|
||||||
|
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// The event's row shows its ID, and its Resubmit form is in the part
|
||||||
|
// that expands. The delivery's row there shows the target's name.
|
||||||
|
eventRow := `//span[text()="` + eventID + `"]`
|
||||||
|
expanded := `form[action$="/resubmit"]`
|
||||||
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||||
|
|
||||||
|
click(ctx, t, eventRow)
|
||||||
|
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||||
|
|
||||||
|
click(ctx, t, deliveryRow)
|
||||||
|
assert.True(t, shown(ctx, attempt),
|
||||||
|
"clicking the delivery does not show its attempts")
|
||||||
|
|
||||||
|
click(ctx, t, deliveryRow)
|
||||||
|
assert.True(t, hidden(ctx, attempt),
|
||||||
|
"clicking the delivery again does not hide its attempts")
|
||||||
|
|
||||||
|
click(ctx, t, eventRow)
|
||||||
|
assert.True(t, hidden(ctx, expanded),
|
||||||
|
"clicking the event again does not collapse it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||||
|
// the menu button opens and closes the mobile menu.
|
||||||
|
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// The menu button is the only button directly in the navigation
|
||||||
|
// bar's top row. Profile is a link only the mobile menu has.
|
||||||
|
button := `//nav/div/button`
|
||||||
|
menu := `//nav//a[text()="Profile"]`
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
||||||
|
loadPage(url),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
|
||||||
|
|
||||||
|
click(ctx, t, button)
|
||||||
|
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
|
||||||
|
|
||||||
|
click(ctx, t, button)
|
||||||
|
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
||||||
|
}
|
||||||
@@ -83,6 +83,22 @@ func TestErrorPage_DeletedTarget(t *testing.T) {
|
|||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestErrorPage_ShowsNoNotice pins that a notice code in the URL of a
|
||||||
|
// page that fails is not shown above the error.
|
||||||
|
func TestErrorPage_ShowsNoNotice(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
w := env.get("/hook/no-such-webhook?notice=webhook-saved", cookies)
|
||||||
|
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
assert.NotContains(t, w.Body.String(), "Webhook saved.")
|
||||||
|
}
|
||||||
|
|
||||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
func TestErrorPage_UnknownPath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import (
|
|||||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// The five admin page route groups below (/pages, /user/{username},
|
// The four admin page route groups below (/pages, /user/{username},
|
||||||
// /settings, /hooks and /hook/{sourceID}) install
|
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
|
||||||
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
// right after their recoverer and error reporting, ahead of both CSRF
|
||||||
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
|
// and RequireAuth. Both orderings are deliberate.
|
||||||
// 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
|
||||||
@@ -155,7 +154,6 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
s.setupPageRoutes()
|
s.setupPageRoutes()
|
||||||
s.setupUserRoutes()
|
s.setupUserRoutes()
|
||||||
s.setupSettingsRoutes()
|
|
||||||
s.setupSourceRoutes()
|
s.setupSourceRoutes()
|
||||||
s.setupWebhookRoutes()
|
s.setupWebhookRoutes()
|
||||||
}
|
}
|
||||||
@@ -203,24 +201,6 @@ func (s *Server) setupUserRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
|
||||||
// configuration comes from the environment and nothing here changes
|
|
||||||
// it.
|
|
||||||
func (s *Server) setupSettingsRoutes() {
|
|
||||||
s.router.Route("/settings", func(r chi.Router) {
|
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
||||||
r.Use(s.mw.NoCache())
|
|
||||||
r.Use(s.mw.RequireAuth())
|
|
||||||
r.Get("/", s.h.HandleSettings())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/hooks", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
s.recoverPanics(
|
||||||
|
|||||||
@@ -263,6 +263,24 @@ func (e *testEnv) urlFrom(
|
|||||||
return html.UnescapeString(match[1])
|
return html.UnescapeString(match[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireNotice requires w to redirect to dest carrying the notice
|
||||||
|
// code, then renders that page and requires it to show text.
|
||||||
|
func (e *testEnv) requireNotice(
|
||||||
|
t *testing.T,
|
||||||
|
w *httptest.ResponseRecorder,
|
||||||
|
dest, code, text string,
|
||||||
|
cookies []*http.Cookie,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
require.Equal(t, dest+"?notice="+code, w.Header().Get("Location"))
|
||||||
|
|
||||||
|
page := e.get(w.Header().Get("Location"), cookies)
|
||||||
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
assert.Contains(t, page.Body.String(), text)
|
||||||
|
}
|
||||||
|
|
||||||
// authCookies forges an authenticated session for the given user.
|
// authCookies forges an authenticated session for the given user.
|
||||||
func (e *testEnv) authCookies(
|
func (e *testEnv) authCookies(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
@@ -741,6 +759,31 @@ func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
|||||||
assert.Equal(t, asked, w.Header().Get("Location"))
|
assert.Equal(t, asked, w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPagesLogout_SaysSignedOut signs out with the navbar's form and
|
||||||
|
// lands on the sign-in page, which says so.
|
||||||
|
func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "leaver", "somepassword")
|
||||||
|
token, cookies := env.csrfFrom(
|
||||||
|
t, "/hooks", env.authCookies(t, userID, "leaver"),
|
||||||
|
)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
|
w := env.post(
|
||||||
|
env.urlFrom(t, "/hooks", `action="(/pages/logout)"`, cookies),
|
||||||
|
form, cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The sign-in page is requested without the session cookie, which
|
||||||
|
// the logout told the browser to delete.
|
||||||
|
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
@@ -858,9 +901,9 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
|
|||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
env.db.DB().Where("name = ?", "created").First(&created).Error,
|
||||||
)
|
)
|
||||||
assert.Equal(
|
env.requireNotice(
|
||||||
t, "/hook/"+created.ID, w.Header().Get("Location"),
|
t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
|
||||||
"creating a webhook should redirect to its page",
|
cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,8 +934,7 @@ func TestHook_EditFormAndDelete(t *testing.T) {
|
|||||||
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
|
||||||
form, cookies,
|
form, cookies,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
|
||||||
assert.Equal(t, page, w.Header().Get("Location"))
|
|
||||||
|
|
||||||
var edited database.Webhook
|
var edited database.Webhook
|
||||||
|
|
||||||
@@ -906,16 +948,17 @@ func TestHook_EditFormAndDelete(t *testing.T) {
|
|||||||
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
|
||||||
form, cookies,
|
form, cookies,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
env.requireNotice(
|
||||||
assert.Equal(t, "/hooks", w.Header().Get("Location"))
|
t, w, "/hooks", "webhook-deleted", "Webhook deleted.", cookies,
|
||||||
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, http.StatusNotFound, env.get(page, cookies).Code,
|
t, http.StatusNotFound, env.get(page, cookies).Code,
|
||||||
"a deleted webhook's page should be gone",
|
"a deleted webhook's page should be gone",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHook_EntrypointActions adds, deactivates and deletes an
|
// TestHook_EntrypointActions adds, deactivates, activates and deletes
|
||||||
// entrypoint with the forms on the webhook page, each submitted to
|
// an entrypoint with the forms on the webhook page, each submitted to
|
||||||
// the action and with the token the page rendered.
|
// the action and with the token the page rendered.
|
||||||
func TestHook_EntrypointActions(t *testing.T) {
|
func TestHook_EntrypointActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -933,16 +976,19 @@ func TestHook_EntrypointActions(t *testing.T) {
|
|||||||
form.Set("csrf_token", token)
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
// submit posts the webhook page's form whose action pattern
|
// submit posts the webhook page's form whose action pattern
|
||||||
// captures, and requires the redirect back to that page.
|
// captures, and requires the redirect back to that page with the
|
||||||
submit := func(pattern string) {
|
// notice code, and the page to show text.
|
||||||
|
submit := func(pattern, code, text string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
env.requireNotice(t, w, page, code, text, cookies)
|
||||||
require.Equal(t, page, w.Header().Get("Location"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(`action="(/hook/[^/"]+/entrypoints)"`)
|
toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
|
||||||
|
|
||||||
|
submit(`action="(/hook/[^/"]+/entrypoints)"`,
|
||||||
|
"entrypoint-added", "Entrypoint added.")
|
||||||
|
|
||||||
var added database.Entrypoint
|
var added database.Entrypoint
|
||||||
|
|
||||||
@@ -951,7 +997,7 @@ func TestHook_EntrypointActions(t *testing.T) {
|
|||||||
)
|
)
|
||||||
require.True(t, added.Active)
|
require.True(t, added.Active)
|
||||||
|
|
||||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
|
submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
|
||||||
|
|
||||||
var toggled database.Entrypoint
|
var toggled database.Entrypoint
|
||||||
|
|
||||||
@@ -960,7 +1006,10 @@ func TestHook_EntrypointActions(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||||
|
|
||||||
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
|
submit(toggle, "entrypoint-activated", "Entrypoint activated.")
|
||||||
|
|
||||||
|
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
|
||||||
|
"entrypoint-deleted", "Entrypoint deleted.")
|
||||||
|
|
||||||
var left int64
|
var left int64
|
||||||
|
|
||||||
@@ -971,8 +1020,8 @@ func TestHook_EntrypointActions(t *testing.T) {
|
|||||||
|
|
||||||
// TestHook_TargetActions adds a target with the form on the webhook
|
// TestHook_TargetActions adds a target with the form on the webhook
|
||||||
// page, follows its Edit link to the target edit form and submits
|
// page, follows its Edit link to the target edit form and submits
|
||||||
// it, then deactivates and deletes it, every URL and token taken from
|
// it, then deactivates, activates and deletes it, every URL and token
|
||||||
// the rendered pages.
|
// taken from the rendered pages.
|
||||||
func TestHook_TargetActions(t *testing.T) {
|
func TestHook_TargetActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -987,27 +1036,29 @@ func TestHook_TargetActions(t *testing.T) {
|
|||||||
|
|
||||||
// submit posts form, with the token, to the action pattern
|
// submit posts form, with the token, to the action pattern
|
||||||
// captures on the page at from, and requires the redirect back to
|
// captures on the page at from, and requires the redirect back to
|
||||||
// the webhook page.
|
// the webhook page with the notice code, and that page to show
|
||||||
submit := func(from, pattern string, form url.Values) {
|
// text.
|
||||||
|
submit := func(from, pattern string, form url.Values, code, text string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
form.Set("csrf_token", token)
|
form.Set("csrf_token", token)
|
||||||
|
|
||||||
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
env.requireNotice(t, w, page, code, text, cookies)
|
||||||
require.Equal(t, page, w.Header().Get("Location"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggle := `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`
|
||||||
|
|
||||||
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
|
||||||
"name": {"added"},
|
"name": {"added"},
|
||||||
"type": {string(database.TargetTypeLog)},
|
"type": {string(database.TargetTypeLog)},
|
||||||
})
|
}, "target-added", "Target added.")
|
||||||
|
|
||||||
editPage := env.urlFrom(
|
editPage := env.urlFrom(
|
||||||
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
|
||||||
)
|
)
|
||||||
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
|
||||||
url.Values{"name": {"renamed"}})
|
url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
|
||||||
|
|
||||||
var edited database.Target
|
var edited database.Target
|
||||||
|
|
||||||
@@ -1017,8 +1068,8 @@ func TestHook_TargetActions(t *testing.T) {
|
|||||||
assert.Equal(t, "renamed", edited.Name)
|
assert.Equal(t, "renamed", edited.Name)
|
||||||
require.True(t, edited.Active)
|
require.True(t, edited.Active)
|
||||||
|
|
||||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
|
submit(page, toggle, url.Values{},
|
||||||
url.Values{})
|
"target-deactivated", "Target deactivated.")
|
||||||
|
|
||||||
var toggled database.Target
|
var toggled database.Target
|
||||||
|
|
||||||
@@ -1027,8 +1078,11 @@ func TestHook_TargetActions(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
assert.False(t, toggled.Active, "the toggle should deactivate it")
|
||||||
|
|
||||||
|
submit(page, toggle, url.Values{},
|
||||||
|
"target-activated", "Target activated.")
|
||||||
|
|
||||||
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
|
||||||
url.Values{})
|
url.Values{}, "target-deleted", "Target deleted.")
|
||||||
|
|
||||||
var left int64
|
var left int64
|
||||||
|
|
||||||
@@ -1064,9 +1118,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
|
||||||
form, cookies,
|
form, cookies,
|
||||||
)
|
)
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
env.requireNotice(
|
||||||
assert.Equal(
|
t, w, logsPath, "resubmit-no-targets",
|
||||||
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
|
"this source has no active targets", cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||||
@@ -1302,10 +1356,8 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
|
|||||||
html.UnescapeString(action[1]), form, cookies,
|
html.UnescapeString(action[1]), form, cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
env.requireNotice(
|
||||||
assert.Equal(
|
t, w, logsPath, "replay-queued", "Replay queued:", cookies,
|
||||||
t, logsPath+"?replay=queued",
|
|
||||||
w.Header().Get("Location"),
|
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, int64(2), env.countDeliveries(t, wh.ID),
|
t, int64(2), env.countDeliveries(t, wh.ID),
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
w := env.get("/settings", nil)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
|
||||||
}
|
|
||||||
+5
-4
@@ -1,15 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/assets: extract Alpine.js from its npm package tarball, committed
|
# script/assets: extract Alpine.js from its npm package tarball, committed
|
||||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
|
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package
|
||||||
# extracted file is not committed. script/test, make build and make dev run
|
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
|
||||||
# this first.
|
# forbids eval. The extracted file is not committed. script/test, make
|
||||||
|
# build and make dev run this first.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
|
||||||
>static/js/alpine.min.js
|
>static/js/alpine.min.js
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/test-browser: run the browser test in internal/server. It runs in
|
||||||
|
# Docker: Dockerfile.browser builds the test and runs it in a digest-pinned
|
||||||
|
# headless browser image, so the host needs no browser.
|
||||||
|
#
|
||||||
|
# --no-cache-filter=browser runs the test again even when nothing changed;
|
||||||
|
# it must name the stage in Dockerfile.browser that runs it.
|
||||||
|
# --output=type=cacheonly leaves no image behind to clean up.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build \
|
||||||
|
-f Dockerfile.browser \
|
||||||
|
--no-cache-filter=browser \
|
||||||
|
--progress=plain \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
.
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -57,3 +57,73 @@
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Alpine.js components.
|
||||||
|
//
|
||||||
|
// The pages' Content-Security-Policy forbids eval, so the UI loads
|
||||||
|
// Alpine's CSP build, which cannot run expressions written in the
|
||||||
|
// markup: a directive in templates/ may only name a property or method,
|
||||||
|
// and each x-data names a component registered here. This script runs
|
||||||
|
// before Alpine, whose script tag is deferred, so this listener is in
|
||||||
|
// place when Alpine starts.
|
||||||
|
document.addEventListener("alpine:init", function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Something a click shows and hides: the mobile menu, an add form,
|
||||||
|
// an event in the event log, a delivery's attempts.
|
||||||
|
window.Alpine.data("collapsible", function () {
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
toggle() {
|
||||||
|
this.open = !this.open;
|
||||||
|
},
|
||||||
|
get closed() {
|
||||||
|
return !this.open;
|
||||||
|
},
|
||||||
|
// Turns a downward caret up while open.
|
||||||
|
get caretClass() {
|
||||||
|
return { "rotate-180": this.open };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The add target form. Only the chosen type's fields show, and the
|
||||||
|
// others are disabled so that the form does not submit them.
|
||||||
|
//
|
||||||
|
// The type is read from the type select when Alpine starts, when the
|
||||||
|
// select changes, and on pageshow. Going back to the page, the
|
||||||
|
// browser restores the type chosen before without a change event,
|
||||||
|
// in some browsers only after Alpine has started, but always before
|
||||||
|
// pageshow.
|
||||||
|
window.Alpine.data("targetForm", function () {
|
||||||
|
return {
|
||||||
|
targetType: "",
|
||||||
|
init() {
|
||||||
|
this.readType();
|
||||||
|
},
|
||||||
|
readType() {
|
||||||
|
this.targetType = this.$root.querySelector(
|
||||||
|
'select[name="type"]'
|
||||||
|
).value;
|
||||||
|
},
|
||||||
|
get isHttp() {
|
||||||
|
return this.targetType === "http";
|
||||||
|
},
|
||||||
|
get isSlack() {
|
||||||
|
return this.targetType === "slack";
|
||||||
|
},
|
||||||
|
get isDatabase() {
|
||||||
|
return this.targetType === "database";
|
||||||
|
},
|
||||||
|
get notHttp() {
|
||||||
|
return !this.isHttp;
|
||||||
|
},
|
||||||
|
get notSlack() {
|
||||||
|
return !this.isSlack;
|
||||||
|
},
|
||||||
|
get notDatabase() {
|
||||||
|
return !this.isDatabase;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
|
{{template "notice" .}}
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "navbar"}}
|
{{define "navbar"}}
|
||||||
<nav class="app-bar" x-data="{ open: false }">
|
<nav class="app-bar" x-data="collapsible">
|
||||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text">Webhooks</a>
|
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||||
<a href="/settings" class="btn-text">Settings</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"/>
|
||||||
@@ -44,7 +43,6 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/settings" class="btn-text w-full text-left">Settings</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}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "notice"}}
|
||||||
|
{{with .Notice}}
|
||||||
|
<div class="max-w-6xl mx-auto px-6 pt-4">
|
||||||
|
<div class="{{if .Failed}}alert-error{{else}}alert-success{{end}}">{{.Text}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}Settings - Webhooker{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
|
||||||
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="divide-y divide-gray-100">
|
|
||||||
{{range .Settings}}
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex justify-between items-start gap-4">
|
|
||||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
|
||||||
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -3,10 +3,14 @@
|
|||||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
|
||||||
|
event log, the navbar and the footer, so an entrypoint URL fits on
|
||||||
|
one line. An inline style, because the committed tailwind.css has
|
||||||
|
no class this wide. -->
|
||||||
|
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hooks" 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 flex-wrap justify-between items-center gap-2 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>
|
||||||
{{if .Webhook.Description}}
|
{{if .Webhook.Description}}
|
||||||
@@ -28,10 +32,10 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card">
|
<div class="card" x-data="collapsible">
|
||||||
<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">Entrypoints</h2>
|
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
||||||
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
<button @click="toggle" class="btn-text text-sm">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 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>
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/hook/{{.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">
|
||||||
@@ -87,10 +91,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Targets -->
|
<!-- Targets -->
|
||||||
<div class="card">
|
<div class="card" x-data="collapsible">
|
||||||
<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">Targets</h2>
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
<button @click="toggle" class="btn-text text-sm">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 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>
|
||||||
@@ -99,42 +103,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" 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">
|
||||||
<select name="type" x-model="targetType" class="input text-sm w-32">
|
<select name="type" @change="readType" class="input text-sm w-32">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Database</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'">
|
<div x-show="isHttp">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'">
|
<div x-show="isHttp">
|
||||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
<div x-show="isHttp" class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||||
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'">
|
<div x-show="isHttp">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'slack'">
|
<div x-show="isSlack">
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'database'">
|
<div x-show="isDatabase">
|
||||||
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||||
|
|||||||
@@ -12,19 +12,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .ReplayMessage}}
|
|
||||||
<div class="{{if .ReplayQueued}}alert-success{{else}}alert-error{{end}}">{{.ReplayMessage}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .ResubmitMessage}}
|
|
||||||
<div class="{{if .ResubmitQueued}}alert-success{{else}}alert-error{{end}}">{{.ResubmitMessage}}</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4" x-data="{ open: false }">
|
<div class="p-4" x-data="collapsible">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||||
@@ -43,7 +35,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,8 +63,8 @@
|
|||||||
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
||||||
<div class="mt-2 divide-y divide-gray-200">
|
<div class="mt-2 divide-y divide-gray-200">
|
||||||
{{range .Deliveries}}
|
{{range .Deliveries}}
|
||||||
<div class="py-2" x-data="{ attempts: false }">
|
<div class="py-2" x-data="collapsible">
|
||||||
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||||
@@ -86,13 +78,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
|
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
||||||
{{if .AttemptsOmitted}}
|
{{if .AttemptsOmitted}}
|
||||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user