Compare commits
1 Commits
6772304c60
...
c0c13ec80c
| Author | SHA1 | Date | |
|---|---|---|---|
| c0c13ec80c |
@@ -3,11 +3,6 @@
|
|||||||
# 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
|
|
||||||
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
|
||||||
# from supplying the bytes that get shipped. The script and its
|
|
||||||
# static/vendor.sha256 manifest stay in the context.
|
|
||||||
static/js/alpine.min.js
|
|
||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
.editorconfig
|
.editorconfig
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -44,9 +44,4 @@ tmp/
|
|||||||
temp/
|
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
|
|
||||||
# script/fetch-assets against static/vendor.sha256. Not committed:
|
|
||||||
# REPO_POLICIES.md forbids minified bundles in version control.
|
|
||||||
/static/js/alpine.min.js
|
|
||||||
10
Dockerfile
10
Dockerfile
@@ -32,7 +32,7 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
|||||||
# Depend on lint stage passing
|
# Depend on lint stage passing
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -44,14 +44,6 @@ 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
|
|
||||||
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
|
||||||
# .dockerignore keeps any host copy out of the build context, so this step is
|
|
||||||
# the only way they enter the image. Each download is checked against a
|
|
||||||
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
|
|
||||||
# hashes against the bytes go:embed actually put in the binary.
|
|
||||||
RUN script/fetch-assets
|
|
||||||
|
|
||||||
# Run tests and build
|
# Run tests and build
|
||||||
RUN make test
|
RUN make test
|
||||||
RUN make build
|
RUN make build
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css
|
.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker clean hooks css
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -9,9 +9,6 @@ bootstrap:
|
|||||||
setup:
|
setup:
|
||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
assets:
|
|
||||||
@script/fetch-assets
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -40,7 +40,6 @@ 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 fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make lint # Run golangci-lint
|
make lint # Run golangci-lint
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
@@ -248,8 +247,6 @@ them. 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
|
|
||||||
`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
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
@@ -263,27 +260,6 @@ them. We provide:
|
|||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
runs `script/precommit`
|
runs `script/precommit`
|
||||||
|
|
||||||
## Third-party browser assets
|
|
||||||
|
|
||||||
The web UI serves one third-party script, Alpine.js. It is **not** committed:
|
|
||||||
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
|
||||||
both committed build artifacts and unpinned external references.
|
|
||||||
|
|
||||||
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
|
||||||
download against a hardcoded sha256, and installs it under `static/`. The
|
|
||||||
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
|
||||||
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
|
||||||
against that manifest — so the pin is enforced on what actually ships, not
|
|
||||||
merely written down. Any mismatch fails the build.
|
|
||||||
|
|
||||||
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
|
||||||
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
|
||||||
artifact out of both the repo and the build context.
|
|
||||||
|
|
||||||
To move to a new version: update the version, URL, and tarball sha256 in
|
|
||||||
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
|
||||||
run `make assets && make check`.
|
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Webhook integrations between services are inherently fragile. The
|
Webhook integrations between services are inherently fragile. The
|
||||||
@@ -1196,34 +1172,9 @@ downstream at form-parse time.
|
|||||||
On SIGINT or SIGTERM, fx runs the registered stop hooks in reverse
|
On SIGINT or SIGTERM, fx runs the registered stop hooks in reverse
|
||||||
dependency order under a **5 second budget** (`fx.StopTimeout` in
|
dependency order under a **5 second budget** (`fx.StopTimeout` in
|
||||||
`cmd/webhooker/main.go`). That budget covers the whole sequence, not
|
`cmd/webhooker/main.go`). That budget covers the whole sequence, not
|
||||||
each hook. The order, read off the fx stop-hook log:
|
each hook: the HTTP server drains first with its own 5 second limit,
|
||||||
|
then the delivery engine, the reapers, and finally the database
|
||||||
1. `ArchiveSweeper`
|
close.
|
||||||
2. `RetentionReaper`
|
|
||||||
3. `server` — the HTTP drain, bounded separately by
|
|
||||||
`server.ShutdownTimeout` (**3 seconds**)
|
|
||||||
4. `delivery.Engine`
|
|
||||||
5. `healthcheck`
|
|
||||||
6. `WebhookDBManager`
|
|
||||||
7. the database close
|
|
||||||
|
|
||||||
The two components that can realistically hold the budget run
|
|
||||||
first: a retention sweep or an archive prune caught mid-tick each
|
|
||||||
waits on its `WaitGroup` bounded by the stop context, so a wedge
|
|
||||||
there consumes the 5 seconds before the HTTP server hook is ever
|
|
||||||
entered. The hooks after the server are microsecond-scale in normal
|
|
||||||
operation.
|
|
||||||
|
|
||||||
The HTTP drain budget is deliberately **shorter** than the sequence
|
|
||||||
budget. Were the two equal, a drain that used its whole budget would
|
|
||||||
exhaust the sequence budget at the instant it finished, and every
|
|
||||||
later hook — the delivery engine, the healthcheck, the webhook DB
|
|
||||||
manager and the database close — would be skipped in exactly the
|
|
||||||
case where the drain mattered. 3 seconds leaves 2 seconds for the
|
|
||||||
tail, which is far more than it needs. This does not make the
|
|
||||||
database close unconditional: a wedged `ArchiveSweeper` or
|
|
||||||
`RetentionReaper` still runs first and can consume the whole budget
|
|
||||||
on its own.
|
|
||||||
|
|
||||||
The value is chosen to sit inside the container stop grace period.
|
The value is chosen to sit inside the container stop grace period.
|
||||||
Docker's default `docker stop` grace is 10 seconds and the Dockerfile
|
Docker's default `docker stop` grace is 10 seconds and the Dockerfile
|
||||||
|
|||||||
@@ -17,27 +17,15 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stopTimeout bounds the whole fx stop sequence, not each hook.
|
// stopTimeout bounds the whole fx stop sequence.
|
||||||
//
|
//
|
||||||
// fx defaults to 15s, which is longer than Docker's 10s default
|
// fx defaults to 15s, which is longer than Docker's 10s default
|
||||||
// stop grace: the container would be SIGKILLed before the bound
|
// stop grace: the container would be SIGKILLed before the bound
|
||||||
// could fire, so nothing bounded by it would ever be observed.
|
// could fire, so nothing bounded by it would ever be observed.
|
||||||
// 5s leaves headroom inside that grace for signal delivery and
|
// 5s leaves headroom inside that grace for signal delivery and
|
||||||
// process exit; the observed wedge case already exits at ~5.3s,
|
// process exit, and matches the HTTP server's own drain budget so
|
||||||
// so a larger bound would trade a rare skipped database close for
|
// the first hook can spend its whole budget without the bound
|
||||||
// a more common hard kill.
|
// truncating it.
|
||||||
//
|
|
||||||
// It must stay strictly above server.ShutdownTimeout: an HTTP
|
|
||||||
// drain that uses its whole budget would otherwise exhaust the
|
|
||||||
// sequence budget at that instant, and fx would skip every hook
|
|
||||||
// after the server — the delivery engine, the healthcheck, the
|
|
||||||
// webhook DB manager and the database close. Those tail hooks are
|
|
||||||
// microsecond-scale in normal operation, so the 2s difference is
|
|
||||||
// ample. TestStopTimeout_LeavesHeadroomForTailHooks pins it.
|
|
||||||
//
|
|
||||||
// This does not make the database close unconditional: the
|
|
||||||
// ArchiveSweeper and RetentionReaper hooks run before the server
|
|
||||||
// and can still consume the whole budget on their own.
|
|
||||||
const stopTimeout = 5 * time.Second
|
const stopTimeout = 5 * time.Second
|
||||||
|
|
||||||
// Build-time variables set via -ldflags.
|
// Build-time variables set via -ldflags.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// dockerStopGrace is Docker's default `docker stop` grace period.
|
// dockerStopGrace is Docker's default `docker stop` grace period.
|
||||||
@@ -32,30 +31,3 @@ func TestNewApp_StopTimeout(t *testing.T) {
|
|||||||
require.Equal(t, stopTimeout, got)
|
require.Equal(t, stopTimeout, got)
|
||||||
require.Less(t, got, dockerStopGrace)
|
require.Less(t, got, dockerStopGrace)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tailHeadroom is the slack the fx stop budget must keep beyond the
|
|
||||||
// HTTP drain. The hooks that run after the server — the delivery
|
|
||||||
// engine, the healthcheck, the webhook DB manager and the database
|
|
||||||
// close — are microsecond-scale in normal operation, so this is
|
|
||||||
// generous for them.
|
|
||||||
const tailHeadroom = 2 * time.Second
|
|
||||||
|
|
||||||
// TestStopTimeout_LeavesHeadroomForTailHooks pins the relationship
|
|
||||||
// between the HTTP drain budget and the fx stop budget. fx bounds
|
|
||||||
// the whole stop sequence, and returns without running its
|
|
||||||
// remaining hooks once the stop context has expired. If the two
|
|
||||||
// values were equal, an HTTP drain that used its full budget would
|
|
||||||
// exhaust the sequence budget at the instant it finished and every
|
|
||||||
// later hook, the database close included, would be skipped in
|
|
||||||
// exactly the case where the drain mattered.
|
|
||||||
//
|
|
||||||
// Lowering either constant to erase the gap must fail here rather
|
|
||||||
// than silently recreating that.
|
|
||||||
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
|
||||||
require.GreaterOrEqual(
|
|
||||||
t, stopTimeout-server.ShutdownTimeout, tailHeadroom,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -106,12 +106,6 @@ func slackConfigFields(configJSON string) []ConfigField {
|
|||||||
// and its retry settings. Header values are not shown — they
|
// and its retry settings. Header values are not shown — they
|
||||||
// routinely carry authorization tokens — only how many are
|
// routinely carry authorization tokens — only how many are
|
||||||
// configured.
|
// configured.
|
||||||
//
|
|
||||||
// The destination is masked to scheme and host by the same
|
|
||||||
// rule the Slack target uses. An HTTP target's destination is
|
|
||||||
// commonly a Slack, Discord or Teams incoming-webhook endpoint
|
|
||||||
// whose path segments are the credential, and the field takes
|
|
||||||
// an arbitrary URL, so no segment can be assumed non-secret.
|
|
||||||
func httpConfigFields(t *database.Target) []ConfigField {
|
func httpConfigFields(t *database.Target) []ConfigField {
|
||||||
cfg, err := parseHTTPConfig(t.Config)
|
cfg, err := parseHTTPConfig(t.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,7 +114,7 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
|
|
||||||
fields := []ConfigField{{
|
fields := []ConfigField{{
|
||||||
Label: "Destination URL",
|
Label: "Destination URL",
|
||||||
Value: MaskURL(cfg.URL),
|
Value: cfg.URL,
|
||||||
}}
|
}}
|
||||||
|
|
||||||
if cfg.Timeout > 0 {
|
if cfg.Timeout > 0 {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const (
|
|||||||
|
|
||||||
viewExampleOrigin = "https://example.com"
|
viewExampleOrigin = "https://example.com"
|
||||||
viewExampleHook = viewExampleOrigin + "/hook"
|
viewExampleHook = viewExampleOrigin + "/hook"
|
||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
)
|
)
|
||||||
@@ -163,7 +162,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewExampleHook,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
"Max Retries": "5",
|
"Max Retries": "5",
|
||||||
@@ -189,41 +188,13 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewExampleHook,
|
||||||
"Max Retries": "0 (fire-and-forget)",
|
"Max Retries": "0 (fire-and-forget)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewTargetViews_HTTPMasksDestinationURL proves the rule
|
|
||||||
// holds for the http target too: an http destination is
|
|
||||||
// routinely an incoming-webhook endpoint whose path segments
|
|
||||||
// are the credential, so none of them is shown.
|
|
||||||
func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
view := viewFor(t, database.Target{
|
|
||||||
Type: database.TargetTypeHTTP,
|
|
||||||
Config: `{"url":"` + slackWebhookURL + `"}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
"https://hooks.slack.com/...",
|
|
||||||
fields["Destination URL"],
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, v := range fields {
|
|
||||||
assert.NotContains(t, v, slackSecretPath)
|
|
||||||
assert.NotContains(t, v, "T00000000")
|
|
||||||
assert.NotContains(t, v, "B00000000")
|
|
||||||
assert.NotContains(t, v, "XXXXXXXXXXXXXXXXXXXXXXXX")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTargetViews_Database(t *testing.T) {
|
func TestNewTargetViews_Database(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxRenderedBodyBytes caps how many bytes of a stored event
|
|
||||||
// body reach the event log page. Bodies come from the
|
|
||||||
// unauthenticated receiver under the 1 MB ingest cap and
|
|
||||||
// renderTemplate buffers a whole page before writing it, so
|
|
||||||
// an uncapped page of paginationPerPage events is tens of
|
|
||||||
// megabytes of resident memory per concurrent viewer.
|
|
||||||
const maxRenderedBodyBytes = 8192
|
|
||||||
|
|
||||||
// eventLogColumns is the event log's projection. The casts to
|
|
||||||
// blob are load-bearing: they make substr and length count
|
|
||||||
// bytes rather than characters, so the cap bounds the page in
|
|
||||||
// bytes whatever the payload's encoding. Cutting in SQLite
|
|
||||||
// rather than in Go is the point of the projection — an
|
|
||||||
// oversized body never becomes a Go string at all.
|
|
||||||
const eventLogColumns = "id, created_at, method, content_type, " +
|
|
||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
|
||||||
|
|
||||||
// EventLogView is the display-safe projection of an event for
|
|
||||||
// the event log page, alongside DeliveryView and TargetView.
|
|
||||||
// It carries a capped body plus the true stored size, so the
|
|
||||||
// page can mark a body as truncated without ever holding the
|
|
||||||
// whole thing.
|
|
||||||
type EventLogView struct {
|
|
||||||
ID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
Method string
|
|
||||||
ContentType string
|
|
||||||
|
|
||||||
// Body holds at most maxRenderedBodyBytes bytes of the
|
|
||||||
// stored body.
|
|
||||||
Body string
|
|
||||||
|
|
||||||
// BodyBytes is the true size of the stored body.
|
|
||||||
BodyBytes int64
|
|
||||||
|
|
||||||
// BodyTruncated reports that the stored body was larger
|
|
||||||
// than the cap, so the page owes the reader a marker.
|
|
||||||
BodyTruncated bool
|
|
||||||
|
|
||||||
Deliveries []DeliveryView
|
|
||||||
}
|
|
||||||
|
|
||||||
// BodyShownBytes is how many body bytes the page is actually
|
|
||||||
// rendering, which the truncation marker reports beside the
|
|
||||||
// true size.
|
|
||||||
func (v EventLogView) BodyShownBytes() int {
|
|
||||||
return len(v.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventLogRow is one row of the event log projection. Its
|
|
||||||
// body column arrives already cut to the cap by SQLite, with
|
|
||||||
// the true size beside it.
|
|
||||||
type eventLogRow struct {
|
|
||||||
ID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
Method string
|
|
||||||
ContentType string
|
|
||||||
Body []byte
|
|
||||||
BodyBytes int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// view projects a loaded row for rendering.
|
|
||||||
func (r *eventLogRow) view() EventLogView {
|
|
||||||
body := r.Body
|
|
||||||
truncated := r.BodyBytes > int64(len(body))
|
|
||||||
|
|
||||||
// Only a cut body can have been left mid-sequence by
|
|
||||||
// this query. A whole body is passed through exactly as
|
|
||||||
// stored, however malformed.
|
|
||||||
if truncated {
|
|
||||||
body = trimPartialRune(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return EventLogView{
|
|
||||||
ID: r.ID,
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
Method: r.Method,
|
|
||||||
ContentType: r.ContentType,
|
|
||||||
Body: string(body),
|
|
||||||
BodyBytes: r.BodyBytes,
|
|
||||||
BodyTruncated: truncated,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// trimPartialRune drops a trailing UTF-8 sequence that the
|
|
||||||
// byte-wise cut left incomplete, so a multi-byte rune severed
|
|
||||||
// at the cap does not surface as a mojibake tail.
|
|
||||||
//
|
|
||||||
// Bytes that are merely invalid UTF-8 are left exactly as
|
|
||||||
// stored: this service receives binary payloads, and rewriting
|
|
||||||
// them would misreport what was delivered. The distinction is
|
|
||||||
// utf8.FullRune's — it reports a complete sequence for an
|
|
||||||
// invalid encoding too, since that decodes to a width-1 error
|
|
||||||
// rune, so only a valid prefix still waiting for its
|
|
||||||
// continuation bytes is removed. A tail with no rune start in
|
|
||||||
// its last utf8.UTFMax bytes cannot be an incomplete sequence
|
|
||||||
// either, and is likewise left alone.
|
|
||||||
func trimPartialRune(b []byte) []byte {
|
|
||||||
for i := len(b) - 1; i >= 0 && len(b)-i <= utf8.UTFMax; i-- {
|
|
||||||
if !utf8.RuneStart(b[i]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if utf8.FullRune(b[i:]) {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
return b[:i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// bodyCap is the number of body bytes the event log page is
|
|
||||||
// allowed to render for one event.
|
|
||||||
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
|
||||||
|
|
||||||
// snowman is a three-byte rune, so a body of them straddles the
|
|
||||||
// byte-wise cut: bodyCap is not a multiple of three.
|
|
||||||
const snowman = "☃"
|
|
||||||
|
|
||||||
// seedEventWithBody records one event with the given body in the
|
|
||||||
// webhook's own database.
|
|
||||||
func seedEventWithBody(
|
|
||||||
t *testing.T,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
webhookID string,
|
|
||||||
body string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
webhookDB, err := 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedAndProject stores one body and returns the projection the
|
|
||||||
// event log page would be handed for it.
|
|
||||||
func seedAndProject(
|
|
||||||
t *testing.T,
|
|
||||||
body string,
|
|
||||||
) handlers.EventLogView {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
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, body)
|
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
|
||||||
|
|
||||||
return views[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
|
||||||
// page is bounded by the cap rather than by the stored payload:
|
|
||||||
// the body here is 64 times the cap, and the ingest path would
|
|
||||||
// accept twice as much again.
|
|
||||||
func TestHandleSourceLogs_BoundsOversizeBody(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 (
|
|
||||||
sentinel = "TAIL-SENTINEL-1f4a9c"
|
|
||||||
storedBytes = 512 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
seedEventWithBody(
|
|
||||||
t, dbMgr, wh.ID,
|
|
||||||
strings.Repeat("A", storedBytes-len(sentinel))+sentinel,
|
|
||||||
)
|
|
||||||
|
|
||||||
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
// Nothing past the cap reaches the page, and the whole page
|
|
||||||
// stays far below the stored body it is reporting on.
|
|
||||||
assert.NotContains(t, page, sentinel)
|
|
||||||
assert.Less(t, len(page), 4*bodyCap)
|
|
||||||
|
|
||||||
// The marker states the true stored size, not the cut one.
|
|
||||||
assert.Contains(
|
|
||||||
t, page,
|
|
||||||
"showing "+strconv.Itoa(bodyCap)+
|
|
||||||
" of "+strconv.Itoa(storedBytes)+" bytes",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceLogs_SmallBodyRendersWhole guards the other
|
|
||||||
// side of the cap: a body under it is shown in full and carries
|
|
||||||
// no truncation marker.
|
|
||||||
func TestHandleSourceLogs_SmallBodyRendersWhole(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, `{"kept":"whole"}`)
|
|
||||||
|
|
||||||
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.Contains(t, page, ""kept"")
|
|
||||||
assert.NotContains(t, page, "Body truncated for display")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
|
||||||
// by the byte-wise cut is dropped rather than surfaced as a
|
|
||||||
// mojibake tail.
|
|
||||||
func TestEventLogView_CutMidRune(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
body := strings.Repeat(snowman, 4096)
|
|
||||||
view := seedAndProject(t, body)
|
|
||||||
|
|
||||||
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
|
||||||
// of the next one; those two are dropped.
|
|
||||||
whole := bodyCap / len(snowman)
|
|
||||||
|
|
||||||
assert.True(t, view.BodyTruncated)
|
|
||||||
assert.Equal(t, int64(len(body)), view.BodyBytes)
|
|
||||||
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
|
|
||||||
assert.True(t, utf8.ValidString(view.Body))
|
|
||||||
assert.LessOrEqual(t, len(view.Body), bodyCap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
|
|
||||||
// payload is passed through byte for byte. Its tail is invalid
|
|
||||||
// UTF-8 however the cut falls, so repairing it would misreport
|
|
||||||
// what the sender delivered.
|
|
||||||
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
raw := make([]byte, bodyCap+808)
|
|
||||||
for i := range raw {
|
|
||||||
// 0x80..0xBF: continuation bytes, never a rune start.
|
|
||||||
raw[i] = 0x80 | byte(i%0x40)
|
|
||||||
}
|
|
||||||
|
|
||||||
view := seedAndProject(t, string(raw))
|
|
||||||
|
|
||||||
assert.True(t, view.BodyTruncated)
|
|
||||||
assert.Equal(t, int64(len(raw)), view.BodyBytes)
|
|
||||||
assert.Equal(t, string(raw[:bodyCap]), view.Body)
|
|
||||||
assert.False(t, utf8.ValidString(view.Body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTrimPartialRune covers the distinction the cut repair
|
|
||||||
// turns on: an incomplete but valid sequence is dropped, while
|
|
||||||
// bytes that are merely invalid UTF-8 are left alone.
|
|
||||||
func TestTrimPartialRune(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in []byte
|
|
||||||
want []byte
|
|
||||||
}{{
|
|
||||||
name: "complete ascii",
|
|
||||||
in: []byte("abc"),
|
|
||||||
want: []byte("abc"),
|
|
||||||
}, {
|
|
||||||
name: "complete multibyte",
|
|
||||||
in: []byte("ab" + snowman),
|
|
||||||
want: []byte("ab" + snowman),
|
|
||||||
}, {
|
|
||||||
name: "two byte rune cut",
|
|
||||||
in: []byte{'a', 0xC3},
|
|
||||||
want: []byte{'a'},
|
|
||||||
}, {
|
|
||||||
name: "three byte rune cut after one",
|
|
||||||
in: []byte{'a', 0xE2},
|
|
||||||
want: []byte{'a'},
|
|
||||||
}, {
|
|
||||||
name: "three byte rune cut after two",
|
|
||||||
in: []byte{'a', 0xE2, 0x98},
|
|
||||||
want: []byte{'a'},
|
|
||||||
}, {
|
|
||||||
name: "four byte rune cut",
|
|
||||||
in: []byte{'a', 0xF0, 0x9F, 0x92}, // U+1F4A9 cut
|
|
||||||
want: []byte{'a'},
|
|
||||||
}, {
|
|
||||||
name: "invalid start byte kept",
|
|
||||||
in: []byte{'a', 0xFF},
|
|
||||||
want: []byte{'a', 0xFF},
|
|
||||||
}, {
|
|
||||||
name: "orphan continuation bytes kept",
|
|
||||||
in: []byte{0x80, 0x81, 0x82, 0x83, 0x84},
|
|
||||||
want: []byte{0x80, 0x81, 0x82, 0x83, 0x84},
|
|
||||||
}, {
|
|
||||||
name: "truncated sequence followed by junk kept",
|
|
||||||
in: []byte{0xE2, 0x98, 0xFF},
|
|
||||||
want: []byte{0xE2, 0x98, 0xFF},
|
|
||||||
}, {
|
|
||||||
name: "empty",
|
|
||||||
in: []byte{},
|
|
||||||
want: []byte{},
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, tc.want,
|
|
||||||
handlers.TrimPartialRuneForTest(tc.in),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,35 +3,8 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxRenderedBodyBytesForTest exposes the event log's body cap
|
|
||||||
// to the handlers_test package.
|
|
||||||
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
|
||||||
|
|
||||||
// TrimPartialRuneForTest exposes trimPartialRune for use in the
|
|
||||||
// handlers_test package.
|
|
||||||
func TrimPartialRuneForTest(b []byte) []byte {
|
|
||||||
return trimPartialRune(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadEventLogViewsForTest exposes loadEventsWithDeliveries for
|
|
||||||
// use in the handlers_test package. Assertions on the projected
|
|
||||||
// body need the bytes as loaded: html/template rewrites invalid
|
|
||||||
// UTF-8 on the way out, so the rendered page cannot show whether
|
|
||||||
// a binary body survived the projection intact.
|
|
||||||
func (s *Handlers) LoadEventLogViewsForTest(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
webhook database.Webhook,
|
|
||||||
page int,
|
|
||||||
) []EventLogView {
|
|
||||||
views, _ := s.loadEventsWithDeliveries(w, webhook, nil, page)
|
|
||||||
|
|
||||||
return views
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -229,10 +229,8 @@ func (s *Handlers) renderTemplate(
|
|||||||
// the response only once rendering has fully succeeded. Executing
|
// the response only once rendering has fully succeeded. Executing
|
||||||
// straight into the ResponseWriter commits a partial body and a 200
|
// straight into the ResponseWriter commits a partial body and a 200
|
||||||
// status before a mid-render error can be reported, leaving no way
|
// status before a mid-render error can be reported, leaving no way
|
||||||
// to serve a 500. Buffering makes a page's rendered size resident
|
// to serve a 500. These pages are small, so holding one in memory is
|
||||||
// memory per concurrent viewer, so every page owes it a bound: the
|
// the right trade.
|
||||||
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
|
||||||
// this reason.
|
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
|
|||||||
@@ -131,47 +131,6 @@ func TestHandleSourceDetail_MasksSlackWebhookURL(t *testing.T) {
|
|||||||
assert.Contains(t, body, "https://hooks.slack.com/...")
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_MasksHTTPDestinationURL is the
|
|
||||||
// regression test for the same leak reached through the http
|
|
||||||
// target: its destination is routinely an incoming-webhook
|
|
||||||
// endpoint whose path segments are the credential, so the
|
|
||||||
// rendered page must not contain them.
|
|
||||||
func TestHandleSourceDetail_MasksHTTPDestinationURL(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
seedConfiguredTarget(
|
|
||||||
t, db, wh.ID,
|
|
||||||
database.TargetTypeHTTP,
|
|
||||||
`{"url":"`+slackWebhookURL+`"}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
||||||
|
|
||||||
assert.NotContains(t, body, slackSecretPath)
|
|
||||||
assert.NotContains(t, body, "T00000000")
|
|
||||||
assert.NotContains(t, body, "B00000000")
|
|
||||||
assert.NotContains(
|
|
||||||
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Contains(t, body, "Destination URL")
|
|
||||||
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
||||||
// other target types render labelled fields rather than the
|
// other target types render labelled fields rather than the
|
||||||
// stored blob.
|
// stored blob.
|
||||||
@@ -213,7 +172,7 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, body, "Destination URL")
|
assert.Contains(t, body, "Destination URL")
|
||||||
assert.Contains(t, body, "https://example.com/...")
|
assert.Contains(t, body, "https://example.com/hook")
|
||||||
assert.Contains(t, body, "Timeout")
|
assert.Contains(t, body, "Timeout")
|
||||||
assert.Contains(t, body, "1 configured")
|
assert.Contains(t, body, "1 configured")
|
||||||
assert.NotContains(t, body, "sekrit")
|
assert.NotContains(t, body, "sekrit")
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ func parseRetentionDays(raw string, fallback int) (int, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventWithDeliveries holds an event and its deliveries.
|
||||||
|
type EventWithDeliveries struct {
|
||||||
|
database.Event
|
||||||
|
|
||||||
|
Deliveries []DeliveryView
|
||||||
|
}
|
||||||
|
|
||||||
// DeliveryView is the display-safe projection of a delivery
|
// DeliveryView is the display-safe projection of a delivery
|
||||||
// for the event log page. Its target is a TargetView, so the
|
// for the event log page. Its target is a TargetView, so the
|
||||||
// stored configuration blob — which holds the target's
|
// stored configuration blob — which holds the target's
|
||||||
@@ -808,18 +815,16 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadEventsWithDeliveries loads paginated events and their
|
// loadEventsWithDeliveries loads paginated events and their
|
||||||
// deliveries from the per-webhook database. Events come back
|
// deliveries from the per-webhook database.
|
||||||
// as capped projections rather than database.Event rows: see
|
|
||||||
// eventLogColumns for why the cut happens in SQL.
|
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]delivery.TargetView,
|
targetMap map[string]delivery.TargetView,
|
||||||
page int,
|
page int,
|
||||||
) ([]EventLogView, int64) {
|
) ([]EventWithDeliveries, int64) {
|
||||||
var totalEvents int64
|
var totalEvents int64
|
||||||
|
|
||||||
var result []EventLogView
|
var result []EventWithDeliveries
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
return result, totalEvents
|
return result, totalEvents
|
||||||
@@ -840,25 +845,23 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
|
|
||||||
offset := (page - 1) * paginationPerPage
|
offset := (page - 1) * paginationPerPage
|
||||||
|
|
||||||
var rows []eventLogRow
|
var events []database.Event
|
||||||
|
|
||||||
webhookDB.Model(&database.Event{}).Select(
|
webhookDB.Where(
|
||||||
eventLogColumns, maxRenderedBodyBytes,
|
|
||||||
).Where(
|
|
||||||
"webhook_id = ?", webhook.ID,
|
"webhook_id = ?", webhook.ID,
|
||||||
).Order("created_at DESC").Offset(offset).Limit(
|
).Order("created_at DESC").Offset(offset).Limit(
|
||||||
paginationPerPage,
|
paginationPerPage,
|
||||||
).Find(&rows)
|
).Find(&events)
|
||||||
|
|
||||||
result = make([]EventLogView, len(rows))
|
result = make([]EventWithDeliveries, len(events))
|
||||||
|
|
||||||
for i := range rows {
|
for i := range events {
|
||||||
result[i] = rows[i].view()
|
result[i].Event = events[i]
|
||||||
|
|
||||||
var deliveries []database.Delivery
|
var deliveries []database.Delivery
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Where(
|
||||||
"event_id = ?", rows[i].ID,
|
"event_id = ?", events[i].ID,
|
||||||
).Find(&deliveries)
|
).Find(&deliveries)
|
||||||
|
|
||||||
result[i].Deliveries = newDeliveryViews(
|
result[i].Deliveries = newDeliveryViews(
|
||||||
|
|||||||
@@ -24,15 +24,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ShutdownTimeout is the maximum time to wait for the HTTP
|
// shutdownTimeout is the maximum time to wait for the HTTP
|
||||||
// server to finish in-flight requests during shutdown.
|
// server to finish in-flight requests during shutdown.
|
||||||
//
|
shutdownTimeout = 5 * time.Second
|
||||||
// It must stay strictly below the fx stop timeout in
|
|
||||||
// cmd/webhooker, which bounds the whole stop sequence: a drain
|
|
||||||
// that used the entire sequence budget would leave nothing for
|
|
||||||
// the hooks that run after the server, including the database
|
|
||||||
// close. It is exported so that relationship can be tested.
|
|
||||||
ShutdownTimeout = 3 * time.Second
|
|
||||||
|
|
||||||
// sentryFlushTimeout is the maximum time to wait for Sentry
|
// sentryFlushTimeout is the maximum time to wait for Sentry
|
||||||
// to flush pending events during shutdown.
|
// to flush pending events during shutdown.
|
||||||
@@ -170,7 +164,7 @@ func (s *Server) cleanShutdown(ctx context.Context) {
|
|||||||
s.exitCode = 0
|
s.exitCode = 0
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
ctx, ShutdownTimeout,
|
ctx, shutdownTimeout,
|
||||||
)
|
)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
|
||||||
// template loads on each page and fetches it through the real router.
|
|
||||||
// Alpine.js is fetched at build time rather than committed, so nothing
|
|
||||||
// in the repo guarantees it is present: this is the check that the page
|
|
||||||
// still gets the JavaScript it asks for.
|
|
||||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// scriptSrc matches the src of every <script> tag pointing at the
|
|
||||||
// /s/ static mount.
|
|
||||||
scriptSrc := regexp.MustCompile(`<script[^>]+src="(/s/[^"]+)"`)
|
|
||||||
|
|
||||||
base, err := templates.Templates.ReadFile("base.html")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
matches := scriptSrc.FindAllStringSubmatch(string(base), -1)
|
|
||||||
require.NotEmpty(t, matches, "base.html should load scripts from /s/")
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
for _, m := range matches {
|
|
||||||
src := m[1]
|
|
||||||
t.Run(src, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
w := env.get(src, nil)
|
|
||||||
|
|
||||||
require.Equalf(
|
|
||||||
t, http.StatusOK, w.Code,
|
|
||||||
"base.html loads %s but the server does not serve it", src,
|
|
||||||
)
|
|
||||||
assert.NotEmptyf(
|
|
||||||
t, w.Body.Bytes(), "%s is served but empty", src,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
# 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 packaged in nix, brew, and apk; on apt
|
||||||
# it is installed from a hash-verified GitHub release archive (never
|
# it is installed from a hash-verified GitHub release archive (never
|
||||||
# curl | sh). Finishes by running script/fetch-assets, which installs the
|
# curl | sh).
|
||||||
# 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)"
|
||||||
@@ -116,11 +115,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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "$@"
|
|
||||||
5
static/js/alpine.min.js
vendored
Normal file
5
static/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
|
||||||
}
|
|
||||||
@@ -37,9 +37,6 @@
|
|||||||
|
|
||||||
<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}}
|
|
||||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user