Compare commits
5
Commits
82478e8052
..
prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1647b43aa6 | ||
|
|
f7151f0168 | ||
|
|
3cc05a36eb | ||
|
|
7316f0a7e2 | ||
|
|
5f84d891cf |
+4
-2
@@ -3,8 +3,10 @@
|
|||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
.git/
|
.git/
|
||||||
bin/
|
bin/
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# Third-party browser assets are fetched and hash-verified inside the build by
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
||||||
|
# from supplying the bytes that get shipped. The script and its
|
||||||
|
# static/vendor.sha256 manifest stay in the context.
|
||||||
static/js/alpine.min.js
|
static/js/alpine.min.js
|
||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
|
|||||||
+3
-2
@@ -46,6 +46,7 @@ temp/
|
|||||||
# CI cache barrier, written into the build context by the check workflow
|
# CI cache barrier, written into the build context by the check workflow
|
||||||
.ci-fingerprint
|
.ci-fingerprint
|
||||||
|
|
||||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
# Third-party browser assets, fetched and hash-verified by
|
||||||
# what is committed.
|
# script/fetch-assets against static/vendor.sha256. Not committed:
|
||||||
|
# REPO_POLICIES.md forbids minified bundles in version control.
|
||||||
/static/js/alpine.min.js
|
/static/js/alpine.min.js
|
||||||
Binary file not shown.
+11
-4
@@ -51,8 +51,15 @@ RUN go mod download
|
|||||||
# the lint stage above.
|
# the lint stage above.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run tests and build. Both first run script/assets, which extracts Alpine.js
|
# Fetch the third-party browser assets the UI serves. They are not committed
|
||||||
# from its tarball in 3p/.
|
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
||||||
|
# .dockerignore keeps any host copy out of the build context, so this step is
|
||||||
|
# the only way they enter the image. Each download is checked against a
|
||||||
|
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
|
||||||
|
# hashes against the bytes go:embed actually put in the binary.
|
||||||
|
RUN script/fetch-assets
|
||||||
|
|
||||||
|
# Run tests and build
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||||
@@ -60,8 +67,8 @@ RUN make test
|
|||||||
# host and passes it in. The default is what a bare `docker build .`
|
# host and passes it in. The default is what a bare `docker build .`
|
||||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||||
#
|
#
|
||||||
# Declared here, below the test step, so a changed version does not
|
# Declared here, below the test and asset steps, so a changed version
|
||||||
# invalidate its cached layer.
|
# does not invalidate their cached layers.
|
||||||
ARG VERSION=unknown
|
ARG VERSION=unknown
|
||||||
|
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
@script/assets
|
@script/fetch-assets
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
@@ -45,13 +45,13 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build: assets
|
build:
|
||||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
|
|
||||||
dev: assets
|
dev:
|
||||||
go run ./cmd/webhooker
|
go run ./cmd/webhooker
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ before deploying one.
|
|||||||
- 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 test stage of the CI gate, and for
|
||||||
containerized deployment)
|
containerized deployment)
|
||||||
|
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||||
|
browser assets, which are not committed (`make bootstrap` installs
|
||||||
|
it if missing)
|
||||||
|
|
||||||
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
|
||||||
@@ -33,7 +36,9 @@ digest-pinned linter image via `Dockerfile.lint`.
|
|||||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||||
cd webhooker
|
cd webhooker
|
||||||
|
|
||||||
# Install the Go toolchain if missing, and the Go dependencies
|
# Install Go dependencies and the third-party browser assets.
|
||||||
|
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||||
|
# and the checks below need the fetched assets.
|
||||||
make bootstrap
|
make bootstrap
|
||||||
|
|
||||||
# Run all checks (test, lint, format check)
|
# Run all checks (test, lint, format check)
|
||||||
@@ -53,7 +58,7 @@ make docker
|
|||||||
```bash
|
```bash
|
||||||
make bootstrap # Install all dependencies (idempotent)
|
make bootstrap # Install all dependencies (idempotent)
|
||||||
make setup # Bootstrap + install git pre-commit hook
|
make setup # Bootstrap + install git pre-commit hook
|
||||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
make assets # Fetch + verify third-party browser assets
|
||||||
make fmt # Format code (gofmt + goimports)
|
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)
|
||||||
@@ -145,11 +150,6 @@ TTY detection, and security headers are always applied.
|
|||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
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
|
||||||
@@ -1221,15 +1221,14 @@ This repository adheres to the
|
|||||||
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. Ten of the Makefile's seventeen 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
|
||||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
`build` and `version` both take their value from `script/version`.
|
||||||
take their value from `script/version`.
|
|
||||||
|
|
||||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
`make check` needs the third-party browser assets in `static/`, which
|
||||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
are not committed, so run `make bootstrap` (or just `make assets`) once
|
||||||
[Third-party browser assets](#third-party-browser-assets)), so
|
after cloning. Without them the tests fail with a message naming that
|
||||||
`make test`, `make check` and the pre-commit hook work on a fresh clone
|
remedy. `make check` does not fetch them itself because it must not
|
||||||
without a separate step.
|
change any files in the repo.
|
||||||
|
|
||||||
We provide:
|
We provide:
|
||||||
|
|
||||||
@@ -1237,8 +1236,8 @@ We provide:
|
|||||||
- `script/setup` — make a fresh clone ready for development
|
- `script/setup` — make a fresh clone ready for development
|
||||||
(bootstrap, then install-precommit)
|
(bootstrap, then install-precommit)
|
||||||
- `script/projectname` — output the project name ("webhooker")
|
- `script/projectname` — output the project name ("webhooker")
|
||||||
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
- `script/fetch-assets` — download the third-party browser assets into
|
||||||
[Third-party browser assets](#third-party-browser-assets))
|
`static/`, verifying each against its pinned sha256
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `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)
|
||||||
@@ -1260,25 +1259,24 @@ 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. It is **not** committed:
|
||||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
||||||
publishes it. It is a dependency, not this repo's build output, so
|
both committed build artifacts and unpinned external references.
|
||||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
|
||||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
|
||||||
`vendor/` directory as its module vendor directory.
|
|
||||||
|
|
||||||
`script/assets` (`make assets`) extracts the browser build,
|
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
||||||
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
|
download against a hardcoded sha256, and installs it under `static/`. The
|
||||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
||||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
||||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
against that manifest — so the pin is enforced on what actually ships, not
|
||||||
`.dockerignore` keeps any host copy out of the build context.
|
merely written down. Any mismatch fails the build.
|
||||||
|
|
||||||
To move to a new version: download
|
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
||||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
||||||
against the `dist.integrity` hash listed at
|
artifact out of both the repo and the build context.
|
||||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
|
||||||
with it, update its file name in `script/assets`, and run `make check`.
|
To move to a new version: update the version, URL, and tarball sha256 in
|
||||||
|
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
||||||
|
run `make assets && make check`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -1441,7 +1439,7 @@ A registered user of the webhooker service.
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | -------- | ----------- |
|
| ---------- | -------- | ----------- |
|
||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie |
|
| `username` | string | Unique login name |
|
||||||
| `password` | string | Argon2id hash (never exposed via API) |
|
| `password` | string | Argon2id hash (never exposed via API) |
|
||||||
|
|
||||||
**Relations:** Has many Webhooks. Has many APIKeys.
|
**Relations:** Has many Webhooks. Has many APIKeys.
|
||||||
@@ -2381,14 +2379,14 @@ Removing either cap fails 14 subtests.
|
|||||||
|
|
||||||
`internal/middleware/logbound_test.go` and
|
`internal/middleware/logbound_test.go` and
|
||||||
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
|
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
|
||||||
at each of these — just under 1 KB at `invalid password`, whose
|
at each of these — 1 KB at `invalid password`, whose accounts are
|
||||||
accounts are shared with the successful-login line and so must stay
|
shared with the successful-login line, where a username past 4 KB
|
||||||
within the 1024-byte username limit — through both handlers, and
|
overflows the session cookie and answers 500 before that line is
|
||||||
through seven fills: plain text as the baseline, and then the
|
written — through both handlers, and through seven fills: plain text
|
||||||
quotation mark, backslash, tab, newline, C0 control and astral
|
as the baseline, and then the quotation mark, backslash, tab, newline,
|
||||||
non-printable, six characters the wider of the two handlers spends
|
C0 control and astral non-printable, six characters the wider of the
|
||||||
more on than the client spent sending them. Every case holds each
|
two handlers spends more on than the client spent sending them. Every
|
||||||
line to the 2,560-byte ceiling. That per-line ceiling
|
case holds each line to the 2,560-byte ceiling. That per-line ceiling
|
||||||
is what the figure above states, and every row establishes it.
|
is what the figure above states, and every row establishes it.
|
||||||
|
|
||||||
Three of the sites go further and bound the whole flood's output — the
|
Three of the sites go further and bound the whole flood's output — the
|
||||||
@@ -2707,7 +2705,6 @@ abuse limit later; they are tracked as future work.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/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` | `/sources` | List user's webhooks |
|
| `GET` | `/sources` | List user's webhooks |
|
||||||
| `GET` | `/sources/new` | Create webhook form |
|
| `GET` | `/sources/new` | Create webhook form |
|
||||||
| `POST` | `/sources/new` | Create webhook submission |
|
| `POST` | `/sources/new` | Create webhook submission |
|
||||||
@@ -2758,8 +2755,6 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
webhooker/
|
webhooker/
|
||||||
├── 3p/
|
|
||||||
│ └── alpinejs-3.14.9.tgz # Alpine.js 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/
|
||||||
@@ -2820,7 +2815,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/
|
||||||
@@ -2854,7 +2848,8 @@ webhooker/
|
|||||||
│ ├── 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 # Progressive-enhancement copy-to-clipboard
|
||||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
|
||||||
|
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
|
||||||
├── 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
|
||||||
@@ -3166,14 +3161,14 @@ version is fixed independently of the compiler's:
|
|||||||
`make fmt-check`, then `golangci-lint config verify` and
|
`make fmt-check`, then `golangci-lint config verify` and
|
||||||
`golangci-lint run`, both with `--network=none`.
|
`golangci-lint run`, both with `--network=none`.
|
||||||
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
|
||||||
stage passing (it copies a file from it), runs `make test` and
|
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
to download and verify the third-party browser assets, then runs
|
||||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
`make test` and `make build`, and finally rebuilds the binary with
|
||||||
runs on musl. Both builds go through `make build`, the relink adding
|
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
||||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
go through `make build`, the relink adding its `-extldflags` via
|
||||||
stamps the version. The version arrives as the `VERSION` build arg,
|
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||||
since the context has no `.git` (see
|
The version arrives as the `VERSION` build arg, since the context
|
||||||
[Version stamping](#version-stamping)).
|
has no `.git` (see [Version stamping](#version-stamping)).
|
||||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
directory for all SQLite databases, exposes port 8080, and includes
|
directory for all SQLite databases, exposes port 8080, and includes
|
||||||
|
|||||||
@@ -1,58 +1,13 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaxUsernameBytes is the longest username, in bytes, that a user may
|
|
||||||
// have. The same number appears in the check constraint on
|
|
||||||
// User.Username, because a struct tag cannot reference a constant.
|
|
||||||
//
|
|
||||||
// A login stores the username in the session cookie, and both
|
|
||||||
// securecookie and browsers refuse a cookie value past about 4096
|
|
||||||
// bytes. That value is the session base64-encoded twice, so it holds
|
|
||||||
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
|
|
||||||
// timestamp and the session's other values take about 270 of those: a
|
|
||||||
// username longer than about 2030 bytes can never log in. The limit is
|
|
||||||
// about half that, so the session can carry more values later without
|
|
||||||
// locking out an account whose username is already at the limit.
|
|
||||||
const MaxUsernameBytes = 1024
|
|
||||||
|
|
||||||
// ErrUsernameTooLong is returned when a user is saved with a username
|
|
||||||
// longer than MaxUsernameBytes.
|
|
||||||
var ErrUsernameTooLong = errors.New("username is too long")
|
|
||||||
|
|
||||||
// User represents a user of the webhooker service
|
// User represents a user of the webhooker service
|
||||||
//
|
|
||||||
//nolint:lll // a struct tag cannot wrap
|
|
||||||
type User struct {
|
type User struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
|
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||||
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
|
|
||||||
// User is created or saved, so those calls get ErrUsernameTooLong rather
|
|
||||||
// than the database's constraint error. A column update such as
|
|
||||||
// Update("username", ...) is caught only by the check constraint, as is
|
|
||||||
// any path that writes the table without this model.
|
|
||||||
func (u *User) BeforeSave(_ *gorm.DB) error {
|
|
||||||
if len(u.Username) > MaxUsernameBytes {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %d bytes, limit is %d",
|
|
||||||
ErrUsernameTooLong,
|
|
||||||
len(u.Username),
|
|
||||||
MaxUsernameBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
|
|
||||||
// two-byte character. A check that counted characters rather than bytes
|
|
||||||
// would see half the length and let the one-byte-longer name through.
|
|
||||||
func usernameAtLimit() string {
|
|
||||||
return strings.Repeat("é", database.MaxUsernameBytes/2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
err := db.Create(&database.User{
|
|
||||||
Username: usernameAtLimit() + "x",
|
|
||||||
Password: "hash",
|
|
||||||
}).Error
|
|
||||||
|
|
||||||
require.ErrorIs(t, err, database.ErrUsernameTooLong)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
require.NoError(t, db.Create(&database.User{
|
|
||||||
Username: usernameAtLimit(),
|
|
||||||
Password: "hash",
|
|
||||||
}).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
|
|
||||||
// SQL, as a path that bypassed User.BeforeSave would, so only the
|
|
||||||
// table's check constraint stands between it and an over-long
|
|
||||||
// username. Accepting the name at the limit and refusing the next byte
|
|
||||||
// also pins the constraint's number to MaxUsernameBytes.
|
|
||||||
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := startedTestDB(t)
|
|
||||||
|
|
||||||
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
|
|
||||||
|
|
||||||
require.NoError(t, db.Exec(
|
|
||||||
insert, uuid.New().String(), usernameAtLimit(), "hash",
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
err := db.Exec(
|
|
||||||
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
|
|
||||||
).Error
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "CHECK constraint failed")
|
|
||||||
}
|
|
||||||
@@ -453,33 +453,3 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
|||||||
"the issued cookie must carry an authenticated session",
|
"the issued cookie must carry an authenticated session",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
|
||||||
// database.MaxUsernameBytes still fits in the session cookie. Past
|
|
||||||
// what the cookie can carry, a correct login answers 500.
|
|
||||||
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
username := strings.Repeat("a", database.MaxUsernameBytes)
|
|
||||||
|
|
||||||
hash, err := database.HashPassword(operatorPassword)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.DB().Create(&database.User{
|
|
||||||
Username: username,
|
|
||||||
Password: hash,
|
|
||||||
}).Error)
|
|
||||||
|
|
||||||
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -54,7 +53,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
|
||||||
@@ -131,7 +129,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"),
|
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||||
|
|||||||
@@ -83,25 +83,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,
|
||||||
|
|||||||
@@ -339,9 +339,11 @@ const storedUserPassword = "correct-horse-battery-staple"
|
|||||||
// storedFillBytes is the raw length of the client-chosen value in
|
// storedFillBytes is the raw length of the client-chosen value in
|
||||||
// those accounts' usernames. It is well past the 512-byte field
|
// those accounts' usernames. It is well past the 512-byte field
|
||||||
// budget, so the line is still truncated, but short enough that the
|
// budget, so the line is still truncated, but short enough that the
|
||||||
// whole username, markers and fill name included, stays within
|
// session cookie a successful login writes stays inside
|
||||||
// database.MaxUsernameBytes.
|
// securecookie's 4 KB limit: the cookie is written BEFORE the
|
||||||
const storedFillBytes = 960
|
// "user logged in" line, so an 8 KB username answers 500 and never
|
||||||
|
// reaches it.
|
||||||
|
const storedFillBytes = 1024
|
||||||
|
|
||||||
// storedFill builds a username fill of storedFillBytes raw bytes out
|
// storedFill builds a username fill of storedFillBytes raw bytes out
|
||||||
// of repetitions of ch, with both markers at its far end.
|
// of repetitions of ch, with both markers at its far end.
|
||||||
|
|||||||
@@ -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,130 +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()
|
|
||||||
|
|
||||||
const (
|
|
||||||
metricsPassword = "metrics-password-1f9a"
|
|
||||||
sentryKey = "dsnkey7c2e"
|
|
||||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Debug: true,
|
|
||||||
MaintenanceMode: true,
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
MetricsUsername: "scraper",
|
|
||||||
MetricsPassword: metricsPassword,
|
|
||||||
Port: 9123,
|
|
||||||
SentryDSN: 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": "true",
|
|
||||||
"METRICS_USERNAME": "scraper",
|
|
||||||
"METRICS_PASSWORD": "set",
|
|
||||||
"SENTRY_DSN": "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.NotContains(t, body, metricsPassword)
|
|
||||||
assert.NotContains(t, body, sentryKey)
|
|
||||||
assert.Contains(
|
|
||||||
t, body, `href="/settings"`,
|
|
||||||
"the navigation bar links to the page",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
shown, _ := settingsShown(t, &config.Config{DataDir: t.TempDir()})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
|
||||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
|
||||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
|
||||||
}
|
|
||||||
@@ -141,7 +141,6 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
s.setupPageRoutes()
|
s.setupPageRoutes()
|
||||||
s.setupUserRoutes()
|
s.setupUserRoutes()
|
||||||
s.setupSettingsRoutes()
|
|
||||||
s.setupSourceRoutes()
|
s.setupSourceRoutes()
|
||||||
s.setupWebhookRoutes()
|
s.setupWebhookRoutes()
|
||||||
}
|
}
|
||||||
@@ -182,21 +181,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) {
|
|
||||||
// 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())
|
|
||||||
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("/sources", func(r chi.Router) {
|
s.router.Route("/sources", func(r chi.Router) {
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
|
|||||||
@@ -1,22 +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", 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")
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
|
|
||||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||||
// template loads on each page and fetches it through the real router.
|
// template loads on each page and fetches it through the real router.
|
||||||
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
|
// Alpine.js is fetched at build time rather than committed, so nothing
|
||||||
// file is not in the tree: this is the check that the page still gets
|
// in the repo guarantees it is present: this is the check that the page
|
||||||
// the JavaScript it asks for.
|
// still gets the JavaScript it asks for.
|
||||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# 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
|
|
||||||
# extracted file is not committed. script/test, make build and make dev run
|
|
||||||
# this first.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
|
||||||
>static/js/alpine.min.js
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
+8
-1
@@ -4,7 +4,9 @@
|
|||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||||
# only in docker, via script/lint and Dockerfile.lint.
|
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||||
|
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||||
|
# assets the repo does not commit.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -67,6 +69,11 @@ main() {
|
|||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
# Third-party browser assets are not committed; fetch and verify them
|
||||||
|
# so a fresh clone can build and test.
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
"$ROOT/script/fetch-assets"
|
||||||
|
|
||||||
echo "bootstrap complete"
|
echo "bootstrap complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
# extension to scripts-to-rule-them-all.
|
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
|
||||||
# Generic: usually needs no adaptation.
|
# Generic: usually needs no adaptation.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
|||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fetch-assets: download the third-party browser assets the web UI
|
||||||
|
# ships and install them under static/. Minified bundles are not committed
|
||||||
|
# (REPO_POLICIES.md: no build artifacts in version control), so the build
|
||||||
|
# fetches them here. Every download is verified against a hardcoded sha256
|
||||||
|
# before it is installed, and any mismatch aborts. Idempotent: an asset
|
||||||
|
# already present with its pinned hash is left alone.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# The sha256 of each installed asset lives in static/vendor.sha256, in
|
||||||
|
# sha256sum(1) format, with paths relative to static/. That file is the
|
||||||
|
# single source of truth: this script verifies against it, and
|
||||||
|
# static/vendor_test.go asserts the bytes embedded into the binary match
|
||||||
|
# it, so the hash cannot rot into a value nothing checks.
|
||||||
|
MANIFEST="static/vendor.sha256"
|
||||||
|
|
||||||
|
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
|
||||||
|
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
|
||||||
|
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
|
||||||
|
# a <script> tag.
|
||||||
|
ALPINE_VERSION="3.14.9"
|
||||||
|
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
# sha256 of alpinejs-3.14.9.tgz
|
||||||
|
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
|
||||||
|
ALPINE_MEMBER="package/dist/cdn.min.js"
|
||||||
|
ALPINE_DEST="js/alpine.min.js"
|
||||||
|
|
||||||
|
sha256_of() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$1" | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
shasum -a 256 "$1" | cut -d' ' -f1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expected_sha256 <path-relative-to-static>
|
||||||
|
expected_sha256() {
|
||||||
|
awk -v want="$1" '$2 == want { print $1; found = 1 }
|
||||||
|
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
# verify <file> <expected-sha256> <what>
|
||||||
|
verify() {
|
||||||
|
actual="$(sha256_of "$1")"
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "fetch-assets: sha256 mismatch for $3" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# up_to_date <path-relative-to-static> <expected-sha256>
|
||||||
|
up_to_date() {
|
||||||
|
[ -f "$ROOT/static/$1" ] || return 1
|
||||||
|
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_alpine() {
|
||||||
|
want="$(expected_sha256 "$ALPINE_DEST")"
|
||||||
|
|
||||||
|
if up_to_date "$ALPINE_DEST" "$want"; then
|
||||||
|
echo "fetch-assets: static/$ALPINE_DEST already at $want"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||||
|
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
|
||||||
|
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
|
||||||
|
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
|
||||||
|
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-check every manifest entry against what is now on disk, so an entry
|
||||||
|
# no script installs fails loudly instead of passing silently.
|
||||||
|
verify_manifest() {
|
||||||
|
while read -r want path; do
|
||||||
|
case "$want" in '' | '#'*) continue ;; esac
|
||||||
|
if [ ! -f "$ROOT/static/$path" ]; then
|
||||||
|
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
verify "$ROOT/static/$path" "$want" "static/$path"
|
||||||
|
done <"$ROOT/$MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
fetch_alpine
|
||||||
|
verify_manifest
|
||||||
|
echo "fetch-assets: all assets in $MANIFEST verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -28,7 +28,6 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
|
||||||
go test -v -race -timeout 90s ./...
|
go test -v -race -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package static_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
const manifestPath = "vendor.sha256"
|
||||||
|
|
||||||
|
// fetchHint is appended to every failure here: the assets the manifest
|
||||||
|
// covers are fetched by the build, not committed, so a fresh clone that
|
||||||
|
// has not run script/fetch-assets fails this test and should be told why.
|
||||||
|
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
|
||||||
|
"the pinned third-party assets"
|
||||||
|
|
||||||
|
// TestVendoredAssetsMatchManifest asserts that every asset listed in
|
||||||
|
// static/vendor.sha256 is embedded in the binary with exactly the pinned
|
||||||
|
// bytes. script/fetch-assets verifies the same hashes at download time;
|
||||||
|
// this test verifies them again on what actually ships, so a build that
|
||||||
|
// skipped, cached, or subverted the fetch cannot produce a binary serving
|
||||||
|
// unpinned third-party JavaScript.
|
||||||
|
func TestVendoredAssetsMatchManifest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
entries := readManifest(t)
|
||||||
|
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
|
||||||
|
|
||||||
|
for path, want := range entries {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data, err := static.Static.ReadFile(path)
|
||||||
|
require.NoErrorf(
|
||||||
|
t, err,
|
||||||
|
"%s is listed in %s but is not embedded; %s",
|
||||||
|
path, manifestPath, fetchHint,
|
||||||
|
)
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
got := hex.EncodeToString(sum[:])
|
||||||
|
require.Equalf(
|
||||||
|
t, want, got,
|
||||||
|
"embedded %s does not match its pinned sha256 in %s; %s",
|
||||||
|
path, manifestPath, fetchHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
|
||||||
|
// format with paths relative to static/.
|
||||||
|
func readManifest(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := os.Open(manifestPath)
|
||||||
|
require.NoError(t, err, "opening %s", manifestPath)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, f.Close()) }()
|
||||||
|
|
||||||
|
entries := make(map[string]string)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
require.Lenf(
|
||||||
|
t, fields, 2,
|
||||||
|
"%s: malformed entry %q, want \"<sha256> <path>\"",
|
||||||
|
manifestPath, line,
|
||||||
|
)
|
||||||
|
|
||||||
|
sum, path := fields[0], fields[1]
|
||||||
|
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
|
||||||
|
entries[path] = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -17,7 +17,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="/sources" class="btn-text">Webhooks</a>
|
<a href="/sources" 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"/>
|
||||||
@@ -40,7 +39,6 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/sources" 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>
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
Reference in New Issue
Block a user