Compare commits
8
Commits
prod
..
718865e075
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718865e075 | ||
|
|
9d29baaa2d | ||
|
|
507980a347 | ||
|
|
b79e4649a1 | ||
|
|
1428154bbd | ||
|
|
8ad2a86e4b | ||
|
|
a891b726e5 | ||
|
|
ab63b5f777 |
+2
-4
@@ -3,10 +3,8 @@
|
|||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
.git/
|
.git/
|
||||||
bin/
|
bin/
|
||||||
# Third-party browser assets are fetched and hash-verified inside the build by
|
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||||
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
# needed. The tarball in 3p/ must stay in the context.
|
||||||
# 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
-4
@@ -46,7 +46,6 @@ 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
|
||||||
|
|
||||||
# Third-party browser assets, fetched and hash-verified by
|
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||||
# script/fetch-assets against static/vendor.sha256. Not committed:
|
# what is committed.
|
||||||
# REPO_POLICIES.md forbids minified bundles in version control.
|
/static/js/alpine.min.js
|
||||||
/static/js/alpine.min.js
|
|
||||||
|
|||||||
Binary file not shown.
+4
-11
@@ -51,15 +51,8 @@ RUN go mod download
|
|||||||
# the lint stage above.
|
# the lint stage above.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Fetch the third-party browser assets the UI serves. They are not committed
|
# Run tests and build. Both first run script/assets, which extracts Alpine.js
|
||||||
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
# from its tarball in 3p/.
|
||||||
# .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
|
||||||
@@ -67,8 +60,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 and asset steps, so a changed version
|
# Declared here, below the test step, so a changed version does not
|
||||||
# does not invalidate their cached layers.
|
# invalidate its cached layer.
|
||||||
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/fetch-assets
|
@script/assets
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
@@ -45,13 +45,13 @@ fmt-check:
|
|||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build:
|
build: assets
|
||||||
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:
|
dev: assets
|
||||||
go run ./cmd/webhooker
|
go run ./cmd/webhooker
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ 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
|
||||||
@@ -36,9 +33,7 @@ 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 Go dependencies and the third-party browser assets.
|
# Install the Go toolchain if missing, and the Go dependencies
|
||||||
# `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)
|
||||||
@@ -58,7 +53,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 # Fetch + verify third-party browser assets
|
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
||||||
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)
|
||||||
@@ -1221,14 +1216,15 @@ 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
|
`version` are inline commands with no script behind them, though `build`,
|
||||||
`build` and `version` both take their value from `script/version`.
|
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||||
|
take their value from `script/version`.
|
||||||
|
|
||||||
`make check` needs the third-party browser assets in `static/`, which
|
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||||
are not committed, so run `make bootstrap` (or just `make assets`) once
|
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||||
after cloning. Without them the tests fail with a message naming that
|
[Third-party browser assets](#third-party-browser-assets)), so
|
||||||
remedy. `make check` does not fetch them itself because it must not
|
`make test`, `make check` and the pre-commit hook work on a fresh clone
|
||||||
change any files in the repo.
|
without a separate step.
|
||||||
|
|
||||||
We provide:
|
We provide:
|
||||||
|
|
||||||
@@ -1236,8 +1232,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/fetch-assets` — download the third-party browser assets into
|
- `script/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||||
`static/`, verifying each against its pinned sha256
|
[Third-party browser assets](#third-party-browser-assets))
|
||||||
- `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)
|
||||||
@@ -1259,24 +1255,25 @@ We provide:
|
|||||||
|
|
||||||
## Third-party browser assets
|
## Third-party browser assets
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js. It is **not** committed:
|
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||||
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||||
both committed build artifacts and unpinned external references.
|
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.
|
||||||
|
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||||
|
`vendor/` directory as its module vendor directory.
|
||||||
|
|
||||||
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
`script/assets` (`make assets`) extracts the browser build,
|
||||||
download against a hardcoded sha256, and installs it under `static/`. The
|
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
|
||||||
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||||
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||||
against that manifest — so the pin is enforced on what actually ships, not
|
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||||
merely written down. Any mismatch fails the build.
|
`.dockerignore` keeps any host copy out of the build context.
|
||||||
|
|
||||||
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
To move to a new version: download
|
||||||
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||||
artifact out of both the repo and the build context.
|
against the `dist.integrity` hash listed at
|
||||||
|
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||||
To move to a new version: update the version, URL, and tarball sha256 in
|
with it, update its file name in `script/assets`, and run `make check`.
|
||||||
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
|
||||||
run `make assets && make check`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
@@ -1439,7 +1436,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 |
|
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie |
|
||||||
| `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.
|
||||||
@@ -2379,14 +2376,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 — 1 KB at `invalid password`, whose accounts are
|
at each of these — just under 1 KB at `invalid password`, whose
|
||||||
shared with the successful-login line, where a username past 4 KB
|
accounts are shared with the successful-login line and so must stay
|
||||||
overflows the session cookie and answers 500 before that line is
|
within the 1024-byte username limit — through both handlers, and
|
||||||
written — through both handlers, and through seven fills: plain text
|
through seven fills: plain text as the baseline, and then the
|
||||||
as the baseline, and then the quotation mark, backslash, tab, newline,
|
quotation mark, backslash, tab, newline, C0 control and astral
|
||||||
C0 control and astral non-printable, six characters the wider of the
|
non-printable, six characters the wider of the two handlers spends
|
||||||
two handlers spends more on than the client spent sending them. Every
|
more on than the client spent sending them. Every case holds each
|
||||||
case holds each line to the 2,560-byte ceiling. That per-line ceiling
|
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
|
||||||
@@ -2755,6 +2752,8 @@ 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/
|
||||||
@@ -2848,8 +2847,7 @@ 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, fetched by script/fetch-assets, not committed
|
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make 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
|
||||||
@@ -3161,14 +3159,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 `script/fetch-assets`
|
stage passing (it copies a file from it), runs `make test` and
|
||||||
to download and verify the third-party browser assets, then runs
|
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||||
`make test` and `make build`, and finally rebuilds the binary with
|
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||||
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
runs on musl. Both builds go through `make build`, the relink adding
|
||||||
go through `make build`, the relink adding its `-extldflags` via
|
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||||
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
stamps the version. The version arrives as the `VERSION` build arg,
|
||||||
The version arrives as the `VERSION` build arg, since the context
|
since the context has no `.git` (see
|
||||||
has no `.git` (see [Version stamping](#version-stamping)).
|
[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,13 +1,58 @@
|
|||||||
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" json:"username"`
|
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,11 +165,7 @@ func (h *Handlers) authenticateUser(
|
|||||||
|
|
||||||
valid, err := database.VerifyPassword(password, user.Password)
|
valid, err := database.VerifyPassword(password, user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to verify password", "error", err)
|
h.serverError(w, r, "failed to verify password", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -241,24 +237,14 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
) error {
|
) error {
|
||||||
oldSess, err := h.session.Get(r)
|
oldSess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to get session", "error", err)
|
h.serverError(w, r, "failed to get session", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
h.serverError(w, r, "failed to regenerate session", err)
|
||||||
"failed to regenerate session", "error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -267,11 +253,7 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
|
|
||||||
err = h.session.Save(r, w, sess)
|
err = h.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to save session", "error", err)
|
h.serverError(w, r, "failed to save session", err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,3 +453,33 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,9 +105,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,14 +122,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,7 +171,7 @@ func (h *Handlers) loadReplaySource(
|
|||||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -195,7 +193,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to count in-flight deliveries", err,
|
w, r, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -212,7 +210,7 @@ func (h *Handlers) queueReplay(
|
|||||||
err = webhookDB.
|
err = webhookDB.
|
||||||
First(&event, "id = ?", original.EventID).Error
|
First(&event, "id = ?", original.EventID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to load event for replay", err)
|
h.serverError(w, r, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,7 +220,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to create replay delivery", err,
|
w, r, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
||||||
|
// which cannot render answers with the status it was reporting, as
|
||||||
|
// plain text, and is not attempted again: a page whose own render
|
||||||
|
// fails reaches the error page, and the error page failing as well
|
||||||
|
// ends there with the 500.
|
||||||
|
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &h)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// .Status is an int, so asking it for a field fails the render.
|
||||||
|
failing := `{{.Status.Missing}}`
|
||||||
|
h.AddTemplateForTest("error.html", template.Must(
|
||||||
|
template.New("error").Parse(failing),
|
||||||
|
))
|
||||||
|
h.AddTemplateForTest("failing.html", template.Must(
|
||||||
|
template.New("failing").Parse(`{{.Data.Missing}}`),
|
||||||
|
))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, "Not Found\n", w.Body.String())
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to read event body", err)
|
h.serverError(w, r, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// alphabet rather than from the request.
|
// alphabet rather than from the request.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get webhook database", err)
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
webhookDB, webhook.ID, eventID.String(),
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to load event to resubmit", err)
|
h.serverError(w, r, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// A miss is a 404 whether the event was reaped, belongs to
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
// another webhook, or never existed.
|
// another webhook, or never existed.
|
||||||
if !found {
|
if !found {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
// inactive one is skipped rather than refused.
|
// inactive one is skipped rather than refused.
|
||||||
targets, err := h.loadActiveTargets(webhook.ID)
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to query targets", err)
|
h.serverError(w, r, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to store resubmitted event", err)
|
h.serverError(w, r, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
@@ -63,12 +65,20 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
page int,
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, webhook, nil, page,
|
w, newRequestForTest(), webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newRequestForTest is the request the helpers here pass on for
|
||||||
|
// callers that have none: it is used only to render the error page.
|
||||||
|
func newRequestForTest() *http.Request {
|
||||||
|
return httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
@@ -122,5 +132,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, expiry)
|
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ func New(
|
|||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
"error.html": parsePageTemplate("error.html"),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -146,6 +147,15 @@ func New(
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleErrorPage returns a handler that answers every request with
|
||||||
|
// the error page for status. The router uses it for unknown paths and
|
||||||
|
// the CSRF middleware for a refused form.
|
||||||
|
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderError(w, r, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
@@ -163,15 +173,72 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverError logs an error and sends a 500 response.
|
// serverError logs an error and answers with the 500 error page.
|
||||||
func (s *Handlers) serverError(
|
func (s *Handlers) serverError(
|
||||||
w http.ResponseWriter, msg string, err error,
|
w http.ResponseWriter, r *http.Request, msg string, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error(msg, "error", err)
|
s.log.Error(msg, "error", err)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
}
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
// renderError answers with status and the error page: the normal
|
||||||
|
// layout, one fixed line explaining the status, and a link back to the
|
||||||
|
// webhook list, or to sign-in when nobody is signed in.
|
||||||
|
//
|
||||||
|
// It renders the page itself rather than through renderTemplate,
|
||||||
|
// whose own failure comes here. If the error page cannot render
|
||||||
|
// either, the answer is the same status in plain text: never a second
|
||||||
|
// attempt, and never a different status.
|
||||||
|
func (s *Handlers) renderError(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
status int,
|
||||||
|
) {
|
||||||
|
data := s.pageData(r, map[string]any{
|
||||||
|
"Status": status,
|
||||||
|
"StatusText": http.StatusText(status),
|
||||||
|
"Message": errorPageText(status),
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := s.templates["error.html"].Execute(&buf, data)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to render error page", "error", err)
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = buf.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write error page", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorPageText is the line the error page shows for status. It is
|
||||||
|
// fixed per status, so the page tells the reader no more than the
|
||||||
|
// plain-text answers it replaced did.
|
||||||
|
func errorPageText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
return "The request could not be read."
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return "The request was refused. If it came from a form " +
|
||||||
|
"left open for a long time, reload the page and try " +
|
||||||
|
"again."
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return "There is nothing here. It may have been deleted, " +
|
||||||
|
"or the address may be wrong."
|
||||||
|
case http.StatusServiceUnavailable:
|
||||||
|
return "The server is busy. Please try again in a moment."
|
||||||
|
default: // http.StatusInternalServerError
|
||||||
|
return "Something went wrong on the server. Please try " +
|
||||||
|
"again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information for templates
|
// UserInfo represents user information for templates
|
||||||
@@ -224,14 +291,17 @@ func (s *Handlers) renderTemplate(
|
|||||||
"template not found",
|
"template not found",
|
||||||
"template", pageTemplate,
|
"template", pageTemplate,
|
||||||
)
|
)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageData adds the fields the shared layout renders to a page's own
|
||||||
|
// data.
|
||||||
|
func (s *Handlers) pageData(r *http.Request, data any) any {
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -245,19 +315,16 @@ func (s *Handlers) renderTemplate(
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
s.executeTemplate(w, tmpl, m)
|
|
||||||
|
|
||||||
return
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper := templateDataWrapper{
|
return templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, tmpl, wrapper)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes to
|
// executeTemplate renders the template into a buffer and writes to
|
||||||
@@ -270,6 +337,7 @@ func (s *Handlers) renderTemplate(
|
|||||||
// this reason.
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
) {
|
) {
|
||||||
@@ -280,10 +348,7 @@ func (s *Handlers) executeTemplate(
|
|||||||
s.log.Error(
|
s.log.Error(
|
||||||
"failed to execute template", "error", err,
|
"failed to execute template", "error", err,
|
||||||
)
|
)
|
||||||
http.Error(
|
s.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,10 +307,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
t, http.StatusInternalServerError, w.Code,
|
t, http.StatusInternalServerError, w.Code,
|
||||||
"a failed render must report a 500",
|
"a failed render must report a 500",
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.NotContains(
|
||||||
t, "Internal server error\n", w.Body.String(),
|
t, w.Body.String(), partialPageMarker,
|
||||||
"the response must carry no part of the aborted page",
|
"the response must carry no part of the aborted page",
|
||||||
)
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(), "500 Internal Server Error",
|
||||||
|
"a failed render must answer with the error page",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
|
|||||||
@@ -339,11 +339,9 @@ 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
|
||||||
// session cookie a successful login writes stays inside
|
// whole username, markers and fill name included, stays within
|
||||||
// securecookie's 4 KB limit: the cookie is written BEFORE the
|
// database.MaxUsernameBytes.
|
||||||
// "user logged in" line, so an 8 KB username answers 500 and never
|
const storedFillBytes = 960
|
||||||
// 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,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
r.Context(),
|
|
||||||
w,
|
w,
|
||||||
|
r,
|
||||||
sessionUsername,
|
sessionUsername,
|
||||||
// PostFormValue, not FormValue: the credential must
|
// PostFormValue, not FormValue: the credential must
|
||||||
// come from the body, never from the query string.
|
// come from the body, never from the query string.
|
||||||
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
// applyPasswordChange verifies the current password and, on success,
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
// persists a fresh hash for the user, reusing the same helpers that
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
// bootstrap the admin user. It returns the success and error messages
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
// to display on the profile page. On an internal failure it writes a
|
// to display on the profile page. On an internal failure it writes the
|
||||||
// 500 response itself and returns handled=false, signalling the caller
|
// error page itself and returns handled=false, signalling the caller
|
||||||
// to stop without re-rendering the page.
|
// to stop without re-rendering the page.
|
||||||
func (h *Handlers) applyPasswordChange(
|
func (h *Handlers) applyPasswordChange(
|
||||||
ctx context.Context,
|
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
username, currentPassword, newPassword, confirmPassword string,
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
// This endpoint verifies one password and hashes another, at
|
// This endpoint verifies one password and hashes another, at
|
||||||
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||||
// this path outside it would leave a hole in it. The slot is held
|
// this path outside it would leave a hole in it. The slot is held
|
||||||
// across both hashes.
|
// across both hashes.
|
||||||
release, ok := h.mw.BeginPasswordVerification(ctx)
|
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Warn("password verification capacity exhausted")
|
h.log.Warn("password verification capacity exhausted")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusServiceUnavailable)
|
||||||
w,
|
|
||||||
"The server is busy verifying credentials. "+
|
|
||||||
"Please try again.",
|
|
||||||
http.StatusServiceUnavailable,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
).First(&user).Error
|
).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to load user for password change", err,
|
w, r, "failed to load user for password change", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
currentPassword, user.Password,
|
currentPassword, user.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to verify password", err)
|
h.serverError(w, r, "failed to verify password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
|
|
||||||
hashedPassword, err := database.HashPassword(newPassword)
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to hash new password", err)
|
h.serverError(w, r, "failed to hash new password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
"password", hashedPassword,
|
"password", hashedPassword,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update password", err)
|
h.serverError(w, r, "failed to update password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
// unexpected retrieval error.
|
// unexpected retrieval error.
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to get session", err)
|
h.serverError(w, r, "failed to get session", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusInternalServerError)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow users to act on their own profile.
|
// Only allow users to act on their own profile.
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
h.renderError(w, r, http.StatusForbidden)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
var sess *session.Session
|
var sess *session.Session
|
||||||
|
|
||||||
app := newTestApp(t, &log, &cfg, &sess)
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
app := newTestApp(t, &log, &cfg, &sess, &h)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -137,7 +139,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Route("/user/{username}", func(r chi.Router) {
|
router.Route("/user/{username}", func(r chi.Router) {
|
||||||
r.Use(mw.CSRF())
|
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(mw.RequireAuth())
|
r.Use(mw.RequireAuth())
|
||||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
handlerReached = true
|
handlerReached = true
|
||||||
|
|||||||
@@ -149,13 +149,7 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
"user_id = ?", userID,
|
"user_id = ?", userID,
|
||||||
).Order("created_at DESC").Find(&webhooks).Error
|
).Order("created_at DESC").Find(&webhooks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(
|
h.serverError(w, r, "failed to list webhooks", err)
|
||||||
"failed to list webhooks", "error", err,
|
|
||||||
)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -311,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
err := h.commitWebhook(webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to create webhook", err)
|
h.serverError(w, r, "failed to create webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -388,7 +380,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -475,7 +467,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -510,7 +502,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -519,9 +511,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -575,7 +565,7 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
|
|
||||||
err := h.db.DB().Save(webhook).Error
|
err := h.db.DB().Save(webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update webhook", err)
|
h.serverError(w, r, "failed to update webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -605,7 +595,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -632,7 +622,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// be removed by hand; deleted history cannot be recovered.
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
err := h.commitWebhookDeletion(&webhook)
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to delete webhook", err)
|
h.serverError(w, r, "failed to delete webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -658,7 +648,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// redirecting as though everything succeeded: the file
|
// redirecting as though everything succeeded: the file
|
||||||
// needs removing by hand, and the logged error names it.
|
// needs removing by hand, and the logged error names it.
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to delete webhook event database", err,
|
w, r, "failed to delete webhook event database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -802,7 +792,7 @@ func (h *Handlers) ownedWebhook(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return database.Webhook{}, false
|
return database.Webhook{}, false
|
||||||
}
|
}
|
||||||
@@ -824,7 +814,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
// Without the map every delivery renders through a
|
// Without the map every delivery renders through a
|
||||||
// zero redactor, so failing the page is the only
|
// zero redactor, so failing the page is the only
|
||||||
// safe answer.
|
// safe answer.
|
||||||
h.serverError(w, "failed to load targets", err)
|
h.serverError(w, r, "failed to load targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -832,7 +822,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
page := h.parsePage(r)
|
page := h.parsePage(r)
|
||||||
|
|
||||||
evts, total, ok := h.loadEventsWithDeliveries(
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
w, webhook, targets, page,
|
w, r, webhook, targets, page,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -942,6 +932,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
// caller must then render nothing further.
|
// caller must then render nothing further.
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
@@ -955,7 +946,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to get webhook database", err,
|
w, r, "failed to get webhook database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -992,7 +983,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to load delivery attempts", err,
|
w, r, "failed to load delivery attempts", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1001,7 +992,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, "failed to count event resubmissions", err,
|
w, r, "failed to count event resubmissions", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1224,7 +1215,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1233,9 +1224,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1251,7 +1240,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
|
|
||||||
err = h.db.DB().Create(entrypoint).Error
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to create entrypoint", err)
|
h.serverError(w, r, "failed to create entrypoint", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1282,7 +1271,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1291,9 +1280,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1364,7 +1351,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to create target", err)
|
h.serverError(w, r, "failed to create target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1458,7 +1445,7 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
@@ -1508,7 +1495,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
|
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -1530,7 +1517,7 @@ func (h *Handlers) buildSlackTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalTargetConfig(w, delivery.SlackTargetConfig{
|
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1584,16 +1571,14 @@ func (h *Handlers) validateTargetURL(
|
|||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage,
|
||||||
// writing a 500 itself if it cannot.
|
// writing a 500 itself if it cannot.
|
||||||
func marshalTargetConfig(
|
func (h *Handlers) marshalTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
cfg any,
|
cfg any,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1609,6 +1594,7 @@ func marshalTargetConfig(
|
|||||||
// expiry yields an empty config (the keep-forever default).
|
// expiry yields an empty config (the keep-forever default).
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
func (h *Handlers) buildDatabaseTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
@@ -1627,8 +1613,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return marshalTargetConfig(
|
return h.marshalTargetConfig(
|
||||||
w, map[string]any{"expiry": expiry},
|
w, r, map[string]any{"expiry": expiry},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,7 +1668,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1692,11 +1678,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
childID, webhook.ID,
|
childID, webhook.ID,
|
||||||
).Delete(model)
|
).Delete(model)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
h.log.Error(errMsg, "error", result.Error)
|
h.serverError(w, r, errMsg, result.Error)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1786,18 +1768,14 @@ func (h *Handlers) toggleChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = toggleFn(webhook.ID, childID)
|
err = toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error(errMsg, "error", err)
|
h.serverError(w, r, errMsg, err)
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
w, "Bad request", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -157,7 +155,7 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
|
|
||||||
err = h.db.DB().Save(target).Error
|
err = h.db.DB().Save(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to update target", err)
|
h.serverError(w, r, "failed to update target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,7 +218,7 @@ func (h *Handlers) ownedTarget(
|
|||||||
chi.URLParam(r, "targetID"), webhook.ID,
|
chi.URLParam(r, "targetID"), webhook.ID,
|
||||||
).First(&target).Error
|
).First(&target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
return database.Webhook{}, nil, false
|
return database.Webhook{}, nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
|
|
||||||
headersJSON, err := json.Marshal(r.Header)
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to serialize headers", err)
|
h.receiverError(w, "failed to serialize headers", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to query targets", err)
|
h.receiverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, "failed to store webhook event", err)
|
h.receiverError(w, "failed to store webhook event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// receiverError logs an error and answers the sender with a plain-text
|
||||||
|
// 500. The receiver's answers are for programs, so it never sends the
|
||||||
|
// error page the web UI uses.
|
||||||
|
func (h *Handlers) receiverError(
|
||||||
|
w http.ResponseWriter, msg string, err error,
|
||||||
|
) {
|
||||||
|
h.log.Error(msg, "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// eventSource carries the fields a new event is built from. The
|
// eventSource carries the fields a new event is built from. The
|
||||||
// receiver fills it from the live request; the resubmit handler fills
|
// receiver fills it from the live request; the resubmit handler fills
|
||||||
// it from a stored event. Both then go through createAndFanOut, so an
|
// it from a stored event. Both then go through createAndFanOut, so an
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
// token receive a 403 Forbidden response.
|
// token are logged and answered by forbidden, which must write the 403.
|
||||||
//
|
//
|
||||||
// The middleware detects the client-facing transport protocol
|
// The middleware detects the client-facing transport protocol
|
||||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||||
@@ -36,7 +36,9 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||||
// csrf.Secure option is set at creation time, not per-request.
|
// csrf.Secure option is set at creation time, not per-request.
|
||||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
func (m *Middleware) CSRF(
|
||||||
|
forbidden http.Handler,
|
||||||
|
) func(http.Handler) http.Handler {
|
||||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// CSRF is registered ahead of RequireAuth on every route
|
// CSRF is registered ahead of RequireAuth on every route
|
||||||
// group that uses it, so this WARN is reachable by an
|
// group that uses it, so this WARN is reachable by an
|
||||||
@@ -57,7 +59,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
|||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"reason", csrf.FailureReason(r),
|
"reason", csrf.FailureReason(r),
|
||||||
)
|
)
|
||||||
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
forbidden.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
key := m.session.GetKey()
|
key := m.session.GetKey()
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import (
|
|||||||
// csrfCookieName is the gorilla/csrf cookie name.
|
// csrfCookieName is the gorilla/csrf cookie name.
|
||||||
const csrfCookieName = "_gorilla_csrf"
|
const csrfCookieName = "_gorilla_csrf"
|
||||||
|
|
||||||
|
// forbidden stands in for the error page the server hands CSRF to
|
||||||
|
// answer a refused request with.
|
||||||
|
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
// csrfGetToken performs a GET request through the CSRF middleware
|
// csrfGetToken performs a GET request through the CSRF middleware
|
||||||
// and returns the token and cookies.
|
// and returns the token and cookies.
|
||||||
func csrfGetToken(
|
func csrfGetToken(
|
||||||
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
|||||||
|
|
||||||
var gotToken string
|
var gotToken string
|
||||||
|
|
||||||
handler := m.CSRF()(http.HandlerFunc(
|
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, r *http.Request) {
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
gotToken = middleware.CSRFToken(r)
|
gotToken = middleware.CSRFToken(r)
|
||||||
},
|
},
|
||||||
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
|||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
|
|
||||||
handler := m.CSRF()(http.HandlerFunc(
|
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
called = true
|
called = true
|
||||||
},
|
},
|
||||||
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
newReq := func(method string) *http.Request {
|
newReq := func(method string) *http.Request {
|
||||||
r := httptest.NewRequestWithContext(
|
r := httptest.NewRequestWithContext(
|
||||||
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF()
|
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
|
|||||||
) http.Handler {
|
) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return m.CSRF()(unreachable(t))
|
return m.CSRF(http.HandlerFunc(forbidden))(
|
||||||
|
unreachable(t),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The link back the error page offers: to the webhook list for a
|
||||||
|
// signed-in user, to sign-in for anyone else.
|
||||||
|
const (
|
||||||
|
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
|
||||||
|
`Back to webhooks</a>`
|
||||||
|
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
||||||
|
`Sign in</a>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// assertErrorPage checks that w is the error page for status, in the
|
||||||
|
// normal layout, offering link.
|
||||||
|
func assertErrorPage(
|
||||||
|
t *testing.T,
|
||||||
|
w *httptest.ResponseRecorder,
|
||||||
|
status int,
|
||||||
|
link string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
assert.Equal(t, status, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, `<nav class="app-bar"`)
|
||||||
|
assert.Contains(
|
||||||
|
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
require.NoError(t, env.db.DB().Delete(wh).Error)
|
||||||
|
|
||||||
|
w := env.get("/source/"+wh.ID, cookies)
|
||||||
|
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_DeletedTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
tgt := env.seedTarget(t, wh.ID)
|
||||||
|
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
||||||
|
|
||||||
|
w := env.get(
|
||||||
|
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_UnknownPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
|
||||||
|
assertErrorPage(
|
||||||
|
t, env.get("/no-such-page", nil),
|
||||||
|
http.StatusNotFound, backToSignIn,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outside every route group there is no form token, so the
|
||||||
|
// page leaves out the logout form rather than offer one that
|
||||||
|
// would be refused.
|
||||||
|
w := env.get("/no-such-page", cookies)
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
||||||
|
|
||||||
|
// Inside a route group the page has a token, and logout works.
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
|
||||||
|
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
||||||
|
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("username", "someone")
|
||||||
|
form.Set("password", "irrelevant")
|
||||||
|
form.Set("csrf_token", "not-a-token")
|
||||||
|
|
||||||
|
assertErrorPage(
|
||||||
|
t, env.post("/pages/login", form, nil),
|
||||||
|
http.StatusForbidden, backToSignIn,
|
||||||
|
)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "owner")
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
|
||||||
|
edit := url.Values{}
|
||||||
|
edit.Set("name", "renamed")
|
||||||
|
|
||||||
|
assertErrorPage(
|
||||||
|
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
|
||||||
|
http.StatusForbidden, backToWebhooks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
||||||
|
// the web UI only: a sender posting to an entrypoint that does not
|
||||||
|
// exist still gets the plain-text answer.
|
||||||
|
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// newTestEnv leaves the receiver rate limit at zero, which
|
||||||
|
// refuses every request before it reaches the receiver.
|
||||||
|
env := newTestEnvWithConfig(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
ReceiverRateLimit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, "404 page not found\n", w.Body.String())
|
||||||
|
}
|
||||||
@@ -46,6 +46,14 @@ const requestTimeout = 60 * time.Second
|
|||||||
// server's router.
|
// server's router.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
|
// An unknown path gets the error page. Registered before the
|
||||||
|
// global middleware, because chi wraps a not-found handler in the
|
||||||
|
// middleware already on its router, which would then run twice.
|
||||||
|
// The route groups below wrap it in their own middleware the same
|
||||||
|
// way; running theirs twice is harmless.
|
||||||
|
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
||||||
|
|
||||||
s.setupGlobalMiddleware()
|
s.setupGlobalMiddleware()
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
}
|
}
|
||||||
@@ -150,7 +158,7 @@ func (s *Server) setupPageRoutes() {
|
|||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
@@ -171,7 +179,7 @@ func (s *Server) setupUserRoutes() {
|
|||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
@@ -186,7 +194,7 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceList())
|
r.Get("/", s.h.HandleSourceList())
|
||||||
@@ -198,7 +206,7 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail())
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
|
|||||||
@@ -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 fetched at build time rather than committed, so nothing
|
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
|
||||||
// in the repo guarantees it is present: this is the check that the page
|
// file is not in the tree: this is the check that the page still gets
|
||||||
// still gets the JavaScript it asks for.
|
// the JavaScript it asks for.
|
||||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/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 "$@"
|
||||||
+1
-8
@@ -4,9 +4,7 @@
|
|||||||
# 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. Finishes by running
|
# only in docker, via script/lint and Dockerfile.lint.
|
||||||
# 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)"
|
||||||
@@ -69,11 +67,6 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
#!/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. Must not modify any files.
|
# extension to scripts-to-rule-them-all.
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/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,6 +28,7 @@ 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 ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-4xl mx-auto px-6 py-12">
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
||||||
|
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/sources" class="btn-secondary">Back to webhooks</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/pages/login" class="btn-primary">Sign in</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -24,10 +24,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{.User.Username}}
|
{{.User.Username}}
|
||||||
</a>
|
</a>
|
||||||
|
{{/* The page for an unknown path is served outside the routes
|
||||||
|
that issue a form token, and a logout without one is refused. */}}
|
||||||
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/pages/login" class="btn-primary">Login</a>
|
<a href="/pages/login" class="btn-primary">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -40,10 +44,12 @@
|
|||||||
{{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="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user