Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82478e8052 | ||
|
|
9d29baaa2d | ||
|
|
507980a347 | ||
|
|
b79e4649a1 | ||
|
|
1428154bbd | ||
|
|
8ad2a86e4b | ||
|
|
a891b726e5 | ||
|
|
ab63b5f777 |
+2
-4
@@ -3,10 +3,8 @@
|
||||
# stage of the Dockerfile.
|
||||
.git/
|
||||
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.
|
||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||
# needed. The tarball in 3p/ must stay in the context.
|
||||
static/js/alpine.min.js
|
||||
*.md
|
||||
LICENSE
|
||||
|
||||
+3
-4
@@ -46,7 +46,6 @@ temp/
|
||||
# CI cache barrier, written into the build context by the check workflow
|
||||
.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
|
||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||
# what is committed.
|
||||
/static/js/alpine.min.js
|
||||
|
||||
Binary file not shown.
+4
-11
@@ -51,15 +51,8 @@ RUN go mod download
|
||||
# the lint stage above.
|
||||
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. Both first run script/assets, which extracts Alpine.js
|
||||
# from its tarball in 3p/.
|
||||
RUN make test
|
||||
|
||||
# Version stamped into the binary. .dockerignore excludes .git/, so
|
||||
@@ -67,8 +60,8 @@ RUN make test
|
||||
# host and passes it in. The default is what a bare `docker build .`
|
||||
# with no --build-arg gets, and it names no tag the tree may not be at.
|
||||
#
|
||||
# Declared here, below the test and asset steps, so a changed version
|
||||
# does not invalidate their cached layers.
|
||||
# Declared here, below the test step, so a changed version does not
|
||||
# invalidate its cached layer.
|
||||
ARG VERSION=unknown
|
||||
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
@@ -28,7 +28,7 @@ setup:
|
||||
@script/setup
|
||||
|
||||
assets:
|
||||
@script/fetch-assets
|
||||
@script/assets
|
||||
|
||||
test:
|
||||
@script/test
|
||||
@@ -45,13 +45,13 @@ fmt-check:
|
||||
check:
|
||||
@script/check
|
||||
|
||||
build:
|
||||
build: assets
|
||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
dev:
|
||||
dev: assets
|
||||
go run ./cmd/webhooker
|
||||
|
||||
deps:
|
||||
|
||||
@@ -21,9 +21,6 @@ before deploying one.
|
||||
- Go 1.26.1+ (the version in `go.mod`)
|
||||
- Docker (for linting, for the test stage of the CI gate, and for
|
||||
containerized deployment)
|
||||
- `curl`, used by `script/fetch-assets` to download the third-party
|
||||
browser assets, which are not committed (`make bootstrap` installs
|
||||
it if missing)
|
||||
|
||||
golangci-lint is not a prerequisite and must not be installed on the
|
||||
host: `script/bootstrap` does not install it, and `make lint` runs the
|
||||
@@ -36,9 +33,7 @@ digest-pinned linter image via `Dockerfile.lint`.
|
||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||
cd webhooker
|
||||
|
||||
# Install Go dependencies and the third-party browser assets.
|
||||
# `make deps` alone is not enough: it only runs go mod download/tidy,
|
||||
# and the checks below need the fetched assets.
|
||||
# Install the Go toolchain if missing, and the Go dependencies
|
||||
make bootstrap
|
||||
|
||||
# Run all checks (test, lint, format check)
|
||||
@@ -58,7 +53,7 @@ make docker
|
||||
```bash
|
||||
make bootstrap # Install all dependencies (idempotent)
|
||||
make setup # Bootstrap + install git pre-commit hook
|
||||
make assets # Fetch + verify third-party browser assets
|
||||
make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it)
|
||||
make fmt # Format code (gofmt + goimports)
|
||||
make fmt-check # Fail if gofmt would change anything (writes nothing)
|
||||
make lint # Run golangci-lint in Docker (Dockerfile.lint)
|
||||
@@ -150,6 +145,11 @@ TTY detection, and security headers are always applied.
|
||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
|
||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||
|
||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
||||
every one of these with the value the running server loaded. It is
|
||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
||||
set or not set, never their values.
|
||||
|
||||
#### Allowing egress to your own network
|
||||
|
||||
By default every delivery target must resolve to a public address. The
|
||||
@@ -1221,14 +1221,15 @@ This repository adheres to the
|
||||
standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow. Ten of the Makefile's seventeen targets are thin
|
||||
shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and
|
||||
`version` are inline commands with no script behind them, though
|
||||
`build` and `version` both take their value from `script/version`.
|
||||
`version` are inline commands with no script behind them, though `build`,
|
||||
`run` and `dev` first run `script/assets`, and `build` and `version` both
|
||||
take their value from `script/version`.
|
||||
|
||||
`make check` needs the third-party browser assets in `static/`, which
|
||||
are not committed, so run `make bootstrap` (or just `make assets`) once
|
||||
after cloning. Without them the tests fail with a message naming that
|
||||
remedy. `make check` does not fetch them itself because it must not
|
||||
change any files in the repo.
|
||||
`script/test`, `make build` and `make dev` each run `script/assets`
|
||||
first, which writes the ignored `static/js/alpine.min.js` (see
|
||||
[Third-party browser assets](#third-party-browser-assets)), so
|
||||
`make test`, `make check` and the pre-commit hook work on a fresh clone
|
||||
without a separate step.
|
||||
|
||||
We provide:
|
||||
|
||||
@@ -1236,8 +1237,8 @@ We provide:
|
||||
- `script/setup` — make a fresh clone ready for development
|
||||
(bootstrap, then install-precommit)
|
||||
- `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/assets` — extract Alpine.js from its tarball in `3p/` (see
|
||||
[Third-party browser assets](#third-party-browser-assets))
|
||||
- `script/test` — run the test suite
|
||||
- `script/lint` — run golangci-lint in Docker (see Linting below)
|
||||
- `script/fmt` — format all code (writes)
|
||||
@@ -1259,24 +1260,25 @@ We provide:
|
||||
|
||||
## 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.
|
||||
The web UI serves one third-party script, Alpine.js. Its npm package tarball
|
||||
is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry
|
||||
publishes it. It is a dependency, not this repo's build output, so
|
||||
`REPO_POLICIES.md`'s rule against committed build artifacts does not apply.
|
||||
The directory is `3p/` rather than `vendor/` because Go treats a root
|
||||
`vendor/` directory as its module vendor directory.
|
||||
|
||||
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
||||
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.
|
||||
`script/assets` (`make assets`) extracts the browser build,
|
||||
`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`,
|
||||
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
|
||||
it first, and the Dockerfile builds through `make test` and `make build`, so
|
||||
nothing downloads Alpine.js. The extracted file is not committed, and
|
||||
`.dockerignore` keeps any host copy out of the build context.
|
||||
|
||||
`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`.
|
||||
To move to a new version: download
|
||||
`https://registry.npmjs.org/alpinejs/-/alpinejs-<version>.tgz`, check it
|
||||
against the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||
with it, update its file name in `script/assets`, and run `make check`.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -1439,7 +1441,7 @@ A registered user of the webhooker service.
|
||||
| Field | Type | Description |
|
||||
| ---------- | -------- | ----------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `username` | string | Unique login name |
|
||||
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie |
|
||||
| `password` | string | Argon2id hash (never exposed via API) |
|
||||
|
||||
**Relations:** Has many Webhooks. Has many APIKeys.
|
||||
@@ -2379,14 +2381,14 @@ Removing either cap fails 14 subtests.
|
||||
|
||||
`internal/middleware/logbound_test.go` and
|
||||
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
|
||||
at each of these — 1 KB at `invalid password`, whose accounts are
|
||||
shared with the successful-login line, where a username past 4 KB
|
||||
overflows the session cookie and answers 500 before that line is
|
||||
written — through both handlers, and through seven fills: plain text
|
||||
as the baseline, and then the quotation mark, backslash, tab, newline,
|
||||
C0 control and astral non-printable, six characters the wider of the
|
||||
two handlers spends more on than the client spent sending them. Every
|
||||
case holds each line to the 2,560-byte ceiling. That per-line ceiling
|
||||
at each of these — just under 1 KB at `invalid password`, whose
|
||||
accounts are shared with the successful-login line and so must stay
|
||||
within the 1024-byte username limit — through both handlers, and
|
||||
through seven fills: plain text as the baseline, and then the
|
||||
quotation mark, backslash, tab, newline, C0 control and astral
|
||||
non-printable, six characters the wider of the two handlers spends
|
||||
more on than the client spent sending them. Every case holds each
|
||||
line to the 2,560-byte ceiling. That per-line ceiling
|
||||
is what the figure above states, and every row establishes it.
|
||||
|
||||
Three of the sites go further and bound the whole flood's output — the
|
||||
@@ -2705,6 +2707,7 @@ abuse limit later; they are tracked as future work.
|
||||
| ------ | ------------------------ | ----------- |
|
||||
| `GET` | `/user/{username}` | User profile page |
|
||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
||||
| `GET` | `/sources` | List user's webhooks |
|
||||
| `GET` | `/sources/new` | Create webhook form |
|
||||
| `POST` | `/sources/new` | Create webhook submission |
|
||||
@@ -2755,6 +2758,8 @@ imports. The entry point is `cmd/webhooker/main.go`.
|
||||
|
||||
```
|
||||
webhooker/
|
||||
├── 3p/
|
||||
│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets
|
||||
├── cmd/webhooker/
|
||||
│ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx
|
||||
├── internal/
|
||||
@@ -2815,6 +2820,7 @@ webhooker/
|
||||
│ │ ├── healthcheck.go # Health check handler
|
||||
│ │ ├── index.go # Index page handler
|
||||
│ │ ├── profile.go # User profile handler
|
||||
│ │ ├── settings.go # Read-only Settings page handler
|
||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||
│ │ └── webhook.go # Webhook receiver handler
|
||||
│ ├── healthcheck/
|
||||
@@ -2848,8 +2854,7 @@ webhooker/
|
||||
│ ├── css/tailwind.css # Generated stylesheet the pages load
|
||||
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded
|
||||
│ ├── js/app.js # Progressive-enhancement copy-to-clipboard
|
||||
│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed
|
||||
│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against
|
||||
│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed
|
||||
├── templates/ # Go HTML templates (base, login, sources, etc.)
|
||||
├── script/ # Scripts to Rule Them All entrypoints
|
||||
├── Dockerfile # Three stages: lint, test+build, Alpine runtime
|
||||
@@ -3161,14 +3166,14 @@ version is fixed independently of the compiler's:
|
||||
`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
|
||||
stage passing (it copies a file from it), runs `script/fetch-assets`
|
||||
to download and verify the third-party browser assets, then runs
|
||||
`make test` and `make build`, and finally rebuilds the binary with
|
||||
`CGO_ENABLED=1` and static linking so it runs on musl. Both builds
|
||||
go through `make build`, the relink adding its `-extldflags` via
|
||||
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||
The version arrives as the `VERSION` build arg, since the context
|
||||
has no `.git` (see [Version stamping](#version-stamping)).
|
||||
stage passing (it copies a file from it), runs `make test` and
|
||||
`make build` (both extract Alpine.js from `3p/` first), and finally
|
||||
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
|
||||
runs on musl. Both builds go through `make build`, the relink adding
|
||||
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
|
||||
stamps the version. The version arrives as the `VERSION` build arg,
|
||||
since the context has no `.git` (see
|
||||
[Version stamping](#version-stamping)).
|
||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||
directory for all SQLite databases, exposes port 8080, and includes
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MaxUsernameBytes is the longest username, in bytes, that a user may
|
||||
// have. The same number appears in the check constraint on
|
||||
// User.Username, because a struct tag cannot reference a constant.
|
||||
//
|
||||
// A login stores the username in the session cookie, and both
|
||||
// securecookie and browsers refuse a cookie value past about 4096
|
||||
// bytes. That value is the session base64-encoded twice, so it holds
|
||||
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
|
||||
// timestamp and the session's other values take about 270 of those: a
|
||||
// username longer than about 2030 bytes can never log in. The limit is
|
||||
// about half that, so the session can carry more values later without
|
||||
// locking out an account whose username is already at the limit.
|
||||
const MaxUsernameBytes = 1024
|
||||
|
||||
// ErrUsernameTooLong is returned when a user is saved with a username
|
||||
// longer than MaxUsernameBytes.
|
||||
var ErrUsernameTooLong = errors.New("username is too long")
|
||||
|
||||
// User represents a user of the webhooker service
|
||||
//
|
||||
//nolint:lll // a struct tag cannot wrap
|
||||
type User struct {
|
||||
BaseModel
|
||||
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||
|
||||
// Relations
|
||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
|
||||
// User is created or saved, so those calls get ErrUsernameTooLong rather
|
||||
// than the database's constraint error. A column update such as
|
||||
// Update("username", ...) is caught only by the check constraint, as is
|
||||
// any path that writes the table without this model.
|
||||
func (u *User) BeforeSave(_ *gorm.DB) error {
|
||||
if len(u.Username) > MaxUsernameBytes {
|
||||
return fmt.Errorf(
|
||||
"%w: %d bytes, limit is %d",
|
||||
ErrUsernameTooLong,
|
||||
len(u.Username),
|
||||
MaxUsernameBytes,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
|
||||
// two-byte character. A check that counted characters rather than bytes
|
||||
// would see half the length and let the one-byte-longer name through.
|
||||
func usernameAtLimit() string {
|
||||
return strings.Repeat("é", database.MaxUsernameBytes/2)
|
||||
}
|
||||
|
||||
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
err := db.Create(&database.User{
|
||||
Username: usernameAtLimit() + "x",
|
||||
Password: "hash",
|
||||
}).Error
|
||||
|
||||
require.ErrorIs(t, err, database.ErrUsernameTooLong)
|
||||
}
|
||||
|
||||
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
require.NoError(t, db.Create(&database.User{
|
||||
Username: usernameAtLimit(),
|
||||
Password: "hash",
|
||||
}).Error)
|
||||
}
|
||||
|
||||
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
|
||||
// SQL, as a path that bypassed User.BeforeSave would, so only the
|
||||
// table's check constraint stands between it and an over-long
|
||||
// username. Accepting the name at the limit and refusing the next byte
|
||||
// also pins the constraint's number to MaxUsernameBytes.
|
||||
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
|
||||
|
||||
require.NoError(t, db.Exec(
|
||||
insert, uuid.New().String(), usernameAtLimit(), "hash",
|
||||
).Error)
|
||||
|
||||
err := db.Exec(
|
||||
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
|
||||
).Error
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "CHECK constraint failed")
|
||||
}
|
||||
@@ -453,3 +453,33 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
||||
"the issued cookie must carry an authenticated session",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
||||
// database.MaxUsernameBytes still fits in the session cookie. Past
|
||||
// what the cookie can carry, a correct login answers 500.
|
||||
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
username := strings.Repeat("a", database.MaxUsernameBytes)
|
||||
|
||||
hash, err := database.HashPassword(operatorPassword)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.DB().Create(&database.User{
|
||||
Username: username,
|
||||
Password: hash,
|
||||
}).Error)
|
||||
|
||||
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
@@ -53,6 +54,7 @@ type HandlersParams struct {
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
WebhookDBMgr *database.WebhookDBManager
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
@@ -129,6 +131,7 @@ func New(
|
||||
s.templates = map[string]*template.Template{
|
||||
"login.html": parsePageTemplate("login.html"),
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"settings.html": parsePageTemplate("settings.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||
|
||||
@@ -83,16 +83,25 @@ func newTestApp(
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return newTestAppWithConfig(
|
||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
||||
)
|
||||
}
|
||||
|
||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
||||
func newTestAppWithConfig(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
targets ...any,
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config {
|
||||
return &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
},
|
||||
func() *config.Config { return cfg },
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
|
||||
@@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple"
|
||||
// storedFillBytes is the raw length of the client-chosen value in
|
||||
// those accounts' usernames. It is well past the 512-byte field
|
||||
// budget, so the line is still truncated, but short enough that the
|
||||
// session cookie a successful login writes stays inside
|
||||
// securecookie's 4 KB limit: the cookie is written BEFORE the
|
||||
// "user logged in" line, so an 8 KB username answers 500 and never
|
||||
// reaches it.
|
||||
const storedFillBytes = 1024
|
||||
// whole username, markers and fill name included, stays within
|
||||
// database.MaxUsernameBytes.
|
||||
const storedFillBytes = 960
|
||||
|
||||
// storedFill builds a username fill of storedFillBytes raw bytes out
|
||||
// of repetitions of ch, with both markers at its far end.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
)
|
||||
|
||||
// notSet is what the Settings page shows for a value that is empty.
|
||||
const notSet = "not set"
|
||||
|
||||
// settingRow is one line of the Settings page: an environment
|
||||
// variable, what it controls, and the value the server loaded for it.
|
||||
type settingRow struct {
|
||||
Name string
|
||||
Description string
|
||||
Value string
|
||||
}
|
||||
|
||||
// HandleSettings returns a handler for the read-only Settings page,
|
||||
// which lists the configuration the server started with.
|
||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
||||
"Settings": settingRows(h.params.Config),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// settingRows lists every field of cfg under the environment variable
|
||||
// it is read from, in the order of the README's configuration table.
|
||||
// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values
|
||||
// never reach the page: only whether they are set.
|
||||
func settingRows(cfg *config.Config) []settingRow {
|
||||
metricsUsername := cfg.MetricsUsername
|
||||
if metricsUsername == "" {
|
||||
metricsUsername = notSet
|
||||
}
|
||||
|
||||
return []settingRow{
|
||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
||||
{
|
||||
"BIND_ADDRESS",
|
||||
"IP address the HTTP listener binds",
|
||||
cfg.BindAddress,
|
||||
},
|
||||
{
|
||||
"DATA_DIR",
|
||||
"Directory for all SQLite databases",
|
||||
cfg.DataDir,
|
||||
},
|
||||
{
|
||||
"DEBUG",
|
||||
"Enable debug logging",
|
||||
strconv.FormatBool(cfg.Debug),
|
||||
},
|
||||
{
|
||||
"MAINTENANCE_MODE",
|
||||
"Report maintenanceMode: true in the healthcheck JSON. " +
|
||||
"It does not change how any request is served",
|
||||
strconv.FormatBool(cfg.MaintenanceMode),
|
||||
},
|
||||
{
|
||||
"METRICS_USERNAME",
|
||||
"Basic auth username for /metrics",
|
||||
metricsUsername,
|
||||
},
|
||||
{
|
||||
"METRICS_PASSWORD",
|
||||
"Basic auth password for /metrics",
|
||||
setOrNotSet(cfg.MetricsPassword),
|
||||
},
|
||||
{
|
||||
"SENTRY_DSN",
|
||||
"Error reporting DSN. Unset leaves error reporting off",
|
||||
setOrNotSet(cfg.SentryDSN),
|
||||
},
|
||||
{
|
||||
"RETENTION_SWEEP_INTERVAL",
|
||||
"How often the retention reaper and archive sweeper run",
|
||||
cfg.RetentionSweepInterval.String(),
|
||||
},
|
||||
{
|
||||
"SESSION_IDLE_TIMEOUT",
|
||||
"Idle session timeout. Zero or negative disables idle " +
|
||||
"expiry",
|
||||
cfg.SessionIdleTimeout.String(),
|
||||
},
|
||||
{
|
||||
"RECEIVER_RATE_LIMIT",
|
||||
"Receiver requests per minute per IP per entrypoint " +
|
||||
"(10x that per IP across the route)",
|
||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
||||
},
|
||||
{
|
||||
"TRUSTED_PROXIES",
|
||||
"CIDRs whose forwarded headers are trusted",
|
||||
cidrList(cfg.TrustedProxies),
|
||||
},
|
||||
{
|
||||
"ALLOWED_EGRESS_CIDRS",
|
||||
"CIDRs that delivery targets may reach despite the " +
|
||||
"SSRF blocklist",
|
||||
cidrList(cfg.AllowedEgressCIDRs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
||||
// has a value, never the value itself.
|
||||
func setOrNotSet(value string) string {
|
||||
if value == "" {
|
||||
return notSet
|
||||
}
|
||||
|
||||
return "set"
|
||||
}
|
||||
|
||||
// cidrList renders a CIDR list setting for the Settings page.
|
||||
func cidrList(prefixes []netip.Prefix) string {
|
||||
if len(prefixes) == 0 {
|
||||
return "none"
|
||||
}
|
||||
|
||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
||||
// and returns the value it shows for each variable name, plus the
|
||||
// whole page.
|
||||
func settingsShown(
|
||||
t *testing.T, cfg *config.Config,
|
||||
) (map[string]string, string) {
|
||||
t.Helper()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
var sess *session.Session
|
||||
|
||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/settings", nil,
|
||||
)
|
||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleSettings().ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
row := regexp.MustCompile(
|
||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
||||
)
|
||||
|
||||
shown := map[string]string{}
|
||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
||||
shown[match[1]] = html.UnescapeString(match[2])
|
||||
}
|
||||
|
||||
return shown, body
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
metricsPassword = "metrics-password-1f9a"
|
||||
sentryKey = "dsnkey7c2e"
|
||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
||||
)
|
||||
|
||||
cfg := &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Debug: true,
|
||||
MaintenanceMode: true,
|
||||
Environment: config.EnvironmentDev,
|
||||
MetricsUsername: "scraper",
|
||||
MetricsPassword: metricsPassword,
|
||||
Port: 9123,
|
||||
SentryDSN: sentryDSN,
|
||||
BindAddress: "192.0.2.10",
|
||||
RetentionSweepInterval: 17 * time.Minute,
|
||||
SessionIdleTimeout: 3 * time.Hour,
|
||||
ReceiverRateLimit: 77,
|
||||
TrustedProxies: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.1.0.0/16"),
|
||||
},
|
||||
AllowedEgressCIDRs: []netip.Prefix{
|
||||
netip.MustParsePrefix("192.168.5.0/24"),
|
||||
netip.MustParsePrefix("fd00::/8"),
|
||||
},
|
||||
}
|
||||
|
||||
shown, body := settingsShown(t, cfg)
|
||||
|
||||
assert.Equal(t, map[string]string{
|
||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
||||
"PORT": "9123",
|
||||
"BIND_ADDRESS": "192.0.2.10",
|
||||
"DATA_DIR": cfg.DataDir,
|
||||
"DEBUG": "true",
|
||||
"MAINTENANCE_MODE": "true",
|
||||
"METRICS_USERNAME": "scraper",
|
||||
"METRICS_PASSWORD": "set",
|
||||
"SENTRY_DSN": "set",
|
||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||
"RECEIVER_RATE_LIMIT": "77",
|
||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||
}, shown)
|
||||
|
||||
assert.NotContains(t, body, metricsPassword)
|
||||
assert.NotContains(t, body, sentryKey)
|
||||
assert.Contains(
|
||||
t, body, `href="/settings"`,
|
||||
"the navigation bar links to the page",
|
||||
)
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shown, _ := settingsShown(t, &config.Config{DataDir: t.TempDir()})
|
||||
|
||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func (s *Server) setupRoutes() {
|
||||
|
||||
s.setupPageRoutes()
|
||||
s.setupUserRoutes()
|
||||
s.setupSettingsRoutes()
|
||||
s.setupSourceRoutes()
|
||||
s.setupWebhookRoutes()
|
||||
}
|
||||
@@ -181,6 +182,21 @@ func (s *Server) setupUserRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||
// configuration comes from the environment and nothing here changes
|
||||
// it.
|
||||
func (s *Server) setupSettingsRoutes() {
|
||||
s.router.Route("/settings", func(r chi.Router) {
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
// see maxFormBodySize for why, and for what it costs.
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.NoCache())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSettings())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
s.router.Route("/sources", func(r chi.Router) {
|
||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
w := env.get("/settings", nil)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
|
||||
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||
// 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.
|
||||
// Alpine.js is extracted from its tarball in 3p/ at build time, so the
|
||||
// file is not in the tree: this is the check that the page still gets
|
||||
// the JavaScript it asks for.
|
||||
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# script/assets: extract Alpine.js from its npm package tarball, committed
|
||||
# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The
|
||||
# extracted file is not committed. script/test, make build and make dev run
|
||||
# this first.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \
|
||||
>static/js/alpine.min.js
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+1
-8
@@ -4,9 +4,7 @@
|
||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). golangci-lint is deliberately not installed: linting runs
|
||||
# only in docker, via script/lint and Dockerfile.lint. Finishes by running
|
||||
# script/fetch-assets, which installs the hash-pinned third-party browser
|
||||
# assets the repo does not commit.
|
||||
# only in docker, via script/lint and Dockerfile.lint.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -69,11 +67,6 @@ main() {
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
# extension to scripts-to-rule-them-all.
|
||||
# Writes only the ignored static/js/alpine.min.js, through script/test.
|
||||
# Generic: usually needs no adaptation.
|
||||
set -eu
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/bin/sh
|
||||
# script/fetch-assets: download the third-party browser assets the web UI
|
||||
# ships and install them under static/. Minified bundles are not committed
|
||||
# (REPO_POLICIES.md: no build artifacts in version control), so the build
|
||||
# fetches them here. Every download is verified against a hardcoded sha256
|
||||
# before it is installed, and any mismatch aborts. Idempotent: an asset
|
||||
# already present with its pinned hash is left alone.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# The sha256 of each installed asset lives in static/vendor.sha256, in
|
||||
# sha256sum(1) format, with paths relative to static/. That file is the
|
||||
# single source of truth: this script verifies against it, and
|
||||
# static/vendor_test.go asserts the bytes embedded into the binary match
|
||||
# it, so the hash cannot rot into a value nothing checks.
|
||||
MANIFEST="static/vendor.sha256"
|
||||
|
||||
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
|
||||
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
|
||||
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
|
||||
# a <script> tag.
|
||||
ALPINE_VERSION="3.14.9"
|
||||
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
|
||||
# sha256 of alpinejs-3.14.9.tgz
|
||||
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
|
||||
ALPINE_MEMBER="package/dist/cdn.min.js"
|
||||
ALPINE_DEST="js/alpine.min.js"
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 "$1" | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
# expected_sha256 <path-relative-to-static>
|
||||
expected_sha256() {
|
||||
awk -v want="$1" '$2 == want { print $1; found = 1 }
|
||||
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
|
||||
}
|
||||
|
||||
# verify <file> <expected-sha256> <what>
|
||||
verify() {
|
||||
actual="$(sha256_of "$1")"
|
||||
if [ "$actual" != "$2" ]; then
|
||||
echo "fetch-assets: sha256 mismatch for $3" >&2
|
||||
echo " expected: $2" >&2
|
||||
echo " actual: $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# up_to_date <path-relative-to-static> <expected-sha256>
|
||||
up_to_date() {
|
||||
[ -f "$ROOT/static/$1" ] || return 1
|
||||
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
|
||||
}
|
||||
|
||||
fetch_alpine() {
|
||||
want="$(expected_sha256 "$ALPINE_DEST")"
|
||||
|
||||
if up_to_date "$ALPINE_DEST" "$want"; then
|
||||
echo "fetch-assets: static/$ALPINE_DEST already at $want"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
|
||||
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
|
||||
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
|
||||
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
|
||||
|
||||
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
|
||||
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
|
||||
rm -rf "$tmp"
|
||||
trap - EXIT INT TERM
|
||||
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
|
||||
}
|
||||
|
||||
# Re-check every manifest entry against what is now on disk, so an entry
|
||||
# no script installs fails loudly instead of passing silently.
|
||||
verify_manifest() {
|
||||
while read -r want path; do
|
||||
case "$want" in '' | '#'*) continue ;; esac
|
||||
if [ ! -f "$ROOT/static/$path" ]; then
|
||||
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
verify "$ROOT/static/$path" "$want" "static/$path"
|
||||
done <"$ROOT/$MANIFEST"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
fetch_alpine
|
||||
verify_manifest
|
||||
echo "fetch-assets: all assets in $MANIFEST verified"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -28,6 +28,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
"$ROOT/script/assets"
|
||||
go test -v -race -timeout 90s ./...
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
||||
@@ -1,92 +0,0 @@
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
const manifestPath = "vendor.sha256"
|
||||
|
||||
// fetchHint is appended to every failure here: the assets the manifest
|
||||
// covers are fetched by the build, not committed, so a fresh clone that
|
||||
// has not run script/fetch-assets fails this test and should be told why.
|
||||
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
|
||||
"the pinned third-party assets"
|
||||
|
||||
// TestVendoredAssetsMatchManifest asserts that every asset listed in
|
||||
// static/vendor.sha256 is embedded in the binary with exactly the pinned
|
||||
// bytes. script/fetch-assets verifies the same hashes at download time;
|
||||
// this test verifies them again on what actually ships, so a build that
|
||||
// skipped, cached, or subverted the fetch cannot produce a binary serving
|
||||
// unpinned third-party JavaScript.
|
||||
func TestVendoredAssetsMatchManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
entries := readManifest(t)
|
||||
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
|
||||
|
||||
for path, want := range entries {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := static.Static.ReadFile(path)
|
||||
require.NoErrorf(
|
||||
t, err,
|
||||
"%s is listed in %s but is not embedded; %s",
|
||||
path, manifestPath, fetchHint,
|
||||
)
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
got := hex.EncodeToString(sum[:])
|
||||
require.Equalf(
|
||||
t, want, got,
|
||||
"embedded %s does not match its pinned sha256 in %s; %s",
|
||||
path, manifestPath, fetchHint,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
|
||||
// format with paths relative to static/.
|
||||
func readManifest(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Open(manifestPath)
|
||||
require.NoError(t, err, "opening %s", manifestPath)
|
||||
|
||||
defer func() { require.NoError(t, f.Close()) }()
|
||||
|
||||
entries := make(map[string]string)
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
require.Lenf(
|
||||
t, fields, 2,
|
||||
"%s: malformed entry %q, want \"<sha256> <path>\"",
|
||||
manifestPath, line,
|
||||
)
|
||||
|
||||
sum, path := fields[0], fields[1]
|
||||
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
|
||||
entries[path] = sum
|
||||
}
|
||||
|
||||
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
|
||||
|
||||
return entries
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text">Webhooks</a>
|
||||
<a href="/settings" class="btn-text">Settings</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
@@ -39,6 +40,7 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
{{if .User}}
|
||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
||||
<a href="/settings" class="btn-text w-full text-left">Settings</a>
|
||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||
<form method="POST" action="/pages/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}Settings - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
||||
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Settings}}
|
||||
<div class="p-4">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
||||
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user