Compare commits
2 Commits
376ec2de92
...
1076edb2e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1076edb2e8 | ||
| 41ff16a817 |
@@ -19,9 +19,14 @@ RUN go mod download
|
|||||||
# .dockerignore.
|
# .dockerignore.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter
|
# Run formatting check and linter. golangci-lint is invoked directly rather
|
||||||
|
# than through `make lint`: this stage is already the pinned linter image, and
|
||||||
|
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here
|
||||||
|
# would need a docker daemon inside the build. Keep these steps in step with
|
||||||
|
# Dockerfile.lint, including --network=none (see its header for why).
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
|||||||
37
Dockerfile.lint
Normal file
37
Dockerfile.lint
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Lint-only image, built by script/lint. golangci-lint is never installed on
|
||||||
|
# the host: the repo is COPYed into the pinned image and linted as a build
|
||||||
|
# step, so a successful build IS a clean lint. This works even when the docker
|
||||||
|
# daemon is remote and bind mounts are impossible.
|
||||||
|
#
|
||||||
|
# script/lint passes --no-cache-filter=lint. Without it an unchanged tree
|
||||||
|
# replays the lint stage from cache and the build succeeds in under a second
|
||||||
|
# having run no linter at all. Do not drop that flag.
|
||||||
|
#
|
||||||
|
# The lint steps run with --network=none. `golangci-lint config verify` is
|
||||||
|
# documented as fetching its JSON schema over HTTPS, which would make linting
|
||||||
|
# depend on an unpinned remote artifact; this pinned image resolves the schema
|
||||||
|
# without any network, and --network=none enforces that rather than trusting
|
||||||
|
# it. It also proves no linter reaches out at analysis time. If a future image
|
||||||
|
# bump makes either step need the network, this build fails loudly instead of
|
||||||
|
# quietly acquiring an unpinned dependency.
|
||||||
|
|
||||||
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||||
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching. This stage is cacheable;
|
||||||
|
# only the lint stage below is forced to re-execute.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
FROM deps AS lint
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||||
|
# disable a setting without a word. `config verify` is what catches that.
|
||||||
|
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN --network=none golangci-lint run --config .golangci.yml ./...
|
||||||
78
README.md
78
README.md
@@ -12,14 +12,16 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.26.1+ (the version in `go.mod`)
|
- Go 1.26.1+ (the version in `go.mod`)
|
||||||
- golangci-lint v2.12.2 (the version pinned in `script/bootstrap` and
|
- Docker (for linting, for the test stage of the CI gate, and for
|
||||||
in the `Dockerfile`'s lint stage; `make bootstrap` installs it)
|
containerized deployment)
|
||||||
- Docker (for containerized deployment, and for the lint and test
|
|
||||||
stages of the CI gate)
|
|
||||||
- `curl`, used by `script/fetch-assets` to download the third-party
|
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||||
browser assets, which are not committed (`make bootstrap` installs
|
browser assets, which are not committed (`make bootstrap` installs
|
||||||
it if missing)
|
it if missing)
|
||||||
|
|
||||||
|
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
|
||||||
|
digest-pinned linter image via `Dockerfile.lint`.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -27,9 +29,9 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
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, the pinned linter, and the third-party
|
# Install Go dependencies and the third-party browser assets.
|
||||||
# browser assets. `make deps` alone is not enough: it only runs
|
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||||
# go mod download/tidy, and the checks below need the fetched assets.
|
# 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)
|
||||||
@@ -52,7 +54,7 @@ make setup # Bootstrap + install git pre-commit hook
|
|||||||
make assets # Fetch + verify third-party browser assets
|
make assets # Fetch + verify third-party browser assets
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||||
make lint # Run golangci-lint
|
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
make check # test + lint + fmt-check (CI gate)
|
make check # test + lint + fmt-check (CI gate)
|
||||||
make build # Build binary to bin/webhooker
|
make build # Build binary to bin/webhooker
|
||||||
@@ -275,7 +277,7 @@ are inline commands with no script behind them. We provide:
|
|||||||
- `script/fetch-assets` — download the third-party browser assets into
|
- `script/fetch-assets` — download the third-party browser assets into
|
||||||
`static/`, verifying each against its pinned sha256
|
`static/`, verifying each against its pinned sha256
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/lint` — run golangci-lint
|
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
@@ -1247,6 +1249,7 @@ webhooker/
|
|||||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||||
├── script/ # Scripts to Rule Them All entrypoints
|
├── script/ # Scripts to Rule Them All entrypoints
|
||||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||||
|
├── Dockerfile.lint # Lint-only image built by script/lint
|
||||||
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
├── Makefile # 10 of 16 targets shim script/; 6 are inline
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
@@ -1452,6 +1455,37 @@ Two operational consequences follow from bounding the sequence:
|
|||||||
no shutdown diagnostics at all. Keep the deployment's grace above
|
no shutdown diagnostics at all. Keep the deployment's grace above
|
||||||
the stop timeout.
|
the stop timeout.
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
golangci-lint never runs on the host. `script/lint` builds
|
||||||
|
`Dockerfile.lint`, which copies the repo into the digest-pinned
|
||||||
|
golangci-lint image and lints as a build step, so a successful build is
|
||||||
|
a clean lint. A host binary would share one cache and one lock with
|
||||||
|
every other checkout on the machine, which has produced both invented
|
||||||
|
findings attributed to other worktrees and unearned passes.
|
||||||
|
|
||||||
|
Three properties are load-bearing:
|
||||||
|
|
||||||
|
- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged
|
||||||
|
tree replays the lint layer from cache and the build exits 0 in under
|
||||||
|
a second having linted nothing. The `deps` stage stays cacheable, so
|
||||||
|
module downloads are not repeated. Invalidation is scoped to the one
|
||||||
|
stage; never prune the shared build cache.
|
||||||
|
- `script/lint` does not trust that flag. Docker silently ignores
|
||||||
|
`--no-cache-filter` for a stage name that does not match, so a stage
|
||||||
|
rename or a one-character typo would restore the cached false green
|
||||||
|
with no warning and a fast exit 0. The script therefore tees the
|
||||||
|
build output and treats a run as a pass only if golangci-lint's own
|
||||||
|
summary line (`N issues.` / `N issues:`) appears in it: no summary,
|
||||||
|
no lint, whatever the exit code says.
|
||||||
|
- Both lint steps use `RUN --network=none`. `golangci-lint config
|
||||||
|
verify` is documented as fetching its JSON schema over HTTPS, which
|
||||||
|
would be an unpinned remote dependency; the pinned image resolves the
|
||||||
|
schema without network access, and `--network=none` enforces that
|
||||||
|
instead of trusting it. Verify is worth keeping because
|
||||||
|
`golangci-lint run` silently ignores config keys it does not
|
||||||
|
recognize, so a typo would disable a setting with no warning.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
The Dockerfile uses a three-stage build. Each stage is pinned by
|
The Dockerfile uses a three-stage build. Each stage is pinned by
|
||||||
@@ -1460,7 +1494,8 @@ version is fixed independently of the compiler's:
|
|||||||
|
|
||||||
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
|
||||||
installs `make`, downloads dependencies, copies the source, and runs
|
installs `make`, downloads dependencies, copies the source, and runs
|
||||||
`make fmt-check` then `make lint`.
|
`make fmt-check`, then `golangci-lint config verify` and
|
||||||
|
`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 `script/fetch-assets`
|
||||||
to download and verify the third-party browser assets, then runs
|
to download and verify the third-party browser assets, then runs
|
||||||
@@ -1471,20 +1506,21 @@ version is fixed independently of the compiler's:
|
|||||||
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
||||||
and includes a health check against `/.well-known/healthcheck`.
|
and includes a health check against `/.well-known/healthcheck`.
|
||||||
|
|
||||||
|
The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
||||||
|
it is already the pinned linter image, and `make lint` builds
|
||||||
|
`Dockerfile.lint`, which would need a docker daemon inside this build.
|
||||||
|
|
||||||
Both check stages use Debian rather than Alpine because
|
Both check stages use Debian rather than Alpine because
|
||||||
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO
|
||||||
and does not compile against musl. Only the final binary is statically
|
and does not compile against musl. Only the final binary is statically
|
||||||
linked, which is what lets it run on the Alpine runtime image.
|
linked, which is what lets it run on the Alpine runtime image.
|
||||||
|
|
||||||
`script/cibuild` — `docker build .` — is the CI gate: the four check
|
`script/cibuild` — `docker build .` — is the CI gate: the checks run
|
||||||
targets run inside the image, so a build that succeeds is a repo that
|
inside the image, so a build that succeeds is a repo that is formatted,
|
||||||
is formatted, linted, tested and compiled. Only `script/cibuild` and
|
linted, tested and compiled. `script/lint` also uses Docker
|
||||||
`script/docker` involve Docker. `script/lint`, and therefore
|
(`Dockerfile.lint`, see Linting above), so `make lint` and `make check`
|
||||||
`make lint` and `make check`, run whatever `golangci-lint` is on the
|
run the same pinned linter version the gate does; only `script/test`
|
||||||
host, which can be a different version from the pinned one — so the
|
and `script/fmt-check` run on the host.
|
||||||
container is the authoritative lint result
|
|
||||||
([issue #109](https://git.eeqj.de/sneak/webhooker/issues/109) tracks
|
|
||||||
routing local linting through it as well).
|
|
||||||
|
|
||||||
#### CI gate honesty
|
#### CI gate honesty
|
||||||
|
|
||||||
@@ -1497,8 +1533,8 @@ the hash of the last commit that touched the build context, so:
|
|||||||
- Any commit that changes code (including a squash merge whose tree
|
- Any commit that changes code (including a squash merge whose tree
|
||||||
matches an already-built branch) gets a new fingerprint, invalidates
|
matches an already-built branch) gets a new fingerprint, invalidates
|
||||||
the `COPY . .` layer of both check stages, and really runs
|
the `COPY . .` layer of both check stages, and really runs
|
||||||
`make fmt-check`, `make lint`, `make test`, and `make build`. A run
|
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
|
||||||
that reports success ran them.
|
run that reports success ran them.
|
||||||
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
|
||||||
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
|
||||||
anyway — so the image replays from cache and costs seconds.
|
anyway — so the image replays from cache and costs seconds.
|
||||||
|
|||||||
199
internal/handlers/event_body.go
Normal file
199
internal/handlers/event_body.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventBodyQuery reads one event's stored body as bytes. The cast
|
||||||
|
// to blob is what makes the driver hand back the stored bytes
|
||||||
|
// rather than a string conversion, so Content-Length taken from
|
||||||
|
// the result matches what goes on the wire. The soft-delete
|
||||||
|
// predicate is spelled out because Raw bypasses GORM's default
|
||||||
|
// scope, and it is what stops a reaped event still being
|
||||||
|
// downloadable.
|
||||||
|
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||||
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
|
// HandleEventBodyDownload serves one event's stored body in
|
||||||
|
// full, which the event log page cannot: it caps each rendered
|
||||||
|
// body at maxRenderedBodyBytes.
|
||||||
|
//
|
||||||
|
// The bytes are attacker-supplied — anyone who can reach the
|
||||||
|
// public receiver chooses them — and this route hands them back
|
||||||
|
// inside the operator's own authenticated origin, so the
|
||||||
|
// response is deliberately not renderable. Content-Disposition
|
||||||
|
// makes the browser download rather than display it, and the
|
||||||
|
// octet-stream type plus nosniff stop it being interpreted as
|
||||||
|
// HTML or script. Without those a stored payload would execute
|
||||||
|
// as the logged-in operator. The application's CSP does not
|
||||||
|
// help here: script-src allows 'unsafe-inline' from 'self', so
|
||||||
|
// a document served from this origin could run its own inline
|
||||||
|
// script.
|
||||||
|
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing the id before use serves two purposes: a
|
||||||
|
// malformed id can never reach the SQL or the response
|
||||||
|
// header, and the canonical form below is drawn from
|
||||||
|
// uuid's own fixed alphabet rather than from the
|
||||||
|
// request, so the Content-Disposition value cannot be
|
||||||
|
// steered by a client.
|
||||||
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.serveEventBody(w, r, webhook, eventID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveEventBody writes the named event's stored body to w.
|
||||||
|
//
|
||||||
|
// The event must belong to webhook, which is what keeps this
|
||||||
|
// route from reading any event in the system by id alone. Two
|
||||||
|
// things enforce that and they are not equally strong. The
|
||||||
|
// operative one is that events live in a per-webhook SQLite
|
||||||
|
// file, so a sibling webhook's event is not in the database
|
||||||
|
// being queried at all. The webhook_id predicate on the query
|
||||||
|
// below is the second guard, and it is currently redundant
|
||||||
|
// against that isolation; it is there so the scoping survives
|
||||||
|
// any future change that puts more than one webhook's events in
|
||||||
|
// one file.
|
||||||
|
//
|
||||||
|
// The body is read in one query and held whole in memory while
|
||||||
|
// it is written. That costs roughly two body-sized allocations
|
||||||
|
// per concurrent download, not one: the driver's column buffer
|
||||||
|
// and the copy database/sql makes in convertAssign when a
|
||||||
|
// []byte column is scanned into a *[]byte are live at the same
|
||||||
|
// time. Measured allocation is ~2x the body plus ~45 KB, so at
|
||||||
|
// the 1 MB ingest cap a download costs ~2 MB of Go heap. On
|
||||||
|
// top of that, SQLite's own materialisation of the column
|
||||||
|
// value sits in the driver's allocator outside the Go heap, so
|
||||||
|
// process peak is higher again: 2x is a floor, not a ceiling.
|
||||||
|
// There is no cheaper bound available — database/sql exposes
|
||||||
|
// no incremental handle on a SQLite BLOB, and reading byte
|
||||||
|
// ranges with substr does not avoid the cost either, because
|
||||||
|
// SQLite materialises the whole column value to evaluate each
|
||||||
|
// substr call. Range reads only pay for that materialisation
|
||||||
|
// once per range.
|
||||||
|
//
|
||||||
|
// One consequence is worth keeping in view: the read finishes
|
||||||
|
// before the client is written to, so no read lock is held for
|
||||||
|
// the length of a slow download. These per-webhook databases
|
||||||
|
// run in SQLite's default journal mode rather than WAL, so a
|
||||||
|
// lock held that long would block the receiver from recording
|
||||||
|
// new events.
|
||||||
|
func (h *Handlers) serveEventBody(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
eventID string,
|
||||||
|
) {
|
||||||
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to read event body", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A miss is a 404 whether the event belongs to another
|
||||||
|
// webhook or does not exist at all, so the response does
|
||||||
|
// not report which. Reading the body before any header is
|
||||||
|
// written is also what keeps an event reaped mid-request
|
||||||
|
// from producing a torn response: either the read finds the
|
||||||
|
// row and the whole body is served, or it does not and the
|
||||||
|
// response is a clean 404.
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setEventBodyHeaders(w, eventID, int64(len(body)))
|
||||||
|
|
||||||
|
_, err = w.Write(body)
|
||||||
|
if err != nil {
|
||||||
|
// The status and Content-Length are already committed,
|
||||||
|
// so the client sees a short download. There is no way
|
||||||
|
// to report a 500 from here; the log is the record.
|
||||||
|
h.log.Error(
|
||||||
|
"failed to write event body",
|
||||||
|
"webhook_id", webhook.ID,
|
||||||
|
"event_id", eventID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventBody returns an event's stored body and whether the event
|
||||||
|
// exists within the webhook.
|
||||||
|
func eventBody(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID, eventID string,
|
||||||
|
) ([]byte, bool, error) {
|
||||||
|
var body []byte
|
||||||
|
|
||||||
|
err := webhookDB.Raw(
|
||||||
|
eventBodyQuery, eventID, webhookID,
|
||||||
|
).Row().Scan(&body)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setEventBodyHeaders applies the response headers that make
|
||||||
|
// this route safe to hand attacker-supplied bytes through. See
|
||||||
|
// HandleEventBodyDownload for why they are a security control
|
||||||
|
// and not a formatting choice.
|
||||||
|
//
|
||||||
|
// nosniff is also set by the global SecurityHeaders middleware.
|
||||||
|
// It is repeated here so the guarantee belongs to the route
|
||||||
|
// that needs it rather than to a middleware someone could
|
||||||
|
// reorder or scope away.
|
||||||
|
func setEventBodyHeaders(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
eventID string,
|
||||||
|
size int64,
|
||||||
|
) {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="webhooker-event-`+eventID+`.bin"`,
|
||||||
|
)
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||||
|
}
|
||||||
506
internal/handlers/event_body_test.go
Normal file
506
internal/handlers/event_body_test.go
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// paramEventID is the chi URL parameter the body download
|
||||||
|
// handler reads.
|
||||||
|
const paramEventID = "eventID"
|
||||||
|
|
||||||
|
// otherTestUserID owns webhooks the session user must not be
|
||||||
|
// able to read.
|
||||||
|
const otherTestUserID = "other-user-id"
|
||||||
|
|
||||||
|
// seedWebhookFor inserts a webhook owned by the given user.
|
||||||
|
func seedWebhookFor(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
userID string,
|
||||||
|
) *database.Webhook {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: userID,
|
||||||
|
Name: "wh-" + userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wh
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEventBody runs the real download handler as the test user
|
||||||
|
// for the given source and event ids.
|
||||||
|
func fetchEventBody(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
sourceID, eventID string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// The path is escaped and the raw id goes in the route
|
||||||
|
// context, which is what chi hands a handler: the param is
|
||||||
|
// already percent-decoded by the time it is read.
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet,
|
||||||
|
"/source/"+url.PathEscape(sourceID)+
|
||||||
|
"/logs/"+url.PathEscape(eventID)+"/body",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, c := range authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
) {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add(paramSourceID, sourceID)
|
||||||
|
rctx.URLParams.Add(paramEventID, eventID)
|
||||||
|
|
||||||
|
req = req.WithContext(
|
||||||
|
context.WithValue(
|
||||||
|
req.Context(), chi.RouteCtxKey, rctx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleEventBodyDownload().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_ServesOversizeBodyInFull is the
|
||||||
|
// capability the render cap took away: a body far above what the
|
||||||
|
// event log page will show comes back whole and byte-identical,
|
||||||
|
// with the headers that keep it from being rendered.
|
||||||
|
func TestHandleEventBodyDownload_ServesOversizeBodyInFull(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
// Far above the render cap, with multibyte runes and a
|
||||||
|
// distinctive tail, so a body that the log page can only
|
||||||
|
// show a slice of comes back whole and in order.
|
||||||
|
const sentinel = "TAIL-SENTINEL-1f4a9c"
|
||||||
|
|
||||||
|
stored := strings.Repeat("A", 200*1024) +
|
||||||
|
strings.Repeat(snowman, 1000) + sentinel
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, wh.ID, stored)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Greater(t, len(stored), bodyCap)
|
||||||
|
assert.Equal(t, stored, w.Body.String())
|
||||||
|
assert.Equal(
|
||||||
|
t, strconv.Itoa(len(stored)),
|
||||||
|
w.Header().Get("Content-Length"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_BodiesRoundTripByteIdentical
|
||||||
|
// covers the sizes and byte values a stored body can actually
|
||||||
|
// take: empty, one byte, either side of the render cap, and
|
||||||
|
// bytes that are not text at all. Content-Length has to equal
|
||||||
|
// the bytes written in every case, since it is derived from the
|
||||||
|
// same read that produces them.
|
||||||
|
func TestHandleEventBodyDownload_BodiesRoundTripByteIdentical(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A NUL, invalid UTF-8 and a multibyte rune, so nothing on
|
||||||
|
// the path can be treating the body as text.
|
||||||
|
binary := "\x00\x01\xff\xfe" + snowman + "\x00tail"
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"single byte": "x",
|
||||||
|
"one below cap": strings.Repeat("b", bodyCap-1),
|
||||||
|
"exactly cap": strings.Repeat("c", bodyCap),
|
||||||
|
"one above cap": strings.Repeat("d", bodyCap+1),
|
||||||
|
"binary": binary,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, stored := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, wh.ID, stored)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, stored, w.Body.String())
|
||||||
|
assert.Equal(
|
||||||
|
t, strconv.Itoa(len(stored)),
|
||||||
|
w.Header().Get("Content-Length"),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, len(stored), w.Body.Len(),
|
||||||
|
"Content-Length must equal bytes written",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_HeadersAreNotRenderable pins the
|
||||||
|
// response headers that stop attacker-supplied bytes executing
|
||||||
|
// in the operator's own origin. They are a security control, not
|
||||||
|
// presentation.
|
||||||
|
func TestHandleEventBodyDownload_HeadersAreNotRenderable(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, wh.ID, `{"small":true}`)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t, "application/octet-stream",
|
||||||
|
w.Header().Get("Content-Type"),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "nosniff",
|
||||||
|
w.Header().Get("X-Content-Type-Options"),
|
||||||
|
)
|
||||||
|
|
||||||
|
disposition := w.Header().Get("Content-Disposition")
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
`attachment; filename="webhooker-event-`+evt.ID+`.bin"`,
|
||||||
|
disposition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_ScriptBodyStaysInert proves a
|
||||||
|
// stored HTML payload is handed back as an attachment of opaque
|
||||||
|
// bytes rather than as anything a browser will execute. The
|
||||||
|
// bytes themselves are unaltered: this route reports what was
|
||||||
|
// delivered.
|
||||||
|
func TestHandleEventBodyDownload_ScriptBodyStaysInert(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
const payload = `<html><script>alert(document.cookie)` +
|
||||||
|
`</script></html>`
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, wh.ID, payload)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, payload, w.Body.String())
|
||||||
|
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
assert.Equal(t, "application/octet-stream", contentType)
|
||||||
|
assert.NotContains(t, contentType, "html")
|
||||||
|
assert.NotContains(t, contentType, "xml")
|
||||||
|
assert.NotContains(t, contentType, "javascript")
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Header().Get("Content-Disposition"), "attachment",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "nosniff",
|
||||||
|
w.Header().Get("X-Content-Type-Options"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_OtherUsersEvent404s is the
|
||||||
|
// authorization test the definition of done asks for: an event
|
||||||
|
// stored under a webhook the session user does not own is not
|
||||||
|
// readable, and the miss does not distinguish itself from a
|
||||||
|
// nonexistent one.
|
||||||
|
func TestHandleEventBodyDownload_OtherUsersEvent404s(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
const theirPayload = "OTHER-USERS-PAYLOAD-8b1d"
|
||||||
|
|
||||||
|
theirs := seedWebhookFor(t, db, otherTestUserID)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, theirs.ID, theirPayload)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, theirs.ID, evt.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), theirPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_EventOfAnotherWebhook404s pins
|
||||||
|
// that holding a valid event id is not enough: the event has to
|
||||||
|
// belong to the webhook in the path. Both webhooks here are the
|
||||||
|
// session user's and both have event databases, so the
|
||||||
|
// ownership check cannot be what produces the 404.
|
||||||
|
//
|
||||||
|
// What does produce it is the per-webhook database file rather
|
||||||
|
// than the webhook_id predicate on the query — removing that
|
||||||
|
// predicate leaves this test green, because the sibling's event
|
||||||
|
// is in a different file. The test is kept as the behavioural
|
||||||
|
// guard the route owes; see serveEventBody for which mechanism
|
||||||
|
// is load-bearing.
|
||||||
|
func TestHandleEventBodyDownload_EventOfAnotherWebhook404s(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
const other = "BELONGS-TO-THE-OTHER-WEBHOOK-3c7e"
|
||||||
|
|
||||||
|
mine := seedWebhook(t, db)
|
||||||
|
seedEventWithBody(t, dbMgr, mine.ID, `{"mine":true}`)
|
||||||
|
|
||||||
|
sibling := seedWebhook(t, db)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, sibling.ID, other)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, mine.ID, evt.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), other)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_UnknownEvent404s covers the plain
|
||||||
|
// miss, including an id that is not a uuid at all and so never
|
||||||
|
// reaches the query or the response header.
|
||||||
|
func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedEventWithBody(t, dbMgr, wh.ID, `{"mine":true}`)
|
||||||
|
|
||||||
|
for _, id := range []string{
|
||||||
|
uuid.New().String(),
|
||||||
|
`../../etc/passwd`,
|
||||||
|
"not-a-uuid",
|
||||||
|
`x"; rm -rf /`,
|
||||||
|
} {
|
||||||
|
w := fetchEventBody(t, h, sess, wh.ID, id)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusNotFound, w.Code,
|
||||||
|
"event id %q", id,
|
||||||
|
)
|
||||||
|
assert.Empty(
|
||||||
|
t, w.Header().Get("Content-Disposition"),
|
||||||
|
"event id %q must not reach a header", id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventBodyDownload_ReapedEvent404s pins what happens
|
||||||
|
// when the retention reaper takes an event out from under this
|
||||||
|
// route. The body is read in one query before any header is
|
||||||
|
// written, so a reaped event cannot produce a partial download:
|
||||||
|
// it is a clean 404 with no Content-Length and no
|
||||||
|
// Content-Disposition. Both removals the codebase performs are
|
||||||
|
// covered — the reaper hard-deletes, and a soft-deleted row is
|
||||||
|
// excluded by the query's own deleted_at predicate rather than
|
||||||
|
// by GORM's default scope, which Raw bypasses.
|
||||||
|
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, hard := range map[string]bool{
|
||||||
|
"soft deleted": false,
|
||||||
|
"hard deleted": true,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
const payload = "REAPED-PAYLOAD-4d2a"
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
evt := seedEventWithBody(t, dbMgr, wh.ID, payload)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
del := webhookDB
|
||||||
|
if hard {
|
||||||
|
del = del.Unscoped()
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
del.Delete(&database.Event{}, "id = ?", evt.ID).
|
||||||
|
Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), payload)
|
||||||
|
assert.Empty(t, w.Header().Get("Content-Length"))
|
||||||
|
assert.Empty(
|
||||||
|
t, w.Header().Get("Content-Disposition"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_TruncationMarkerLinksToDownload proves
|
||||||
|
// the page tells the reader where the rest of the body is, and
|
||||||
|
// only when there is a rest to fetch.
|
||||||
|
func TestHandleSourceLogs_TruncationMarkerLinksToDownload(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
big := seedWebhook(t, db)
|
||||||
|
bigEvt := seedEventWithBody(
|
||||||
|
t, dbMgr, big.ID, strings.Repeat("A", 4*bodyCap),
|
||||||
|
)
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, h, sess, big.ID)
|
||||||
|
assert.Contains(
|
||||||
|
t, page,
|
||||||
|
"/source/"+big.ID+"/logs/"+bigEvt.ID+"/body",
|
||||||
|
)
|
||||||
|
|
||||||
|
small := seedWebhook(t, db)
|
||||||
|
smallEvt := seedEventWithBody(
|
||||||
|
t, dbMgr, small.ID, `{"kept":"whole"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = renderSourceLogsPage(t, h, sess, small.ID)
|
||||||
|
assert.NotContains(
|
||||||
|
t, page,
|
||||||
|
"/source/"+small.ID+"/logs/"+smallEvt.ID+"/body",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,13 +25,14 @@ const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
|||||||
const snowman = "☃"
|
const snowman = "☃"
|
||||||
|
|
||||||
// seedEventWithBody records one event with the given body in the
|
// seedEventWithBody records one event with the given body in the
|
||||||
// webhook's own database.
|
// webhook's own database and returns it, so a caller that needs
|
||||||
|
// the generated event id can have it.
|
||||||
func seedEventWithBody(
|
func seedEventWithBody(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
dbMgr *database.WebhookDBManager,
|
dbMgr *database.WebhookDBManager,
|
||||||
webhookID string,
|
webhookID string,
|
||||||
body string,
|
body string,
|
||||||
) {
|
) *database.Event {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(webhookID)
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||||
@@ -47,6 +48,8 @@ func seedEventWithBody(
|
|||||||
require.NoError(t, webhookDB.Omit(
|
require.NoError(t, webhookDB.Omit(
|
||||||
clause.Associations,
|
clause.Associations,
|
||||||
).Create(event).Error)
|
).Create(event).Error)
|
||||||
|
|
||||||
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAndProject stores one body and returns the projection the
|
// seedAndProject stores one body and returns the projection the
|
||||||
|
|||||||
@@ -713,29 +713,55 @@ func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
|
|||||||
h.evictArchiveWriter(webhookID)
|
h.evictArchiveWriter(webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceLogs shows the request/response logs for a
|
// ownedWebhook resolves the request's sourceID parameter to a
|
||||||
// webhook.
|
// webhook the session's user owns.
|
||||||
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
//
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
// Ownership and existence are decided by one query, so a
|
||||||
|
// webhook belonging to another user is indistinguishable from
|
||||||
|
// one that does not exist: both are a 404, and neither confirms
|
||||||
|
// the id. Callers that reach further into a webhook's data —
|
||||||
|
// the event log page and the event body download — share this
|
||||||
|
// one check rather than restating it, so the download cannot
|
||||||
|
// come to authorize differently from the page that links to it.
|
||||||
|
//
|
||||||
|
// It reports false once it has written the response, which is a
|
||||||
|
// redirect to the login page for an unauthenticated request and
|
||||||
|
// a 404 otherwise. The caller returns without writing more.
|
||||||
|
func (h *Handlers) ownedWebhook(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) (database.Webhook, bool) {
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
userID, ok := h.getUserID(r)
|
userID, ok := h.getUserID(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(
|
http.Redirect(
|
||||||
w, r, "/pages/login", http.StatusSeeOther,
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return database.Webhook{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID := chi.URLParam(r, "sourceID")
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
var webhook database.Webhook
|
|
||||||
|
|
||||||
err := h.db.DB().Where(
|
err := h.db.DB().Where(
|
||||||
"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)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
return database.Webhook{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhook, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSourceLogs shows the request/response logs for a
|
||||||
|
// webhook.
|
||||||
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,15 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
r.Post("/delete", s.h.HandleSourceDelete())
|
r.Post("/delete", s.h.HandleSourceDelete())
|
||||||
r.Get("/logs", s.h.HandleSourceLogs())
|
r.Get("/logs", s.h.HandleSourceLogs())
|
||||||
|
// The log page renders each body only up to its cap, so
|
||||||
|
// this is the only route that serves a whole one. It
|
||||||
|
// belongs to this group for its RequireAuth and
|
||||||
|
// NoCache; see HandleEventBodyDownload for the headers
|
||||||
|
// that keep the bytes it returns inert.
|
||||||
|
r.Get(
|
||||||
|
"/logs/{eventID}/body",
|
||||||
|
s.h.HandleEventBodyDownload(),
|
||||||
|
)
|
||||||
r.Post(
|
r.Post(
|
||||||
"/entrypoints",
|
"/entrypoints",
|
||||||
s.h.HandleEntrypointCreate(),
|
s.h.HandleEntrypointCreate(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
@@ -49,6 +51,7 @@ type testEnv struct {
|
|||||||
router http.Handler
|
router http.Handler
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestEnv wires the dependency graph with fx and builds the
|
// newTestEnv wires the dependency graph with fx and builds the
|
||||||
@@ -64,6 +67,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
hnd *handlers.Handlers
|
hnd *handlers.Handlers
|
||||||
sess *session.Session
|
sess *session.Session
|
||||||
db *database.Database
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
)
|
)
|
||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
@@ -86,7 +90,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
middleware.New,
|
middleware.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
),
|
),
|
||||||
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db),
|
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db, &dbMgr),
|
||||||
)
|
)
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -95,6 +99,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
||||||
sess: sess,
|
sess: sess,
|
||||||
db: db,
|
db: db,
|
||||||
|
dbMgr: dbMgr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +238,49 @@ func (e *testEnv) seedUser(
|
|||||||
return user.ID, hash
|
return user.ID, hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedWebhook creates a webhook owned by the given user.
|
||||||
|
func (e *testEnv) seedWebhook(
|
||||||
|
t *testing.T,
|
||||||
|
userID string,
|
||||||
|
) *database.Webhook {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{UserID: userID, Name: "routed"}
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
e.db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wh
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEvent records one event with the given body in a webhook's
|
||||||
|
// own database.
|
||||||
|
func (e *testEnv) seedEvent(
|
||||||
|
t *testing.T,
|
||||||
|
webhookID, body string,
|
||||||
|
) *database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: body,
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
webhookDB.Omit(clause.Associations).Create(event).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
// storedHash reads the current password hash for a username.
|
// storedHash reads the current password hash for a username.
|
||||||
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -432,3 +480,95 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|||||||
"an under-limit password change should still apply",
|
"an under-limit password change should still apply",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- /source/{sourceID} group ---
|
||||||
|
|
||||||
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||||
|
// feature the way a user does: render the event log page through
|
||||||
|
// the production router, take the download URL out of the markup
|
||||||
|
// the template emitted, and fetch that URL through the router
|
||||||
|
// again. Nothing here is hand-written, so a typo in either the
|
||||||
|
// route pattern or the template href fails this test — the
|
||||||
|
// handler-level tests cannot catch that, because they forge
|
||||||
|
// their own route context and assert a URL string they wrote
|
||||||
|
// themselves.
|
||||||
|
func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "loguser", "somepassword")
|
||||||
|
cookies := env.authCookies(t, userID, "loguser")
|
||||||
|
|
||||||
|
// Comfortably over the event log page's render cap, so the
|
||||||
|
// page truncates the body and renders the download link at
|
||||||
|
// all. The exact cap is the handlers package's business and
|
||||||
|
// is pinned by its own tests; this only needs to exceed it.
|
||||||
|
stored := strings.Repeat("Z", 64*1024)
|
||||||
|
|
||||||
|
wh := env.seedWebhook(t, userID)
|
||||||
|
env.seedEvent(t, wh.ID, stored)
|
||||||
|
|
||||||
|
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
||||||
|
require.Equal(t, http.StatusOK, page.Code)
|
||||||
|
|
||||||
|
link := regexp.MustCompile(
|
||||||
|
`href="(/source/[^"]+/body)"`,
|
||||||
|
).FindStringSubmatch(page.Body.String())
|
||||||
|
require.Len(
|
||||||
|
t, link, 2,
|
||||||
|
"truncated body should render a download link",
|
||||||
|
)
|
||||||
|
|
||||||
|
w := env.get(html.UnescapeString(link[1]), cookies)
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
t, http.StatusOK, w.Code,
|
||||||
|
"the link the page emits must be a live route",
|
||||||
|
)
|
||||||
|
assert.Equal(t, stored, w.Body.String())
|
||||||
|
assert.Equal(
|
||||||
|
t, strconv.Itoa(len(stored)),
|
||||||
|
w.Header().Get("Content-Length"),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "application/octet-stream",
|
||||||
|
w.Header().Get("Content-Type"),
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Header().Get("Content-Disposition"), "attachment",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "nosniff", w.Header().Get("X-Content-Type-Options"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceLogsBody_OtherUser404s pins that the download route
|
||||||
|
// as registered is behind the auth the group provides and the
|
||||||
|
// ownership check the handler applies: another logged-in user
|
||||||
|
// asking the real router for the same URL gets a 404, and an
|
||||||
|
// unauthenticated request never reaches the handler at all.
|
||||||
|
func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
||||||
|
wh := env.seedWebhook(t, ownerID)
|
||||||
|
|
||||||
|
const payload = "OWNERS-PAYLOAD-77c1"
|
||||||
|
|
||||||
|
evt := env.seedEvent(t, wh.ID, payload)
|
||||||
|
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
||||||
|
|
||||||
|
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
||||||
|
intruder := env.authCookies(t, intruderID, "intruder")
|
||||||
|
|
||||||
|
w := env.get(path, intruder)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), payload)
|
||||||
|
|
||||||
|
anon := env.get(path, nil)
|
||||||
|
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
||||||
|
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,20 +3,14 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# 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 packaged in nix, brew, and apk; on apt
|
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||||
# it is installed from a hash-verified GitHub release archive (never
|
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||||
# curl | sh). Finishes by running script/fetch-assets, which installs the
|
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||||
# hash-pinned third-party browser assets the repo does not commit.
|
# assets the repo does not commit.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Pinned versions, 2026-08-07. Never "latest"; exact versions only.
|
|
||||||
GOLANGCI_LINT_VERSION="2.12.2"
|
|
||||||
# sha256 of golangci-lint-2.12.2-linux-<arch>.tar.gz release archives
|
|
||||||
GOLANGCI_LINT_SHA256_AMD64="8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553"
|
|
||||||
GOLANGCI_LINT_SHA256_ARM64="44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a"
|
|
||||||
|
|
||||||
PKGMGR=""
|
PKGMGR=""
|
||||||
SUDO=""
|
SUDO=""
|
||||||
|
|
||||||
@@ -57,52 +51,6 @@ missing() {
|
|||||||
! command -v "$1" >/dev/null 2>&1
|
! command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
# verify_sha256 <file> <expected-hash>
|
|
||||||
verify_sha256() {
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
|
||||||
else
|
|
||||||
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
|
||||||
fi
|
|
||||||
if [ "$actual" != "$2" ]; then
|
|
||||||
echo "bootstrap: sha256 mismatch for $1" >&2
|
|
||||||
echo " expected: $2" >&2
|
|
||||||
echo " actual: $actual" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# apt has no golangci-lint package: install a pinned release archive
|
|
||||||
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
|
||||||
install_golangci_lint_release() {
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
|
||||||
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
|
||||||
*)
|
|
||||||
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if missing curl; then pkg_install curl curl curl curl; fi
|
|
||||||
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
curl -fsSL -o "$tmp/$name.tar.gz" \
|
|
||||||
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
|
||||||
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
|
||||||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
|
||||||
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
|
||||||
rm -rf "$tmp"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_golangci_lint() {
|
|
||||||
if ! missing golangci-lint; then return 0; fi
|
|
||||||
detect_pkgmgr
|
|
||||||
case "$PKGMGR" in
|
|
||||||
apt) install_golangci_lint_release ;;
|
|
||||||
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -110,9 +58,14 @@ main() {
|
|||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
|
||||||
# Go toolchain and linter
|
# Go toolchain
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
ensure_golangci_lint
|
|
||||||
|
# Not installed here: docker is platform-specific and out of scope for a
|
||||||
|
# package-manager bootstrap, but script/lint needs it.
|
||||||
|
if missing docker; then
|
||||||
|
echo "bootstrap: docker not found; script/lint requires it" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
|||||||
47
script/lint
47
script/lint
@@ -1,12 +1,55 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter.
|
# script/lint: run the linter. golangci-lint is never installed locally: it
|
||||||
|
# runs via docker only, one way, everywhere — script/lint builds
|
||||||
|
# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image
|
||||||
|
# and lints as a build step. This works even when the docker daemon is remote
|
||||||
|
# and bind mounts are impossible, and it removes the host linter's shared
|
||||||
|
# cache, which has attributed other checkouts' findings to this one.
|
||||||
|
#
|
||||||
|
# --no-cache-filter=lint forces the lint stage to re-execute on every run; a
|
||||||
|
# cached lint stage exits 0 in under a second having linted nothing. The deps
|
||||||
|
# stage keeps its cache, so module downloads are not repeated.
|
||||||
|
# --progress=plain keeps the linter's own output visible on success, so a
|
||||||
|
# passing run shows the issue count rather than nothing.
|
||||||
|
# --output=type=cacheonly leaves no image behind to clean up.
|
||||||
|
#
|
||||||
|
# docker silently ignores --no-cache-filter for a stage name that does not
|
||||||
|
# match, so a rename or a typo would restore the cached false green with no
|
||||||
|
# warning and a fast exit 0. The flag is therefore not trusted: the build
|
||||||
|
# output is teed to a log and a run is only a pass if golangci-lint's own
|
||||||
|
# summary line ("N issues." / "N issues:") is in it. No summary, no lint,
|
||||||
|
# whatever the exit code says.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
log="$(mktemp -t webhooker-lint.XXXXXXXX)"
|
||||||
|
rcfile="$(mktemp -t webhooker-lint-rc.XXXXXXXX)"
|
||||||
|
trap 'rm -f "$log" "$rcfile"' EXIT INT TERM
|
||||||
|
|
||||||
|
# The pipeline's status is tee's, and POSIX sh has no pipefail, so the
|
||||||
|
# build's status travels via a file. Output still streams live.
|
||||||
|
{
|
||||||
|
docker build \
|
||||||
|
-f Dockerfile.lint \
|
||||||
|
--no-cache-filter=lint \
|
||||||
|
--progress=plain \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
. 2>&1 && echo 0 >"$rcfile" || echo $? >"$rcfile"
|
||||||
|
} | tee "$log" >&2
|
||||||
|
|
||||||
|
rc="$(cat "$rcfile")"
|
||||||
|
[ "$rc" -eq 0 ] || exit "$rc"
|
||||||
|
|
||||||
|
if ! grep -qE '[0-9]+ issues[.:]' "$log"; then
|
||||||
|
echo "script/lint: golangci-lint printed no summary line; the linter" >&2
|
||||||
|
echo " did not run. Check that the stage named in --no-cache-filter" >&2
|
||||||
|
echo " still matches a stage in Dockerfile.lint." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||||
{{if .BodyTruncated}}
|
{{if .BodyTruncated}}
|
||||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged.</p>
|
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user