10 Commits

Author SHA1 Message Date
ed81db137e Implement the log delivery target (closes #70)
All checks were successful
check / check (push) Successful in 4s
2026-08-07 19:57:16 +07:00
752d6beead Validate Slack target URLs at creation time (closes #68) (#73)
All checks were successful
check / check (push) Successful in 4s
Slack delivery targets were only checked by the request-time dialer guard, not at creation, giving them a weaker SSRF gate than HTTP targets.

This validates the Slack incoming-webhook URL with `delivery.ValidateTargetURL` in the Slack target creation path (`buildSlackTargetConfig`), before persisting, mirroring the existing HTTP-target path. On failure the create is rejected with the same clear, non-leaking user-facing error the HTTP path uses.

Adds handlers-package tests covering both an accepted public URL and a rejected private/reserved URL. Confined to `internal/handlers/`; `internal/delivery/` is unchanged.

Closes #68

Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #73
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 14:03:56 +02:00
b1f43c9520 Keep the SSRF-safe transport in clientForConfig (closes #69) (#74)
All checks were successful
check / check (push) Superseded by a newer commit; never tested
`clientForConfig()` in `internal/delivery/engine.go` built a fresh `http.Client` without a Transport when a per-target timeout was configured, dropping the request-time private-IP guard for that path.

It now reuses the shared client's SSRF-safe transport (`e.client.Transport`, the same `NewSSRFSafeTransport` instance), overriding only the `Timeout`. Behaviour is unchanged when no per-target timeout is set (the shared client is returned as before), so no engine code path makes an outbound target request with a client lacking the SSRF-safe transport.

Adds a delivery-package test proving a client from `clientForConfig()` with a per-target timeout still refuses private/reserved/link-local destinations, that the timeout is applied, that the SSRF-safe transport is reused (not duplicated), and that the no-timeout path returns the shared client unchanged.

Confined to `internal/delivery/` only; handlers and server code untouched.

Closes #69

Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #74
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 14:03:38 +02:00
07fc63d9fa Wrap /user/{username} in RequireAuth middleware (closes #60) (#71)
All checks were successful
check / check (push) Successful in 4s
Enforces authentication for the `/user/{username}` route group at the middleware layer, matching every other authenticated route group.

## Changes

- **`internal/server/routes.go`** (`setupUserRoutes`): added `r.Use(s.mw.RequireAuth())` immediately after the existing `r.Use(s.mw.CSRF())` on the `/user/{username}` group, so auth is enforced by design (CSRF first, then RequireAuth) — consistent with `/sources` and `/source/{sourceID}`.
- **`internal/handlers/profile.go`** (`HandleProfile`): removed the now-dead unauthenticated-redirect branch (RequireAuth guarantees an authenticated session before the handler runs). The handler still reads the username and user id from the session for the own-profile-only check; a request for another user's profile still returns 403. The session-retrieval error is now handled as a 500.

## Tests (`internal/handlers/profile_test.go`)

- own profile returns 200
- another user's profile returns 403
- an unauthenticated request to `/user/{username}` is redirected to `/pages/login` at the middleware layer and never reaches the endpoint handler (routing-level test replicating the CSRF + RequireAuth chain)

## Validation

`docker build .` (fmt-check, lint, test, build) passes.

Closes #60

Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #71
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 14:00:16 +02:00
0c9c885d51 Raise HTTP WriteTimeout above the request middleware timeout (closes #62) (#72)
All checks were successful
check / check (push) Successful in 4s
Raise `httpWriteTimeout` in `internal/server/http.go` from 10s to `65 * time.Second` so it comfortably exceeds the router's 60s `requestTimeout`. This makes the `middleware.Timeout(60s)` the effective request limit — a slow response now returns a clean 503 from the middleware instead of being cut at the socket write deadline by the transport.

`httpReadTimeout` stays at 10s. A comment on `httpWriteTimeout` documents that it must remain above the 60s request timeout. Change is confined to `internal/server/http.go`; `routes.go` is untouched.

Closes #62

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #72
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 13:58:28 +02:00
2cc8723997 scripts-to-rule-them-all (#59)
All checks were successful
check / check (push) Successful in 4s
Reviewed-on: #59
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:14:09 +02:00
e0b1e7cf54 Restore TODO.md and move TODO content out of README (#58)
All checks were successful
check / check (push) Successful in 4s
Reviewed-on: #58
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-06 21:14:03 +02:00
afe88c601a refactor: use pinned golangci-lint Docker image for linting (#55)
All checks were successful
check / check (push) Successful in 5s
Closes [issue #50](#50)

## Summary

Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage.

## Changes

### Dockerfile
- **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage
- **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes
- **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256)
- **golangci-lint bumped** from v1.64.8 to v2.11.3
- All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest
- Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t)

### Linter Config (.golangci.yml)
- Migrated from v1 to v2 format (`version: "2"` added)
- Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2)
- Same set of linters enabled — no rules weakened

### Code Fixes (all lint issues from v2 upgrade)
- Added package comments to all packages
- Added doc comments to all exported types, functions, and methods
- Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint)
- Fixed unused parameters flagged by `revive` (renamed to `_`)
- Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls
- Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf`
- Fixed `staticcheck` QF1003: converted if/else chain to tagged switch
- Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`)
- Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt`
- Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores)

### README.md
- Updated version requirements: Go 1.26+, golangci-lint v2.11+
- Updated Dockerfile description in project structure

## Verification

`docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed.

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #55
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 02:16:38 +01:00
d771fe14df fix: detect TLS per-request in CSRF middleware to fix login (#54)
All checks were successful
check / check (push) Successful in 1m55s
## Problem

After the security hardening in PR #42, login fails with `Forbidden - invalid CSRF token` in production deployments.

The CSRF middleware tied its `PlaintextHTTPRequest` wrapping and cookie `Secure` flag to the `IsDev()` environment check. This meant production mode always assumed HTTPS via gorilla/csrf's strict mode, which broke login in common deployment scenarios:

1. **Production behind a TLS-terminating reverse proxy**: gorilla/csrf assumed HTTPS but `r.TLS` was nil (the Go server receives HTTP from the proxy). Origin/Referer scheme mismatches caused `referer not supplied` or `origin invalid` errors.

2. **Production over direct HTTP** (testing/staging with prod config): the `Secure` cookie flag prevented the browser from sending the CSRF cookie back over HTTP, causing `CSRF token invalid` errors.

## Root Cause

gorilla/csrf v1.7.3 defaults to HTTPS-strict mode unless `PlaintextHTTPRequest()` is called. In strict mode it:
- Forces `requestURL.Scheme = "https"` for Origin/Referer comparisons
- Requires a `Referer` header on POST and rejects `http://` Referer schemes
- The `csrf.Secure(true)` option makes the browser refuse to send the CSRF cookie over HTTP

The old code only called `PlaintextHTTPRequest()` in dev mode, leaving prod mode permanently stuck in HTTPS-strict mode regardless of the actual transport.

## Fix

Detect the actual transport protocol **per-request** using:
- `r.TLS != nil` — direct TLS connection to the Go server
- `X-Forwarded-Proto: https` header — TLS-terminating reverse proxy

Two gorilla/csrf middleware instances are maintained (one with `Secure: true`, one with `Secure: false`) since `csrf.Secure()` is a creation-time option. Both use the same signing key, so cookies are interchangeable.

| Scenario | Cookie Secure | Origin/Referer Mode |
|---|---|---|
| Direct TLS (`r.TLS != nil`) |  Secure | Strict (HTTPS scheme) |
| Behind TLS proxy (`X-Forwarded-Proto: https`) |  Secure | Strict (HTTPS scheme) |
| Plaintext HTTP |  Non-Secure | Relaxed (PlaintextHTTPRequest) |

CSRF token validation (cookie + form double-submit) is always enforced regardless of mode.

## Testing

- Added `TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken` — prod mode over plaintext HTTP
- Added `TestCSRF_ProdMode_BehindProxy_POSTWithValidToken` — prod mode behind TLS proxy
- Added `TestCSRF_ProdMode_DirectTLS_POSTWithValidToken` — prod mode with direct TLS
- Added `TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken` — token still required
- Added `TestIsClientTLS_*` — TLS detection unit tests
- All existing CSRF tests pass unchanged
- `docker build .` passes (includes `make check`)
- Manual verification: built and ran the container in both `dev` and `prod` modes, confirmed login succeeds in both

Closes #53

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #54
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-18 04:30:57 +01:00
33e2140a5a feat: redirect root path based on auth state (#52)
All checks were successful
check / check (push) Successful in 1m54s
Closes #51

The root path `/` now checks for an authenticated session and redirects accordingly:

- **Authenticated users** → `303 See Other` redirect to `/sources` (the webhook dashboard)
- **Unauthenticated users** → `303 See Other` redirect to `/pages/login`

### Changes

- **`internal/handlers/index.go`** — Replaced the template-rendering `HandleIndex()` with a session-checking redirect handler. Removed `formatUptime` helper (dead code after this change).
- **`internal/handlers/handlers.go`** — Removed `index.html` from the template map (no longer rendered).
- **`internal/handlers/handlers_test.go`** — Replaced the old "handler is not nil" test with two proper redirect tests:
  - `unauthenticated redirects to login` — verifies 303 to `/pages/login`
  - `authenticated redirects to sources` — sets up an authenticated session cookie, verifies 303 to `/sources`
  - Removed `TestFormatUptime` (tested dead code).
- **`README.md`** — Updated the API endpoints table to describe the new redirect behavior.

### How it works

The handler calls `session.Get(r)` and `session.IsAuthenticated(sess)` — the same pattern used by the `RequireAuth` middleware and `HandleLoginPage`. No new dependencies or session logic introduced.

The login flow is unaffected: `HandleLoginSubmit` redirects to `/` after successful login, which now forwards to `/sources` (one extra redirect hop, but correct and clean).

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #52
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-17 16:35:08 +01:00
75 changed files with 8972 additions and 4380 deletions

View File

@@ -12,4 +12,4 @@ jobs:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
- name: Build Docker image (runs make check)
run: docker build .
run: script/cibuild

View File

@@ -2,42 +2,31 @@ version: "2"
run:
timeout: 5m
tests: true
modules-download-mode: readonly
linters:
enable:
- revive
- govet
- errcheck
- staticcheck
- unused
- ineffassign
- gosec
- misspell
- unparam
- prealloc
- copyloopvar
- gocritic
- gochecknoinits
- gochecknoglobals
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
revive:
confidence: 0.8
govet:
enable:
- shadow
errcheck:
check-type-assertions: true
check-blank: true
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-rules:
# Exclude globals check for version variables in main
- path: cmd/webhooker/main.go
linters:
- gochecknoglobals
# Exclude globals check for version variables in globals package
- path: internal/globals/globals.go
linters:
- gochecknoglobals
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -1,22 +1,28 @@
.PHONY: 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_GOAL := check
bootstrap:
@script/bootstrap
setup:
@script/setup
test:
go test -v -race -timeout 30s ./...
@script/test
lint:
golangci-lint run --config .golangci.yml ./...
@script/lint
fmt:
gofmt -s -w .
@command -v goimports >/dev/null 2>&1 && goimports -w . || true
@script/fmt
fmt-check:
@test -z "$$(gofmt -s -l .)" || { echo "gofmt needed on:"; gofmt -s -l .; exit 1; }
@script/fmt-check
check: fmt-check lint test build
check:
@script/check
build:
go build -o bin/webhooker ./cmd/webhooker
@@ -32,15 +38,13 @@ deps:
go mod tidy
docker:
docker build -t webhooker:latest .
@script/docker
clean:
rm -rf bin/
hooks:
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "pre-commit hook installed"
@script/install-precommit
css:
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify

142
README.md
View File

@@ -38,14 +38,16 @@ make docker
### Development Commands
```bash
make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook
make fmt # Format code (gofmt + goimports)
make lint # Run golangci-lint
make test # Run tests with race detection
make check # fmt-check + lint + test + build (CI gate)
make check # test + lint + fmt-check (CI gate)
make build # Build binary to bin/webhooker
make dev # go run ./cmd/webhooker
make docker # Build Docker image
make hooks # Install git pre-commit hook that runs make check
make hooks # Install git pre-commit hook that runs script/precommit
```
### Configuration
@@ -62,6 +64,21 @@ or `prod` (default: `dev`). The setting controls several behaviors:
| CORS | Allows any origin (`*`) | Disabled (no-op) |
| Session cookie Secure | `false` (works over plain HTTP) | `true` (requires HTTPS) |
The CSRF cookie's `Secure` flag and Origin/Referer validation mode are
determined per-request based on the actual transport protocol, not the
environment setting. The middleware checks `r.TLS` (direct TLS) and the
`X-Forwarded-Proto` header (TLS-terminating reverse proxy) to decide:
- **Direct TLS or `X-Forwarded-Proto: https`**: Secure cookies, strict
Origin/Referer validation.
- **Plaintext HTTP**: Non-Secure cookies, relaxed Origin/Referer
checks (token validation still enforced).
This means CSRF protection works correctly in all deployment scenarios:
behind a TLS-terminating reverse proxy, with direct TLS, or over plain
HTTP during development. When running behind a reverse proxy, ensure it
sets the `X-Forwarded-Proto: https` header.
All other differences (log format, security headers, etc.) are
independent of the environment setting — log format is determined by
TTY detection, and security headers are always applied.
@@ -101,6 +118,31 @@ SQLite databases: the main application database (`webhooker.db`) and
the per-webhook event databases (`events-{uuid}.db`). Mount this as a
persistent volume to preserve data across container restarts.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (idempotent)
- `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit)
- `script/projectname` — output the project name ("webhooker")
- `script/test` — run the test suite
- `script/lint` — run golangci-lint
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`)
- `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## Rationale
Webhook integrations between services are inherently fragile. The
@@ -652,7 +694,7 @@ against a misbehaving sender).
| Method | Path | Description |
| ------ | --------------------------- | ----------- |
| `GET` | `/` | Web UI index page (server-rendered) |
| `GET` | `/` | Root redirect (authenticated → `/sources`, unauthenticated → `/pages/login`) |
| `GET` | `/.well-known/healthcheck` | Health check (JSON: status, uptime, version) |
| `GET` | `/s/*` | Static file serving (embedded CSS, JS) |
| `ANY` | `/webhook/{uuid}` | Webhook receiver endpoint (accepts all methods) |
@@ -841,7 +883,9 @@ Additionally, form endpoints (`/pages`, `/sources`, `/source/*`) apply a
on all state-changing forms (cookie-based double-submit tokens with
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
`/user` routes. Excluded from `/webhook` (inbound webhook POSTs) and
`/api` (stateless API)
`/api` (stateless API). The middleware auto-detects TLS status
per-request (via `r.TLS` and `X-Forwarded-Proto`) to set appropriate
cookie security flags and Origin/Referer validation mode
- **SSRF prevention** for HTTP delivery targets: private/reserved IP
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
both at target creation time (URL validation) and at delivery time
@@ -876,95 +920,7 @@ linted, tested, and compiled.
## TODO
### Completed: Code Quality (Phase 1 of MVP)
- [x] Rename Processor → Webhook, Webhook → Entrypoint in code
([#12](https://git.eeqj.de/sneak/webhooker/issues/12))
- [x] Embed templates via `//go:embed`
([#7](https://git.eeqj.de/sneak/webhooker/issues/7))
- [x] Use `slog.LevelVar` for dynamic log level switching
([#8](https://git.eeqj.de/sneak/webhooker/issues/8))
- [x] Simplify configuration to prefer environment variables
([#10](https://git.eeqj.de/sneak/webhooker/issues/10))
- [x] Remove redundant `godotenv/autoload` import
([#11](https://git.eeqj.de/sneak/webhooker/issues/11))
- [x] Implement authentication middleware for protected routes
([#9](https://git.eeqj.de/sneak/webhooker/issues/9))
- [x] Replace Bootstrap with Tailwind CSS + Alpine.js
([#4](https://git.eeqj.de/sneak/webhooker/issues/4))
### Completed: Core Webhook Engine (Phase 2 of MVP)
- [x] Implement webhook reception and event storage at `/webhook/{uuid}`
- [x] Build event processing and target delivery engine
- [x] Implement HTTP target type (fire-and-forget with max_retries=0,
retries with exponential backoff when max_retries>0)
- [x] Implement database target type (store events in per-webhook DB)
- [x] Implement log target type (console output)
- [x] Implement Slack target type (Slack/Mattermost incoming webhook
notifications with pretty-printed payloads)
- [x] Webhook management pages (list, create, edit, delete)
- [x] Webhook request log viewer with pagination
- [x] Entrypoint and target management UI
### Completed: Per-Webhook Event Databases
- [x] Split into main application DB + per-webhook event DBs
- [x] Per-webhook database lifecycle management (create on webhook
creation, delete on webhook removal)
- [x] `WebhookDBManager` component with lazy connection pooling
- [x] Event-driven delivery engine (channel notifications + timer-based retries)
- [x] Self-contained delivery tasks: in the ≤16KB happy path, the engine
delivers without reading from any database — target config, event
headers, and body are all carried inline in the channel notification.
The engine only touches the DB to record results (success/failure).
Large bodies (≥16KB) are fetched from the per-webhook DB on demand.
- [x] Database target type marks delivery as immediately successful
(events are already in the per-webhook DB)
- [x] Parallel fan-out: all targets for an event are delivered via
the bounded worker pool (no goroutine-per-target)
- [x] Circuit breaker for HTTP targets with retries: tracks consecutive
failures per target, opens after 5 failures (30s cooldown),
half-open probe to test recovery
### Completed: Security Hardening
- [x] Security headers middleware (HSTS, CSP, X-Frame-Options,
X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
([#34](https://git.eeqj.de/sneak/webhooker/issues/34))
- [x] Session regeneration on login to prevent session fixation
([#38](https://git.eeqj.de/sneak/webhooker/issues/38))
- [x] Request body size limits on form endpoints
([#39](https://git.eeqj.de/sneak/webhooker/issues/39))
### Remaining: Core Features
- [ ] Per-webhook rate limiting in the receiver handler
- [ ] Webhook signature verification (GitHub, Stripe formats)
- [x] CSRF protection for forms
([#35](https://git.eeqj.de/sneak/webhooker/issues/35))
- [x] SSRF prevention for HTTP delivery targets
([#36](https://git.eeqj.de/sneak/webhooker/issues/36))
- [x] Login rate limiting (per-IP brute-force protection)
([#37](https://git.eeqj.de/sneak/webhooker/issues/37))
- [ ] Session expiration and "remember me"
- [ ] Password change/reset flow
- [ ] API key authentication for programmatic access
- [ ] Manual event redelivery
- [ ] Analytics dashboard (success rates, response times)
- [ ] Delivery status and retry management UI
### Remaining: Event Maintenance
- [ ] Automatic event retention cleanup based on `retention_days`
### Remaining: REST API
- [ ] RESTful CRUD for webhooks, entrypoints, targets
- [ ] Event viewing and filtering endpoints
- [ ] Event redelivery endpoint
- [ ] OpenAPI specification
### Future
- [ ] Email delivery target type
- [ ] SNS, S3, Slack delivery targets
- [ ] Data transformations (e.g., webhook-to-Slack message formatting)
- [ ] JSONL file delivery with periodic S3 upload
- [ ] Webhook event search and filtering
- [ ] Multi-user with role-based access
See [TODO.md](TODO.md).
## License

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-02-22
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -34,10 +34,46 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
@@ -57,11 +93,83 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -69,9 +177,11 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -82,6 +192,42 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
@@ -98,6 +244,13 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
@@ -121,12 +274,76 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
@@ -145,11 +362,11 @@ style conventions are in separate documents:
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations tracking
table itself. Nothing else.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There
is no installed base to migrate. Edit `001_schema.sql` directly.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
@@ -181,6 +398,9 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`

77
TODO.md Normal file
View File

@@ -0,0 +1,77 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
pre-1.0. No git tags exist. main (afe88c6) is a working webhook proxy
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
policy compliance (#6), and pinned lint tooling (#55). Note: TODO.md was
deliberately deleted from this repo in f9a9569 (2026-03-01, #6); its
content was folded into the README TODO section, which this draft
reconstructs as of 2026-07-06.
# Next Step
Implement automatic event retention cleanup based on retention_days: a
periodic maintenance job that deletes Events, Deliveries, and
DeliveryResults older than the parent webhook's retention_days from each
per-webhook event database. The field exists on the Webhook model and
the README promises the behavior, but nothing enforces it, so event
databases currently grow without bound.
# Completed Steps
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
plain HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
with DNS rebinding defense, and per-IP login rate limiting (#42)
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
absolute dev DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on
login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages
(#32); removed globals.Buildarch (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
delivery engine with bounded worker pool and circuit breaker,
parallel fan-out, per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
into README (#6)
# Future Steps
- Manual event redelivery from the web UI (replay is a core promised
capability in the README rationale)
- Delivery status and retry management UI
- Per-webhook rate limiting in the receiver handler (per-webhook config
plus handler enforcement; global limits must not apply to receiver
endpoints)
- Webhook signature verification for GitHub and Stripe HMAC formats
- API key authentication for programmatic access (APIKey model exists;
Bearer token middleware does not)
- REST API v1
- CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints
- event redelivery endpoint
- OpenAPI specification
- Analytics dashboard: success rates, response times, volume
- Session expiration tuning and a remember-me option
- Password change and reset flow
- Later, nice to have
- email delivery target type
- SNS and S3 delivery targets
- data transformations (e.g. webhook to Slack message formatting)
- JSONL file delivery with periodic S3 upload
- webhook event search and filtering
- multi-user with role-based access control

4
go.mod
View File

@@ -1,8 +1,6 @@
module sneak.berlin/go/webhooker
go 1.24.0
toolchain go1.24.1
go 1.26.1
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8

View File

@@ -2,6 +2,7 @@
package config
import (
"errors"
"fmt"
"log/slog"
"os"
@@ -18,20 +19,29 @@ import (
)
const (
// EnvironmentDev represents development environment
// EnvironmentDev represents development environment.
EnvironmentDev = "dev"
// EnvironmentProd represents production environment
// EnvironmentProd represents production environment.
EnvironmentProd = "prod"
// defaultPort is the default HTTP listen port.
defaultPort = 8080
)
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
// contains an unrecognised value.
var ErrInvalidEnvironment = errors.New("invalid environment")
//nolint:revive // ConfigParams is a standard fx naming convention.
type ConfigParams struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
// Config holds all application configuration loaded from environment variables.
// Config holds all application configuration loaded from
// environment variables.
type Config struct {
DataDir string
Debug bool
@@ -45,39 +55,43 @@ type Config struct {
log *slog.Logger
}
// IsDev returns true if running in development environment
// IsDev returns true if running in development environment.
func (c *Config) IsDev() bool {
return c.Environment == EnvironmentDev
}
// IsProd returns true if running in production environment
// IsProd returns true if running in production environment.
func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd
}
// envString returns the value of the named environment variable, or
// an empty string if not set.
// envString returns the value of the named environment variable,
// or an empty string if not set.
func envString(key string) string {
return os.Getenv(key)
}
// envBool returns the value of the named environment variable parsed as a
// boolean. Returns defaultValue if not set.
// envBool returns the value of the named environment variable
// parsed as a boolean. Returns defaultValue if not set.
func envBool(key string, defaultValue bool) bool {
if v := os.Getenv(key); v != "" {
return strings.EqualFold(v, "true") || v == "1"
}
return defaultValue
}
// envInt returns the value of the named environment variable parsed as an
// integer. Returns defaultValue if not set or unparseable.
// envInt returns the value of the named environment variable
// parsed as an integer. Returns defaultValue if not set or
// unparseable.
func envInt(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
i, err := strconv.Atoi(v)
if err == nil {
return i
}
}
return defaultValue
}
@@ -87,16 +101,21 @@ func envInt(key string, defaultValue int) int {
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log := params.Logger.Get()
// Determine environment from WEBHOOKER_ENVIRONMENT env var, default to dev
// Determine environment from WEBHOOKER_ENVIRONMENT env var,
// default to dev
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentDev
}
// Validate environment
if environment != EnvironmentDev && environment != EnvironmentProd {
return nil, fmt.Errorf("WEBHOOKER_ENVIRONMENT must be either '%s' or '%s', got '%s'",
EnvironmentDev, EnvironmentProd, environment)
if environment != EnvironmentDev &&
environment != EnvironmentProd {
return nil, fmt.Errorf(
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
ErrInvalidEnvironment,
EnvironmentDev, EnvironmentProd, environment,
)
}
// Load configuration values from environment variables
@@ -107,15 +126,16 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
Environment: environment,
MetricsUsername: envString("METRICS_USERNAME"),
MetricsPassword: envString("METRICS_PASSWORD"),
Port: envInt("PORT", 8080),
Port: envInt("PORT", defaultPort),
SentryDSN: envString("SENTRY_DSN"),
log: log,
params: &params,
}
// Set default DataDir. All SQLite databases (main application DB
// and per-webhook event DBs) live here. The same default is used
// regardless of environment; override with DATA_DIR if needed.
// Set default DataDir. All SQLite databases (main application
// DB and per-webhook event DBs) live here. The same default is
// used regardless of environment; override with DATA_DIR if
// needed.
if s.DataDir == "" {
s.DataDir = "/var/lib/webhooker"
}
@@ -132,7 +152,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir,
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "",
"hasMetricsAuth",
s.MetricsUsername != "" && s.MetricsPassword != "",
)
return s, nil

View File

@@ -1,4 +1,4 @@
package config
package config_test
import (
"os"
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
@@ -23,117 +24,142 @@ func TestEnvironmentConfig(t *testing.T) {
}{
{
name: "default is dev",
envValue: "",
envVars: map[string]string{},
expectError: false,
isDev: true,
isProd: false,
},
{
name: "explicit dev",
envValue: "dev",
envVars: map[string]string{},
expectError: false,
isDev: true,
isProd: false,
},
{
name: "explicit prod",
envValue: "prod",
envVars: map[string]string{},
expectError: false,
isDev: false,
isProd: true,
},
{
name: "invalid environment",
envValue: "staging",
envVars: map[string]string{},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set environment variable if specified
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
if tt.envValue != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue)
t.Setenv(
"WEBHOOKER_ENVIRONMENT", tt.envValue,
)
} else {
require.NoError(t, os.Unsetenv("WEBHOOKER_ENVIRONMENT"))
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
}
// Set additional environment variables
for k, v := range tt.envVars {
t.Setenv(k, v)
}
if tt.expectError {
// Use regular fx.New for error cases since fxtest doesn't expose errors the same way
var cfg *Config
app := fx.New(
fx.NopLogger, // Suppress fx logs in tests
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
testEnvironmentConfigError(t)
} else {
// Use fxtest for success cases
var cfg *Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
testEnvironmentConfigSuccess(
t, tt.isDev, tt.isProd,
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(t, tt.isDev, cfg.IsDev())
assert.Equal(t, tt.isProd, cfg.IsProd())
}
})
}
}
func testEnvironmentConfigError(t *testing.T) {
t.Helper()
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
}
func testEnvironmentConfigSuccess(
t *testing.T,
isDev, isProd bool,
) {
t.Helper()
var cfg *config.Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(t, isDev, cfg.IsDev())
assert.Equal(t, isProd, cfg.IsProd())
}
func TestDefaultDataDir(t *testing.T) {
// Verify that when DATA_DIR is unset, the default is /var/lib/webhooker
// regardless of the environment setting.
for _, env := range []string{"", "dev", "prod"} {
name := env
if name == "" {
name = "unset"
}
t.Run("env="+name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
if env != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
} else {
require.NoError(t, os.Unsetenv("WEBHOOKER_ENVIRONMENT"))
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
}
require.NoError(t, os.Unsetenv("DATA_DIR"))
var cfg *Config
var cfg *config.Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
New,
config.New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(t, "/var/lib/webhooker", cfg.DataDir)
assert.Equal(
t, "/var/lib/webhooker", cfg.DataDir,
)
})
}
}

View File

@@ -11,9 +11,9 @@ import (
// This replaces gorm.Model but uses UUID instead of uint for ID
type BaseModel struct {
ID string `gorm:"type:uuid;primary_key" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt,omitzero"`
}
// BeforeCreate hook to set UUID before creating a record.
@@ -21,5 +21,6 @@ func (b *BaseModel) BeforeCreate(_ *gorm.DB) error {
if b.ID == "" {
b.ID = uuid.New().String()
}
return nil
}

View File

@@ -20,9 +20,16 @@ import (
"sneak.berlin/go/webhooker/internal/logger"
)
const (
dataDirPerm = 0750
randomPasswordLen = 16
sessionKeyLen = 32
)
//nolint:revive // DatabaseParams is a standard fx naming convention.
type DatabaseParams struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
@@ -35,17 +42,20 @@ type Database struct {
}
// New creates a Database that connects on fx start and disconnects on stop.
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
func New(
lc fx.Lifecycle,
params DatabaseParams,
) (*Database, error) {
d := &Database{
params: &params,
log: params.Logger.Get(),
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
OnStart: func(_ context.Context) error {
return d.connect()
},
OnStop: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
OnStop: func(_ context.Context) error {
return d.close()
},
})
@@ -53,105 +63,6 @@ func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
return d, nil
}
func (d *Database) connect() error {
// Ensure the data directory exists before opening the database.
dataDir := d.params.Config.DataDir
if err := os.MkdirAll(dataDir, 0750); err != nil {
return fmt.Errorf("creating data directory %s: %w", dataDir, err)
}
// Construct the main application database path inside DATA_DIR.
dbPath := filepath.Join(dataDir, "webhooker.db")
dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", dbPath)
// Open the database with the pure Go SQLite driver
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
d.log.Error("failed to open database", "error", err)
return err
}
// Then use it with GORM
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
d.log.Error("failed to connect to database", "error", err)
return err
}
d.db = db
d.log.Info("connected to database", "path", dbPath)
// Run migrations
return d.migrate()
}
func (d *Database) migrate() error {
// Run GORM auto-migrations
if err := d.Migrate(); err != nil {
d.log.Error("failed to run database migrations", "error", err)
return err
}
d.log.Info("database migrations completed")
// Check if admin user exists
var userCount int64
if err := d.db.Model(&User{}).Count(&userCount).Error; err != nil {
d.log.Error("failed to count users", "error", err)
return err
}
if userCount == 0 {
// Create admin user
d.log.Info("no users found, creating admin user")
// Generate random password
password, err := GenerateRandomPassword(16)
if err != nil {
d.log.Error("failed to generate random password", "error", err)
return err
}
// Hash the password
hashedPassword, err := HashPassword(password)
if err != nil {
d.log.Error("failed to hash password", "error", err)
return err
}
// Create admin user
adminUser := &User{
Username: "admin",
Password: hashedPassword,
}
if err := d.db.Create(adminUser).Error; err != nil {
d.log.Error("failed to create admin user", "error", err)
return err
}
d.log.Info("admin user created",
"username", "admin",
"password", password,
"message", "SAVE THIS PASSWORD - it will not be shown again!",
)
}
return nil
}
func (d *Database) close() error {
if d.db != nil {
sqlDB, err := d.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
// DB returns the underlying GORM database handle.
func (d *Database) DB() *gorm.DB {
return d.db
@@ -162,29 +73,201 @@ func (d *Database) DB() *gorm.DB {
// 32-byte key is generated, base64-encoded, and stored for future use.
func (d *Database) GetOrCreateSessionKey() (string, error) {
var setting Setting
result := d.db.Where(&Setting{Key: "session_key"}).First(&setting)
result := d.db.Where(
&Setting{Key: "session_key"},
).First(&setting)
if result.Error == nil {
return setting.Value, nil
}
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return "", fmt.Errorf("failed to query session key: %w", result.Error)
return "", fmt.Errorf(
"failed to query session key: %w",
result.Error,
)
}
// Generate a new cryptographically secure 32-byte key
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return "", fmt.Errorf("failed to generate session key: %w", err)
keyBytes := make([]byte, sessionKeyLen)
_, err := rand.Read(keyBytes)
if err != nil {
return "", fmt.Errorf(
"failed to generate session key: %w",
err,
)
}
encoded := base64.StdEncoding.EncodeToString(keyBytes)
setting = Setting{
Key: "session_key",
Value: encoded,
}
if err := d.db.Create(&setting).Error; err != nil {
return "", fmt.Errorf("failed to store session key: %w", err)
err = d.db.Create(&setting).Error
if err != nil {
return "", fmt.Errorf(
"failed to store session key: %w",
err,
)
}
d.log.Info("generated new session key and stored in database")
d.log.Info(
"generated new session key and stored in database",
)
return encoded, nil
}
func (d *Database) connect() error {
// Ensure the data directory exists before opening the database.
dataDir := d.params.Config.DataDir
err := os.MkdirAll(dataDir, dataDirPerm)
if err != nil {
return fmt.Errorf(
"creating data directory %s: %w",
dataDir,
err,
)
}
// Construct the main application database path inside DATA_DIR.
dbPath := filepath.Join(dataDir, "webhooker.db")
dbURL := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc",
dbPath,
)
// Open the database with the pure Go SQLite driver
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
d.log.Error(
"failed to open database",
"error", err,
)
return err
}
// Then use it with GORM
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
d.log.Error(
"failed to connect to database",
"error", err,
)
return err
}
d.db = db
d.log.Info("connected to database", "path", dbPath)
// Run migrations
return d.migrate()
}
func (d *Database) migrate() error {
// Run GORM auto-migrations
err := d.Migrate()
if err != nil {
d.log.Error(
"failed to run database migrations",
"error", err,
)
return err
}
d.log.Info("database migrations completed")
// Check if admin user exists
var userCount int64
err = d.db.Model(&User{}).Count(&userCount).Error
if err != nil {
d.log.Error(
"failed to count users",
"error", err,
)
return err
}
if userCount == 0 {
return d.createAdminUser()
}
return nil
}
func (d *Database) createAdminUser() error {
d.log.Info("no users found, creating admin user")
// Generate random password
password, err := GenerateRandomPassword(
randomPasswordLen,
)
if err != nil {
d.log.Error(
"failed to generate random password",
"error", err,
)
return err
}
// Hash the password
hashedPassword, err := HashPassword(password)
if err != nil {
d.log.Error(
"failed to hash password",
"error", err,
)
return err
}
// Create admin user
adminUser := &User{
Username: "admin",
Password: hashedPassword,
}
err = d.db.Create(adminUser).Error
if err != nil {
d.log.Error(
"failed to create admin user",
"error", err,
)
return err
}
d.log.Info("admin user created",
"username", "admin",
"password", password,
"message",
"SAVE THIS PASSWORD - it will not be shown again!",
)
return nil
}
func (d *Database) close() error {
if d.db != nil {
sqlDB, err := d.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}

View File

@@ -1,4 +1,4 @@
package database
package database_test
import (
"context"
@@ -6,37 +6,37 @@ import (
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
func TestDatabaseConnection(t *testing.T) {
// Set up test dependencies
func setupTestDB(
t *testing.T,
) (*database.Database, *fxtest.Lifecycle) {
t.Helper()
lc := fxtest.NewLifecycle(t)
// Create globals
globals.Appname = "webhooker-test"
globals.Version = "test"
g, err := globals.New(lc)
if err != nil {
t.Fatalf("Failed to create globals: %v", err)
g := &globals.Globals{
Appname: "webhooker-test",
Version: "test",
}
// Create logger
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
l, err := logger.New(
lc,
logger.LoggerParams{Globals: g},
)
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}
// Create config with DataDir pointing to a temp directory
c := &config.Config{
DataDir: t.TempDir(),
Environment: "dev",
}
// Create database
db, err := New(lc, DatabaseParams{
db, err := database.New(lc, database.DatabaseParams{
Config: c,
Logger: l,
})
@@ -44,31 +44,45 @@ func TestDatabaseConnection(t *testing.T) {
t.Fatalf("Failed to create database: %v", err)
}
// Start lifecycle (this will trigger the connection)
return db, lc
}
func TestDatabaseConnection(t *testing.T) {
t.Parallel()
db, lc := setupTestDB(t)
ctx := context.Background()
err = lc.Start(ctx)
err := lc.Start(ctx)
if err != nil {
t.Fatalf("Failed to connect to database: %v", err)
}
defer func() {
if stopErr := lc.Stop(ctx); stopErr != nil {
t.Errorf("Failed to stop lifecycle: %v", stopErr)
stopErr := lc.Stop(ctx)
if stopErr != nil {
t.Errorf(
"Failed to stop lifecycle: %v",
stopErr,
)
}
}()
// Verify we can get the DB instance
if db.DB() == nil {
t.Error("Expected non-nil database connection")
}
// Test that we can perform a simple query
var result int
err = db.DB().Raw("SELECT 1").Scan(&result).Error
if err != nil {
t.Fatalf("Failed to execute test query: %v", err)
}
if result != 1 {
t.Errorf("Expected query result to be 1, got %d", result)
t.Errorf(
"Expected query result to be 1, got %d",
result,
)
}
}

View File

@@ -6,11 +6,11 @@ import "time"
type APIKey struct {
BaseModel
UserID string `gorm:"type:uuid;not null" json:"user_id"`
UserID string `gorm:"type:uuid;not null" json:"userId"`
Key string `gorm:"uniqueIndex;not null" json:"key"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations
User User `json:"user,omitempty"`
User User `json:"user,omitzero"`
}

View File

@@ -15,12 +15,12 @@ const (
type Delivery struct {
BaseModel
EventID string `gorm:"type:uuid;not null" json:"event_id"`
TargetID string `gorm:"type:uuid;not null" json:"target_id"`
EventID string `gorm:"type:uuid;not null" json:"eventId"`
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
// Relations
Event Event `json:"event,omitempty"`
Target Target `json:"target,omitempty"`
DeliveryResults []DeliveryResult `json:"delivery_results,omitempty"`
Event Event `json:"event,omitzero"`
Target Target `json:"target,omitzero"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
}

View File

@@ -4,14 +4,14 @@ package database
type DeliveryResult struct {
BaseModel
DeliveryID string `gorm:"type:uuid;not null" json:"delivery_id"`
AttemptNum int `gorm:"not null" json:"attempt_num"`
DeliveryID string `gorm:"type:uuid;not null" json:"deliveryId"`
AttemptNum int `gorm:"not null" json:"attemptNum"`
Success bool `json:"success"`
StatusCode int `json:"status_code,omitempty"`
ResponseBody string `gorm:"type:text" json:"response_body,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
ResponseBody string `gorm:"type:text" json:"responseBody,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"` // Duration in milliseconds
Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations
Delivery Delivery `json:"delivery,omitempty"`
Delivery Delivery `json:"delivery,omitzero"`
}

View File

@@ -4,11 +4,11 @@ package database
type Entrypoint struct {
BaseModel
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint
Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"`
// Relations
Webhook Webhook `json:"webhook,omitempty"`
Webhook Webhook `json:"webhook,omitzero"`
}

View File

@@ -4,17 +4,17 @@ package database
type Event struct {
BaseModel
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypoint_id"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
// Request data
Method string `gorm:"not null" json:"method"`
Headers string `gorm:"type:text" json:"headers"` // JSON
Body string `gorm:"type:text" json:"body"`
ContentType string `json:"content_type"`
ContentType string `json:"contentType"`
// Relations
Webhook Webhook `json:"webhook,omitempty"`
Entrypoint Entrypoint `json:"entrypoint,omitempty"`
Webhook Webhook `json:"webhook,omitzero"`
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}

View File

@@ -15,7 +15,7 @@ const (
type Target struct {
BaseModel
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
Name string `gorm:"not null" json:"name"`
Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"`
@@ -24,10 +24,10 @@ type Target struct {
Config string `gorm:"type:text" json:"config"` // JSON configuration
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff)
MaxRetries int `json:"max_retries,omitempty"`
MaxQueueSize int `json:"max_queue_size,omitempty"`
MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations
Webhook Webhook `json:"webhook,omitempty"`
Webhook Webhook `json:"webhook,omitzero"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}

View File

@@ -9,5 +9,5 @@ type User struct {
// Relations
Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"api_keys,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"`
}

View File

@@ -4,13 +4,13 @@ package database
type Webhook struct {
BaseModel
UserID string `gorm:"type:uuid;not null" json:"user_id"`
UserID string `gorm:"type:uuid;not null" json:"userId"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events
RetentionDays int `gorm:"default:30" json:"retentionDays"` // Days to retain events
// Relations
User User `json:"user,omitempty"`
User User `json:"user,omitzero"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"`
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"math/big"
"strings"
@@ -20,6 +21,23 @@ const (
argon2SaltLen = 16
)
// hashParts is the expected number of $-separated segments
// in an encoded Argon2id hash string.
const hashParts = 6
// minPasswordComplexityLen is the minimum password length that
// triggers per-character-class complexity enforcement.
const minPasswordComplexityLen = 4
// Sentinel errors returned by decodeHash.
var (
errInvalidHashFormat = errors.New("invalid hash format")
errInvalidAlgorithm = errors.New("invalid algorithm")
errIncompatibleVersion = errors.New("incompatible argon2 version")
errSaltLengthOutOfRange = errors.New("salt length out of range")
errHashLengthOutOfRange = errors.New("hash length out of range")
)
// PasswordConfig holds Argon2 configuration
type PasswordConfig struct {
Time uint32
@@ -46,26 +64,44 @@ func HashPassword(password string) (string, error) {
// Generate a salt
salt := make([]byte, config.SaltLen)
if _, err := rand.Read(salt); err != nil {
_, err := rand.Read(salt)
if err != nil {
return "", err
}
// Generate the hash
hash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
hash := argon2.IDKey(
[]byte(password),
salt,
config.Time,
config.Memory,
config.Threads,
config.KeyLen,
)
// Encode the hash and parameters
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
// Format: $argon2id$v=19$m=65536,t=1,p=4$salt$hash
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, config.Memory, config.Time, config.Threads, b64Salt, b64Hash)
encoded := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
config.Memory,
config.Time,
config.Threads,
b64Salt,
b64Hash,
)
return encoded, nil
}
// VerifyPassword checks if the provided password matches the hash
func VerifyPassword(password, encodedHash string) (bool, error) {
func VerifyPassword(
password, encodedHash string,
) (bool, error) {
// Extract parameters and hash from encoded string
config, salt, hash, err := decodeHash(encodedHash)
if err != nil {
@@ -73,60 +109,119 @@ func VerifyPassword(password, encodedHash string) (bool, error) {
}
// Generate hash of the provided password
otherHash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
otherHash := argon2.IDKey(
[]byte(password),
salt,
config.Time,
config.Memory,
config.Threads,
config.KeyLen,
)
// Compare hashes using constant time comparison
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
}
// decodeHash extracts parameters, salt, and hash from an encoded hash string
func decodeHash(encodedHash string) (*PasswordConfig, []byte, []byte, error) {
// decodeHash extracts parameters, salt, and hash from an
// encoded hash string.
func decodeHash(
encodedHash string,
) (*PasswordConfig, []byte, []byte, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, fmt.Errorf("invalid hash format")
if len(parts) != hashParts {
return nil, nil, nil, errInvalidHashFormat
}
if parts[1] != "argon2id" {
return nil, nil, nil, fmt.Errorf("invalid algorithm")
return nil, nil, nil, errInvalidAlgorithm
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
version, err := parseVersion(parts[2])
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, fmt.Errorf("incompatible argon2 version")
return nil, nil, nil, errIncompatibleVersion
}
config := &PasswordConfig{}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &config.Memory, &config.Time, &config.Threads); err != nil {
return nil, nil, nil, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
config, err := parseParams(parts[3])
if err != nil {
return nil, nil, nil, err
}
saltLen := len(salt)
if saltLen < 0 || saltLen > int(^uint32(0)) {
return nil, nil, nil, fmt.Errorf("salt length out of range")
}
config.SaltLen = uint32(saltLen) // nolint:gosec // checked above
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
salt, err := decodeSalt(parts[4])
if err != nil {
return nil, nil, nil, err
}
hashLen := len(hash)
if hashLen < 0 || hashLen > int(^uint32(0)) {
return nil, nil, nil, fmt.Errorf("hash length out of range")
config.SaltLen = uint32(len(salt)) //nolint:gosec // validated in decodeSalt
hash, err := decodeHashBytes(parts[5])
if err != nil {
return nil, nil, nil, err
}
config.KeyLen = uint32(hashLen) // nolint:gosec // checked above
config.KeyLen = uint32(len(hash)) //nolint:gosec // validated in decodeHashBytes
return config, salt, hash, nil
}
// GenerateRandomPassword generates a cryptographically secure random password
func parseVersion(s string) (int, error) {
var version int
_, err := fmt.Sscanf(s, "v=%d", &version)
if err != nil {
return 0, fmt.Errorf("parsing version: %w", err)
}
return version, nil
}
func parseParams(s string) (*PasswordConfig, error) {
config := &PasswordConfig{}
_, err := fmt.Sscanf(
s, "m=%d,t=%d,p=%d",
&config.Memory, &config.Time, &config.Threads,
)
if err != nil {
return nil, fmt.Errorf("parsing params: %w", err)
}
return config, nil
}
func decodeSalt(s string) ([]byte, error) {
salt, err := base64.RawStdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("decoding salt: %w", err)
}
saltLen := len(salt)
if saltLen < 0 || saltLen > int(^uint32(0)) {
return nil, errSaltLengthOutOfRange
}
return salt, nil
}
func decodeHashBytes(s string) ([]byte, error) {
hash, err := base64.RawStdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("decoding hash: %w", err)
}
hashLen := len(hash)
if hashLen < 0 || hashLen > int(^uint32(0)) {
return nil, errHashLengthOutOfRange
}
return hash, nil
}
// GenerateRandomPassword generates a cryptographically secure
// random password.
func GenerateRandomPassword(length int) (string, error) {
const (
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -141,27 +236,27 @@ func GenerateRandomPassword(length int) (string, error) {
// Create password slice
password := make([]byte, length)
// Ensure at least one character from each set for password complexity
if length >= 4 {
// Get one character from each set
// Ensure at least one character from each set
if length >= minPasswordComplexityLen {
password[0] = uppercase[cryptoRandInt(len(uppercase))]
password[1] = lowercase[cryptoRandInt(len(lowercase))]
password[2] = digits[cryptoRandInt(len(digits))]
password[3] = special[cryptoRandInt(len(special))]
// Fill the rest randomly from all characters
for i := 4; i < length; i++ {
for i := minPasswordComplexityLen; i < length; i++ {
password[i] = allChars[cryptoRandInt(len(allChars))]
}
// Shuffle the password to avoid predictable pattern
for i := len(password) - 1; i > 0; i-- {
j := cryptoRandInt(i + 1)
password[i], password[j] = password[j], password[i]
for i := range len(password) - 1 {
j := cryptoRandInt(len(password) - i)
idx := len(password) - 1 - i
password[idx], password[j] = password[j], password[idx]
}
} else {
// For very short passwords, just use all characters
for i := 0; i < length; i++ {
for i := range length {
password[i] = allChars[cryptoRandInt(len(allChars))]
}
}
@@ -169,16 +264,17 @@ func GenerateRandomPassword(length int) (string, error) {
return string(password), nil
}
// cryptoRandInt generates a cryptographically secure random integer in [0, upperBound).
// cryptoRandInt generates a cryptographically secure random
// integer in [0, upperBound).
func cryptoRandInt(upperBound int) int {
if upperBound <= 0 {
panic("upperBound must be positive")
}
// Calculate the maximum valid value to avoid modulo bias
// For example, if upperBound=200 and we have 256 possible values,
// we only accept values 0-199 (reject 200-255)
nBig, err := rand.Int(rand.Reader, big.NewInt(int64(upperBound)))
nBig, err := rand.Int(
rand.Reader,
big.NewInt(int64(upperBound)),
)
if err != nil {
panic(fmt.Sprintf("crypto/rand error: %v", err))
}

View File

@@ -1,11 +1,15 @@
package database
package database_test
import (
"strings"
"testing"
"sneak.berlin/go/webhooker/internal/database"
)
func TestGenerateRandomPassword(t *testing.T) {
t.Parallel()
tests := []struct {
name string
length int
@@ -18,109 +22,172 @@ func TestGenerateRandomPassword(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
password, err := GenerateRandomPassword(tt.length)
t.Parallel()
password, err := database.GenerateRandomPassword(
tt.length,
)
if err != nil {
t.Fatalf("GenerateRandomPassword() error = %v", err)
t.Fatalf(
"GenerateRandomPassword() error = %v",
err,
)
}
if len(password) != tt.length {
t.Errorf("Password length = %v, want %v", len(password), tt.length)
t.Errorf(
"Password length = %v, want %v",
len(password), tt.length,
)
}
// For passwords >= 4 chars, check complexity
if tt.length >= 4 {
hasUpper := false
hasLower := false
hasDigit := false
hasSpecial := false
for _, char := range password {
switch {
case char >= 'A' && char <= 'Z':
hasUpper = true
case char >= 'a' && char <= 'z':
hasLower = true
case char >= '0' && char <= '9':
hasDigit = true
case strings.ContainsRune("!@#$%^&*()_+-=[]{}|;:,.<>?", char):
hasSpecial = true
}
}
if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
t.Errorf("Password lacks required complexity: upper=%v, lower=%v, digit=%v, special=%v",
hasUpper, hasLower, hasDigit, hasSpecial)
}
}
checkPasswordComplexity(
t, password, tt.length,
)
})
}
}
func checkPasswordComplexity(
t *testing.T,
password string,
length int,
) {
t.Helper()
// For passwords >= 4 chars, check complexity
if length < 4 {
return
}
flags := classifyChars(password)
if !flags[0] || !flags[1] || !flags[2] || !flags[3] {
t.Errorf(
"Password lacks required complexity: "+
"upper=%v, lower=%v, digit=%v, special=%v",
flags[0], flags[1], flags[2], flags[3],
)
}
}
func classifyChars(s string) [4]bool {
var flags [4]bool // upper, lower, digit, special
for _, char := range s {
switch {
case char >= 'A' && char <= 'Z':
flags[0] = true
case char >= 'a' && char <= 'z':
flags[1] = true
case char >= '0' && char <= '9':
flags[2] = true
case strings.ContainsRune(
"!@#$%^&*()_+-=[]{}|;:,.<>?",
char,
):
flags[3] = true
}
}
return flags
}
func TestGenerateRandomPasswordUniqueness(t *testing.T) {
t.Parallel()
// Generate multiple passwords and ensure they're different
passwords := make(map[string]bool)
const numPasswords = 100
for i := 0; i < numPasswords; i++ {
password, err := GenerateRandomPassword(16)
for range numPasswords {
password, err := database.GenerateRandomPassword(16)
if err != nil {
t.Fatalf("GenerateRandomPassword() error = %v", err)
t.Fatalf(
"GenerateRandomPassword() error = %v",
err,
)
}
if passwords[password] {
t.Errorf("Duplicate password generated: %s", password)
t.Errorf(
"Duplicate password generated: %s",
password,
)
}
passwords[password] = true
}
}
func TestHashPassword(t *testing.T) {
t.Parallel()
password := "testPassword123!"
hash, err := HashPassword(password)
hash, err := database.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
// Check that hash has correct format
if !strings.HasPrefix(hash, "$argon2id$") {
t.Errorf("Hash doesn't have correct prefix: %s", hash)
t.Errorf(
"Hash doesn't have correct prefix: %s",
hash,
)
}
// Verify password
valid, err := VerifyPassword(password, hash)
valid, err := database.VerifyPassword(password, hash)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !valid {
t.Error("VerifyPassword() returned false for correct password")
t.Error(
"VerifyPassword() returned false " +
"for correct password",
)
}
// Verify wrong password fails
valid, err = VerifyPassword("wrongPassword", hash)
valid, err = database.VerifyPassword(
"wrongPassword", hash,
)
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if valid {
t.Error("VerifyPassword() returned true for wrong password")
t.Error(
"VerifyPassword() returned true " +
"for wrong password",
)
}
}
func TestHashPasswordUniqueness(t *testing.T) {
t.Parallel()
password := "testPassword123!"
// Same password should produce different hashes due to salt
hash1, err := HashPassword(password)
// Same password should produce different hashes
hash1, err := database.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
hash2, err := HashPassword(password)
hash2, err := database.HashPassword(password)
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
if hash1 == hash2 {
t.Error("Same password produced identical hashes (salt not working)")
t.Error(
"Same password produced identical hashes " +
"(salt not working)",
)
}
}

View File

@@ -3,6 +3,7 @@ package database
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
@@ -16,87 +17,82 @@ import (
"sneak.berlin/go/webhooker/internal/logger"
)
// nolint:revive // WebhookDBManagerParams is a standard fx naming convention
// WebhookDBManagerParams holds the fx dependencies for
// WebhookDBManager.
type WebhookDBManagerParams struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
// WebhookDBManager manages per-webhook SQLite database files for event storage.
// Each webhook gets its own dedicated database containing Events, Deliveries,
// and DeliveryResults. Database connections are opened lazily and cached.
// errInvalidCachedDBType indicates a type assertion failure
// when retrieving a cached database connection.
var errInvalidCachedDBType = errors.New(
"invalid cached database type",
)
// WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, and DeliveryResults.
// Database connections are opened lazily and cached.
type WebhookDBManager struct {
dataDir string
dbs sync.Map // map[webhookID]*gorm.DB
log *slog.Logger
}
// NewWebhookDBManager creates a new WebhookDBManager and registers lifecycle hooks.
func NewWebhookDBManager(lc fx.Lifecycle, params WebhookDBManagerParams) (*WebhookDBManager, error) {
// NewWebhookDBManager creates a new WebhookDBManager and
// registers lifecycle hooks.
func NewWebhookDBManager(
lc fx.Lifecycle,
params WebhookDBManagerParams,
) (*WebhookDBManager, error) {
m := &WebhookDBManager{
dataDir: params.Config.DataDir,
log: params.Logger.Get(),
}
// Create data directory if it doesn't exist
if err := os.MkdirAll(m.dataDir, 0750); err != nil {
return nil, fmt.Errorf("creating data directory %s: %w", m.dataDir, err)
err := os.MkdirAll(m.dataDir, dataDirPerm)
if err != nil {
return nil, fmt.Errorf(
"creating data directory %s: %w",
m.dataDir,
err,
)
}
lc.Append(fx.Hook{
OnStop: func(_ context.Context) error { //nolint:revive // ctx unused but required by fx
OnStop: func(_ context.Context) error {
return m.CloseAll()
},
})
m.log.Info("webhook database manager initialized", "data_dir", m.dataDir)
m.log.Info(
"webhook database manager initialized",
"data_dir", m.dataDir,
)
return m, nil
}
// dbPath returns the filesystem path for a webhook's database file.
func (m *WebhookDBManager) dbPath(webhookID string) string {
return filepath.Join(m.dataDir, fmt.Sprintf("events-%s.db", webhookID))
}
// openDB opens (or creates) a per-webhook SQLite database and runs migrations.
func (m *WebhookDBManager) openDB(webhookID string) (*gorm.DB, error) {
path := m.dbPath(webhookID)
dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", path)
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
return nil, fmt.Errorf("opening webhook database %s: %w", webhookID, err)
}
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("connecting to webhook database %s: %w", webhookID, err)
}
// Run migrations for event-tier models only
if err := db.AutoMigrate(&Event{}, &Delivery{}, &DeliveryResult{}); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("migrating webhook database %s: %w", webhookID, err)
}
m.log.Info("opened per-webhook database", "webhook_id", webhookID, "path", path)
return db, nil
}
// GetDB returns the database connection for a webhook, creating the database
// file lazily if it doesn't exist. This handles both new webhooks and existing
// webhooks that were created before per-webhook databases were introduced.
func (m *WebhookDBManager) GetDB(webhookID string) (*gorm.DB, error) {
// GetDB returns the database connection for a webhook,
// creating the database file lazily if it doesn't exist.
func (m *WebhookDBManager) GetDB(
webhookID string,
) (*gorm.DB, error) {
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
cachedDB, castOK := val.(*gorm.DB)
if !castOK {
return nil, fmt.Errorf("invalid cached database type for webhook %s", webhookID)
return nil, fmt.Errorf(
"%w for webhook %s",
errInvalidCachedDBType,
webhookID,
)
}
return cachedDB, nil
}
@@ -106,43 +102,60 @@ func (m *WebhookDBManager) GetDB(webhookID string) (*gorm.DB, error) {
return nil, err
}
// Store it; if another goroutine beat us, close ours and use theirs
// Store it; if another goroutine beat us, close ours
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
if loaded {
// Another goroutine created it first; close our duplicate
if sqlDB, closeErr := db.DB(); closeErr == nil {
sqlDB, closeErr := db.DB()
if closeErr == nil {
_ = sqlDB.Close()
}
existingDB, castOK := actual.(*gorm.DB)
if !castOK {
return nil, fmt.Errorf("invalid cached database type for webhook %s", webhookID)
return nil, fmt.Errorf(
"%w for webhook %s",
errInvalidCachedDBType,
webhookID,
)
}
return existingDB, nil
}
return db, nil
}
// CreateDB explicitly creates a new per-webhook database file and runs migrations.
// This is called when a new webhook is created.
func (m *WebhookDBManager) CreateDB(webhookID string) error {
// CreateDB explicitly creates a new per-webhook database file
// and runs migrations.
func (m *WebhookDBManager) CreateDB(
webhookID string,
) error {
_, err := m.GetDB(webhookID)
return err
}
// DBExists checks if a per-webhook database file exists on disk.
func (m *WebhookDBManager) DBExists(webhookID string) bool {
// DBExists checks if a per-webhook database file exists on
// disk.
func (m *WebhookDBManager) DBExists(
webhookID string,
) bool {
_, err := os.Stat(m.dbPath(webhookID))
return err == nil
}
// DeleteDB closes the connection and deletes the database file for a webhook.
// This performs a hard delete — the file is permanently removed.
func (m *WebhookDBManager) DeleteDB(webhookID string) error {
// DeleteDB closes the connection and deletes the database file
// for a webhook. The file is permanently removed.
func (m *WebhookDBManager) DeleteDB(
webhookID string,
) error {
// Close and remove from cache
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
if gormDB, castOK := val.(*gorm.DB); castOK {
if sqlDB, err := gormDB.DB(); err == nil {
sqlDB, err := gormDB.DB()
if err == nil {
_ = sqlDB.Close()
}
}
@@ -151,12 +164,20 @@ func (m *WebhookDBManager) DeleteDB(webhookID string) error {
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
if err := os.Remove(path + suffix); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("deleting webhook database file %s%s: %w", path, suffix, err)
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"deleting webhook database file %s%s: %w",
path, suffix, err,
)
}
}
m.log.Info("deleted per-webhook database", "webhook_id", webhookID)
m.log.Info(
"deleted per-webhook database",
"webhook_id", webhookID,
)
return nil
}
@@ -164,20 +185,97 @@ func (m *WebhookDBManager) DeleteDB(webhookID string) error {
// Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error {
var lastErr error
m.dbs.Range(func(key, value interface{}) bool {
m.dbs.Range(func(key, value any) bool {
if gormDB, castOK := value.(*gorm.DB); castOK {
if sqlDB, err := gormDB.DB(); err == nil {
if closeErr := sqlDB.Close(); closeErr != nil {
sqlDB, err := gormDB.DB()
if err == nil {
closeErr := sqlDB.Close()
if closeErr != nil {
lastErr = closeErr
m.log.Error("failed to close webhook database",
m.log.Error(
"failed to close webhook database",
"webhook_id", key,
"error", closeErr,
)
}
}
}
m.dbs.Delete(key)
return true
})
return lastErr
}
// DBPath returns the filesystem path for a webhook's database
// file.
func (m *WebhookDBManager) DBPath(
webhookID string,
) string {
return m.dbPath(webhookID)
}
func (m *WebhookDBManager) dbPath(
webhookID string,
) string {
return filepath.Join(
m.dataDir,
fmt.Sprintf("events-%s.db", webhookID),
)
}
// openDB opens (or creates) a per-webhook SQLite database and
// runs migrations.
func (m *WebhookDBManager) openDB(
webhookID string,
) (*gorm.DB, error) {
path := m.dbPath(webhookID)
dbURL := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc",
path,
)
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
return nil, fmt.Errorf(
"opening webhook database %s: %w",
webhookID, err,
)
}
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"connecting to webhook database %s: %w",
webhookID, err,
)
}
// Run migrations for event-tier models only
err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{},
)
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"migrating webhook database %s: %w",
webhookID, err,
)
}
m.log.Info(
"opened per-webhook database",
"webhook_id", webhookID,
"path", path,
)
return db, nil
}

View File

@@ -1,4 +1,4 @@
package database
package database_test
import (
"context"
@@ -10,23 +10,29 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
func setupTestWebhookDBManager(t *testing.T) (*WebhookDBManager, *fxtest.Lifecycle) {
func setupTestWebhookDBManager(
t *testing.T,
) (*database.WebhookDBManager, *fxtest.Lifecycle) {
t.Helper()
lc := fxtest.NewLifecycle(t)
globals.Appname = "webhooker-test"
globals.Version = "test"
g := &globals.Globals{
Appname: "webhooker-test",
Version: "test",
}
g, err := globals.New(lc)
require.NoError(t, err)
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
l, err := logger.New(
lc,
logger.LoggerParams{Globals: g},
)
require.NoError(t, err)
dataDir := filepath.Join(t.TempDir(), "events")
@@ -35,19 +41,25 @@ func setupTestWebhookDBManager(t *testing.T) (*WebhookDBManager, *fxtest.Lifecyc
DataDir: dataDir,
}
mgr, err := NewWebhookDBManager(lc, WebhookDBManagerParams{
mgr, err := database.NewWebhookDBManager(
lc,
database.WebhookDBManagerParams{
Config: cfg,
Logger: l,
})
},
)
require.NoError(t, err)
return mgr, lc
}
func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
@@ -68,7 +80,7 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
require.NotNil(t, db)
// Verify we can write an event
event := &Event{
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
@@ -80,27 +92,35 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
assert.NotEmpty(t, event.ID)
// Verify we can read it back
var readEvent Event
require.NoError(t, db.First(&readEvent, "id = ?", event.ID).Error)
var readEvent database.Event
require.NoError(
t,
db.First(&readEvent, "id = ?", event.ID).Error,
)
assert.Equal(t, webhookID, readEvent.WebhookID)
assert.Equal(t, "POST", readEvent.Method)
assert.Equal(t, `{"test": true}`, readEvent.Body)
}
func TestWebhookDBManager_DeleteDB(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
// Create the DB and write some data
require.NoError(t, mgr.CreateDB(webhookID))
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
event := &Event{
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
@@ -116,15 +136,19 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
assert.False(t, mgr.DBExists(webhookID))
// Verify the file is actually gone from disk
dbPath := mgr.dbPath(webhookID)
dbPath := mgr.DBPath(webhookID)
_, err = os.Stat(dbPath)
assert.True(t, os.IsNotExist(err))
}
func TestWebhookDBManager_LazyCreation(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
@@ -139,9 +163,12 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
}
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
@@ -150,8 +177,23 @@ func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
// Create an event
event := &Event{
event, delivery := seedDeliveryWorkflow(
t, db, webhookID, targetID,
)
verifyPendingDeliveries(t, db, event)
completeDelivery(t, db, delivery)
verifyNoPending(t, db)
}
func seedDeliveryWorkflow(
t *testing.T,
db *gorm.DB,
webhookID, targetID string,
) (*database.Event, *database.Delivery) {
t.Helper()
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
@@ -161,25 +203,45 @@ func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
}
require.NoError(t, db.Create(event).Error)
// Create a delivery
delivery := &Delivery{
delivery := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: DeliveryStatusPending,
Status: database.DeliveryStatusPending,
}
require.NoError(t, db.Create(delivery).Error)
// Query pending deliveries
var pending []Delivery
require.NoError(t, db.Where("status = ?", DeliveryStatusPending).
Preload("Event").
Find(&pending).Error)
return event, delivery
}
func verifyPendingDeliveries(
t *testing.T,
db *gorm.DB,
event *database.Event,
) {
t.Helper()
var pending []database.Delivery
require.NoError(
t,
db.Where(
"status = ?",
database.DeliveryStatusPending,
).Preload("Event").Find(&pending).Error,
)
require.Len(t, pending, 1)
assert.Equal(t, event.ID, pending[0].EventID)
assert.Equal(t, "POST", pending[0].Event.Method)
}
// Create a delivery result
result := &DeliveryResult{
func completeDelivery(
t *testing.T,
db *gorm.DB,
delivery *database.Delivery,
) {
t.Helper()
result := &database.DeliveryResult{
DeliveryID: delivery.ID,
AttemptNum: 1,
Success: true,
@@ -188,19 +250,40 @@ func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
}
require.NoError(t, db.Create(result).Error)
// Update delivery status
require.NoError(t, db.Model(delivery).Update("status", DeliveryStatusDelivered).Error)
require.NoError(
t,
db.Model(delivery).Update(
"status",
database.DeliveryStatusDelivered,
).Error,
)
}
// Verify no more pending deliveries
var stillPending []Delivery
require.NoError(t, db.Where("status = ?", DeliveryStatusPending).Find(&stillPending).Error)
func verifyNoPending(
t *testing.T,
db *gorm.DB,
) {
t.Helper()
var stillPending []database.Delivery
require.NoError(
t,
db.Where(
"status = ?",
database.DeliveryStatusPending,
).Find(&stillPending).Error,
)
assert.Empty(t, stillPending)
}
func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhook1 := uuid.New().String()
@@ -212,34 +295,38 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
db1, err := mgr.GetDB(webhook1)
require.NoError(t, err)
db2, err := mgr.GetDB(webhook2)
require.NoError(t, err)
// Write events to each webhook's DB
event1 := &Event{
event1 := &database.Event{
WebhookID: webhook1,
EntrypointID: uuid.New().String(),
Method: "POST",
Body: `{"webhook": 1}`,
ContentType: "application/json",
}
event2 := &Event{
event2 := &database.Event{
WebhookID: webhook2,
EntrypointID: uuid.New().String(),
Method: "PUT",
Body: `{"webhook": 2}`,
ContentType: "application/json",
}
require.NoError(t, db1.Create(event1).Error)
require.NoError(t, db2.Create(event2).Error)
// Verify isolation: each DB only has its own events
var count1 int64
db1.Model(&Event{}).Count(&count1)
db1.Model(&database.Event{}).Count(&count1)
assert.Equal(t, int64(1), count1)
var count2 int64
db2.Model(&Event{}).Count(&count2)
db2.Model(&database.Event{}).Count(&count2)
assert.Equal(t, int64(1), count2)
// Delete webhook1's DB, webhook2 should be unaffected
@@ -248,25 +335,31 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
assert.True(t, mgr.DBExists(webhook2))
// webhook2's data should still be accessible
var events []Event
var events []database.Event
require.NoError(t, db2.Find(&events).Error)
assert.Len(t, events, 1)
assert.Equal(t, "PUT", events[0].Method)
}
func TestWebhookDBManager_CloseAll(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
// Create a few DBs
for i := 0; i < 3; i++ {
require.NoError(t, mgr.CreateDB(uuid.New().String()))
for range 3 {
require.NoError(
t,
mgr.CreateDB(uuid.New().String()),
)
}
// CloseAll should close all connections without error
require.NoError(t, mgr.CloseAll())
// Stop lifecycle (CloseAll already called, but shouldn't panic)
// Stop lifecycle (CloseAll already called)
require.NoError(t, lc.Stop(ctx))
}

View File

@@ -5,41 +5,32 @@ import (
"time"
)
// CircuitState represents the current state of a circuit breaker.
// CircuitState represents the current state of a circuit
// breaker.
type CircuitState int
const (
// CircuitClosed is the normal operating state. Deliveries flow through.
// CircuitClosed is the normal operating state.
CircuitClosed CircuitState = iota
// CircuitOpen means the circuit has tripped. Deliveries are skipped
// until the cooldown expires.
// CircuitOpen means the circuit has tripped.
CircuitOpen
// CircuitHalfOpen allows a single probe delivery to test whether
// the target has recovered.
// CircuitHalfOpen allows a single probe delivery to
// test whether the target has recovered.
CircuitHalfOpen
)
const (
// defaultFailureThreshold is the number of consecutive failures
// before a circuit breaker trips open.
// defaultFailureThreshold is the number of consecutive
// failures before a circuit breaker trips open.
defaultFailureThreshold = 5
// defaultCooldown is how long a circuit stays open before
// transitioning to half-open for a probe delivery.
// defaultCooldown is how long a circuit stays open
// before transitioning to half-open.
defaultCooldown = 30 * time.Second
)
// CircuitBreaker implements the circuit breaker pattern for a single
// delivery target. It tracks consecutive failures and prevents
// hammering a down target by temporarily stopping delivery attempts.
//
// States:
// - Closed (normal): deliveries flow through; consecutive failures
// are counted.
// - Open (tripped): deliveries are skipped; a cooldown timer is
// running. After the cooldown expires the state moves to HalfOpen.
// - HalfOpen (probing): one probe delivery is allowed. If it
// succeeds the circuit closes; if it fails the circuit reopens.
// CircuitBreaker implements the circuit breaker pattern
// for a single delivery target.
type CircuitBreaker struct {
mu sync.Mutex
state CircuitState
@@ -49,7 +40,8 @@ type CircuitBreaker struct {
lastFailure time.Time
}
// NewCircuitBreaker creates a circuit breaker with default settings.
// NewCircuitBreaker creates a circuit breaker with default
// settings.
func NewCircuitBreaker() *CircuitBreaker {
return &CircuitBreaker{
state: CircuitClosed,
@@ -58,12 +50,7 @@ func NewCircuitBreaker() *CircuitBreaker {
}
}
// Allow checks whether a delivery attempt should proceed. It returns
// true if the delivery should be attempted, false if the circuit is
// open and the delivery should be skipped.
//
// When the circuit is open and the cooldown has elapsed, Allow
// transitions to half-open and permits exactly one probe delivery.
// Allow checks whether a delivery attempt should proceed.
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
@@ -73,17 +60,15 @@ func (cb *CircuitBreaker) Allow() bool {
return true
case CircuitOpen:
// Check if cooldown has elapsed
if time.Since(cb.lastFailure) >= cb.cooldown {
cb.state = CircuitHalfOpen
return true
}
return false
case CircuitHalfOpen:
// Only one probe at a time — reject additional attempts while
// a probe is in flight. The probe goroutine will call
// RecordSuccess or RecordFailure to resolve the state.
return false
default:
@@ -91,9 +76,8 @@ func (cb *CircuitBreaker) Allow() bool {
}
}
// CooldownRemaining returns how much time is left before an open circuit
// transitions to half-open. Returns zero if the circuit is not open or
// the cooldown has already elapsed.
// CooldownRemaining returns how much time is left before
// an open circuit transitions to half-open.
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
cb.mu.Lock()
defer cb.mu.Unlock()
@@ -106,11 +90,12 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
if remaining < 0 {
return 0
}
return remaining
}
// RecordSuccess records a successful delivery and resets the circuit
// breaker to closed state with zero failures.
// RecordSuccess records a successful delivery and resets
// the circuit breaker to closed state.
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
@@ -119,8 +104,8 @@ func (cb *CircuitBreaker) RecordSuccess() {
cb.state = CircuitClosed
}
// RecordFailure records a failed delivery. If the failure count reaches
// the threshold, the circuit trips open.
// RecordFailure records a failed delivery. If the failure
// count reaches the threshold, the circuit trips open.
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
@@ -134,20 +119,25 @@ func (cb *CircuitBreaker) RecordFailure() {
cb.state = CircuitOpen
}
case CircuitOpen:
// Already open; no state change needed.
case CircuitHalfOpen:
// Probe failed reopen immediately
// Probe failed -- reopen immediately.
cb.state = CircuitOpen
}
}
// State returns the current circuit state. Safe for concurrent use.
// State returns the current circuit state.
func (cb *CircuitBreaker) State() CircuitState {
cb.mu.Lock()
defer cb.mu.Unlock()
return cb.state
}
// String returns the human-readable name of a circuit state.
// String returns the human-readable name of a circuit
// state.
func (s CircuitState) String() string {
switch s {
case CircuitClosed:

View File

@@ -1,4 +1,4 @@
package delivery
package delivery_test
import (
"sync"
@@ -7,237 +7,304 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/delivery"
)
func TestCircuitBreaker_ClosedState_AllowsDeliveries(t *testing.T) {
func TestCircuitBreaker_ClosedState_AllowsDeliveries(
t *testing.T,
) {
t.Parallel()
cb := NewCircuitBreaker()
assert.Equal(t, CircuitClosed, cb.State())
assert.True(t, cb.Allow(), "closed circuit should allow deliveries")
// Multiple calls should all succeed
for i := 0; i < 10; i++ {
cb := delivery.NewCircuitBreaker()
assert.Equal(t, delivery.CircuitClosed, cb.State())
assert.True(t, cb.Allow(),
"closed circuit should allow deliveries",
)
for range 10 {
assert.True(t, cb.Allow())
}
}
func TestCircuitBreaker_FailureCounting(t *testing.T) {
t.Parallel()
cb := NewCircuitBreaker()
// Record failures below threshold — circuit should stay closed
for i := 0; i < defaultFailureThreshold-1; i++ {
cb := delivery.NewCircuitBreaker()
for i := range delivery.ExportDefaultFailureThreshold - 1 {
cb.RecordFailure()
assert.Equal(t, CircuitClosed, cb.State(),
"circuit should remain closed after %d failures", i+1)
assert.True(t, cb.Allow(), "should still allow after %d failures", i+1)
assert.Equal(t,
delivery.CircuitClosed, cb.State(),
"circuit should remain closed after %d failures",
i+1,
)
assert.True(t, cb.Allow(),
"should still allow after %d failures",
i+1,
)
}
}
func TestCircuitBreaker_OpenTransition(t *testing.T) {
t.Parallel()
cb := NewCircuitBreaker()
// Record exactly threshold failures
for i := 0; i < defaultFailureThreshold; i++ {
cb := delivery.NewCircuitBreaker()
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
assert.Equal(t, CircuitOpen, cb.State(), "circuit should be open after threshold failures")
assert.False(t, cb.Allow(), "open circuit should reject deliveries")
assert.Equal(t, delivery.CircuitOpen, cb.State(),
"circuit should be open after threshold failures",
)
assert.False(t, cb.Allow(),
"open circuit should reject deliveries",
)
}
func TestCircuitBreaker_Cooldown_StaysOpen(t *testing.T) {
t.Parallel()
// Use a circuit with a known short cooldown for testing
cb := &CircuitBreaker{
state: CircuitClosed,
threshold: defaultFailureThreshold,
cooldown: 200 * time.Millisecond,
}
// Trip the circuit open
for i := 0; i < defaultFailureThreshold; i++ {
cb := delivery.NewCircuitBreaker()
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
require.Equal(t, CircuitOpen, cb.State())
// During cooldown, Allow should return false
assert.False(t, cb.Allow(), "should be blocked during cooldown")
require.Equal(t, delivery.CircuitOpen, cb.State())
assert.False(t, cb.Allow(),
"should be blocked during cooldown",
)
// CooldownRemaining should be positive
remaining := cb.CooldownRemaining()
assert.Greater(t, remaining, time.Duration(0), "cooldown should have remaining time")
assert.Greater(t, remaining, time.Duration(0),
"cooldown should have remaining time",
)
}
func TestCircuitBreaker_HalfOpen_AfterCooldown(t *testing.T) {
func TestCircuitBreaker_HalfOpen_AfterCooldown(
t *testing.T,
) {
t.Parallel()
cb := &CircuitBreaker{
state: CircuitClosed,
threshold: defaultFailureThreshold,
cooldown: 50 * time.Millisecond,
}
// Trip the circuit open
for i := 0; i < defaultFailureThreshold; i++ {
cb := newShortCooldownCB(t)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
require.Equal(t, CircuitOpen, cb.State())
// Wait for cooldown to expire
require.Equal(t, delivery.CircuitOpen, cb.State())
time.Sleep(60 * time.Millisecond)
// CooldownRemaining should be zero after cooldown
assert.Equal(t, time.Duration(0), cb.CooldownRemaining())
assert.Equal(t, time.Duration(0),
cb.CooldownRemaining(),
)
// First Allow after cooldown should succeed (probe)
assert.True(t, cb.Allow(), "should allow one probe after cooldown")
assert.Equal(t, CircuitHalfOpen, cb.State(), "should be half-open after probe allowed")
assert.True(t, cb.Allow(),
"should allow one probe after cooldown",
)
// Second Allow should be rejected (only one probe at a time)
assert.False(t, cb.Allow(), "should reject additional probes while half-open")
assert.Equal(t,
delivery.CircuitHalfOpen, cb.State(),
"should be half-open after probe allowed",
)
assert.False(t, cb.Allow(),
"should reject additional probes while half-open",
)
}
func TestCircuitBreaker_ProbeSuccess_ClosesCircuit(t *testing.T) {
func TestCircuitBreaker_ProbeSuccess_ClosesCircuit(
t *testing.T,
) {
t.Parallel()
cb := &CircuitBreaker{
state: CircuitClosed,
threshold: defaultFailureThreshold,
cooldown: 50 * time.Millisecond,
}
// Trip open → wait for cooldown → allow probe
for i := 0; i < defaultFailureThreshold; i++ {
cb := newShortCooldownCB(t)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
time.Sleep(60 * time.Millisecond)
require.True(t, cb.Allow()) // probe allowed, state → half-open
// Probe succeeds → circuit should close
time.Sleep(60 * time.Millisecond)
require.True(t, cb.Allow())
cb.RecordSuccess()
assert.Equal(t, CircuitClosed, cb.State(), "successful probe should close circuit")
// Should allow deliveries again
assert.True(t, cb.Allow(), "closed circuit should allow deliveries")
assert.Equal(t, delivery.CircuitClosed, cb.State(),
"successful probe should close circuit",
)
assert.True(t, cb.Allow(),
"closed circuit should allow deliveries",
)
}
func TestCircuitBreaker_ProbeFailure_ReopensCircuit(t *testing.T) {
func TestCircuitBreaker_ProbeFailure_ReopensCircuit(
t *testing.T,
) {
t.Parallel()
cb := &CircuitBreaker{
state: CircuitClosed,
threshold: defaultFailureThreshold,
cooldown: 50 * time.Millisecond,
}
// Trip open → wait for cooldown → allow probe
for i := 0; i < defaultFailureThreshold; i++ {
cb := newShortCooldownCB(t)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
time.Sleep(60 * time.Millisecond)
require.True(t, cb.Allow()) // probe allowed, state → half-open
// Probe fails → circuit should reopen
require.True(t, cb.Allow())
cb.RecordFailure()
assert.Equal(t, CircuitOpen, cb.State(), "failed probe should reopen circuit")
assert.False(t, cb.Allow(), "reopened circuit should reject deliveries")
assert.Equal(t, delivery.CircuitOpen, cb.State(),
"failed probe should reopen circuit",
)
assert.False(t, cb.Allow(),
"reopened circuit should reject deliveries",
)
}
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
func TestCircuitBreaker_SuccessResetsFailures(
t *testing.T,
) {
t.Parallel()
cb := NewCircuitBreaker()
// Accumulate failures just below threshold
for i := 0; i < defaultFailureThreshold-1; i++ {
cb := delivery.NewCircuitBreaker()
for range delivery.ExportDefaultFailureThreshold - 1 {
cb.RecordFailure()
}
require.Equal(t, CircuitClosed, cb.State())
// Success should reset the failure counter
require.Equal(t, delivery.CircuitClosed, cb.State())
cb.RecordSuccess()
assert.Equal(t, CircuitClosed, cb.State())
// Now we should need another full threshold of failures to trip
for i := 0; i < defaultFailureThreshold-1; i++ {
assert.Equal(t, delivery.CircuitClosed, cb.State())
for range delivery.ExportDefaultFailureThreshold - 1 {
cb.RecordFailure()
}
assert.Equal(t, CircuitClosed, cb.State(),
"circuit should still be closed — success reset the counter")
// One more failure should trip it
assert.Equal(t, delivery.CircuitClosed, cb.State(),
"circuit should still be closed -- "+
"success reset the counter",
)
cb.RecordFailure()
assert.Equal(t, CircuitOpen, cb.State())
assert.Equal(t, delivery.CircuitOpen, cb.State())
}
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
t.Parallel()
cb := NewCircuitBreaker()
cb := delivery.NewCircuitBreaker()
const goroutines = 100
var wg sync.WaitGroup
wg.Add(goroutines * 3)
// Concurrent Allow calls
for i := 0; i < goroutines; i++ {
for range goroutines {
go func() {
defer wg.Done()
cb.Allow()
}()
}
// Concurrent RecordFailure calls
for i := 0; i < goroutines; i++ {
for range goroutines {
go func() {
defer wg.Done()
cb.RecordFailure()
}()
}
// Concurrent RecordSuccess calls
for i := 0; i < goroutines; i++ {
for range goroutines {
go func() {
defer wg.Done()
cb.RecordSuccess()
}()
}
wg.Wait()
// No panic or data race — the test passes if -race doesn't flag anything.
// State should be one of the valid states.
state := cb.State()
assert.Contains(t, []CircuitState{CircuitClosed, CircuitOpen, CircuitHalfOpen}, state,
"state should be valid after concurrent access")
assert.Contains(t,
[]delivery.CircuitState{
delivery.CircuitClosed,
delivery.CircuitOpen,
delivery.CircuitHalfOpen,
},
state,
"state should be valid after concurrent access",
)
}
func TestCircuitBreaker_CooldownRemaining_ClosedReturnsZero(t *testing.T) {
func TestCircuitBreaker_CooldownRemaining_ClosedReturnsZero(
t *testing.T,
) {
t.Parallel()
cb := NewCircuitBreaker()
assert.Equal(t, time.Duration(0), cb.CooldownRemaining(),
"closed circuit should have zero cooldown remaining")
cb := delivery.NewCircuitBreaker()
assert.Equal(t, time.Duration(0),
cb.CooldownRemaining(),
"closed circuit should have zero cooldown remaining",
)
}
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(t *testing.T) {
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(
t *testing.T,
) {
t.Parallel()
cb := &CircuitBreaker{
state: CircuitClosed,
threshold: defaultFailureThreshold,
cooldown: 50 * time.Millisecond,
}
// Trip open, wait, transition to half-open
for i := 0; i < defaultFailureThreshold; i++ {
cb := newShortCooldownCB(t)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
time.Sleep(60 * time.Millisecond)
require.True(t, cb.Allow()) // → half-open
assert.Equal(t, time.Duration(0), cb.CooldownRemaining(),
"half-open circuit should have zero cooldown remaining")
time.Sleep(60 * time.Millisecond)
require.True(t, cb.Allow())
assert.Equal(t, time.Duration(0),
cb.CooldownRemaining(),
"half-open circuit should have zero cooldown remaining",
)
}
func TestCircuitState_String(t *testing.T) {
t.Parallel()
assert.Equal(t, "closed", CircuitClosed.String())
assert.Equal(t, "open", CircuitOpen.String())
assert.Equal(t, "half-open", CircuitHalfOpen.String())
assert.Equal(t, "unknown", CircuitState(99).String())
assert.Equal(t, "closed", delivery.CircuitClosed.String())
assert.Equal(t, "open", delivery.CircuitOpen.String())
assert.Equal(t, "half-open", delivery.CircuitHalfOpen.String())
assert.Equal(t, "unknown", delivery.CircuitState(99).String())
}
// newShortCooldownCB creates a CircuitBreaker with a short
// cooldown for testing. We use NewCircuitBreaker and
// manipulate through the public API.
func newShortCooldownCB(t *testing.T) *delivery.CircuitBreaker {
t.Helper()
return delivery.NewTestCircuitBreaker(
delivery.ExportDefaultFailureThreshold,
50*time.Millisecond,
)
}

View File

@@ -0,0 +1,112 @@
package delivery_test
import (
"context"
"log/slog"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/delivery"
)
// newSSRFTestEngine builds an Engine whose shared client
// carries the SSRF-safe transport, mirroring production.
func newSSRFTestEngine() *delivery.Engine {
log := slog.New(slog.DiscardHandler)
client := &http.Client{
Timeout: 30 * time.Second,
Transport: delivery.NewSSRFSafeTransport(),
}
return delivery.NewTestEngine(log, client, 1)
}
// TestClientForConfig_TimeoutKeepsSSRFGuard asserts that a
// client returned by clientForConfig for a config with a
// per-target timeout still refuses connections to
// private/reserved addresses (the timeout must not drop the
// SSRF-safe transport).
func TestClientForConfig_TimeoutKeepsSSRFGuard(t *testing.T) {
t.Parallel()
engine := newSSRFTestEngine()
blocked := []string{
"http://127.0.0.1/hook",
"http://169.254.169.254/latest/meta-data/",
"http://[fe80::1]/hook",
}
for _, target := range blocked {
t.Run(target, func(t *testing.T) {
t.Parallel()
cfg := &delivery.HTTPTargetConfig{
URL: target,
Timeout: 5,
}
client := engine.ExportClientForConfig(cfg)
require.NotSame(t, engine.ExportClient(), client,
"a per-target timeout must yield a "+
"distinct client",
)
assert.Equal(t,
5*time.Second, client.Timeout,
"the per-target timeout must be applied",
)
assert.Same(t,
engine.ExportClient().Transport,
client.Transport,
"the SSRF-safe transport must be reused, "+
"not dropped",
)
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost, target, nil,
)
require.NoError(t, err)
resp, doErr := client.Do(req)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, doErr,
"request to %s must be blocked", target,
)
assert.Contains(t, doErr.Error(), "blocked",
"error must come from the SSRF guard",
)
})
}
}
// TestClientForConfig_NoTimeoutUnchanged asserts that with
// no per-target timeout the shared SSRF-safe client is
// returned unchanged.
func TestClientForConfig_NoTimeoutUnchanged(t *testing.T) {
t.Parallel()
engine := newSSRFTestEngine()
cfg := &delivery.HTTPTargetConfig{
URL: "https://example.com/hook",
}
client := engine.ExportClientForConfig(cfg)
assert.Same(t, engine.ExportClient(), client,
"without a per-target timeout the shared client "+
"must be returned unchanged",
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
package delivery
import (
"context"
"log/slog"
"net"
"net/http"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// Exported constants for test access.
const (
ExportDeliveryChannelSize = deliveryChannelSize
ExportRetryChannelSize = retryChannelSize
ExportDefaultFailureThreshold = defaultFailureThreshold
ExportDefaultCooldown = defaultCooldown
)
// ExportIsBlockedIP exposes isBlockedIP for testing.
func ExportIsBlockedIP(ip net.IP) bool {
return isBlockedIP(ip)
}
// ExportBlockedNetworks exposes blockedNetworks.
func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks
}
// ExportIsForwardableHeader exposes isForwardableHeader.
func ExportIsForwardableHeader(name string) bool {
return isForwardableHeader(name)
}
// ExportTruncate exposes truncate for testing.
func ExportTruncate(s string, maxLen int) string {
return truncate(s, maxLen)
}
// ExportDeliverHTTP exposes deliverHTTP for testing.
func (e *Engine) ExportDeliverHTTP(
ctx context.Context,
webhookDB *gorm.DB,
d *database.Delivery,
task *Task,
) {
e.deliverHTTP(ctx, webhookDB, d, task)
}
// ExportDeliverDatabase exposes deliverDatabase.
func (e *Engine) ExportDeliverDatabase(
webhookDB *gorm.DB, d *database.Delivery,
) {
e.deliverDatabase(webhookDB, d)
}
// ExportDeliverLog exposes deliverLog for testing.
func (e *Engine) ExportDeliverLog(
webhookDB *gorm.DB, d *database.Delivery,
) {
e.deliverLog(webhookDB, d)
}
// ExportDeliverSlack exposes deliverSlack for testing.
func (e *Engine) ExportDeliverSlack(
ctx context.Context,
webhookDB *gorm.DB,
d *database.Delivery,
) {
e.deliverSlack(ctx, webhookDB, d)
}
// ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task,
) {
e.processNewTask(ctx, task)
}
// ExportProcessRetryTask exposes processRetryTask.
func (e *Engine) ExportProcessRetryTask(
ctx context.Context, task *Task,
) {
e.processRetryTask(ctx, task)
}
// ExportProcessDelivery exposes processDelivery.
func (e *Engine) ExportProcessDelivery(
ctx context.Context,
webhookDB *gorm.DB,
d *database.Delivery,
task *Task,
) {
e.processDelivery(ctx, webhookDB, d, task)
}
// ExportGetCircuitBreaker exposes getCircuitBreaker.
func (e *Engine) ExportGetCircuitBreaker(
targetID string,
) *CircuitBreaker {
return e.getCircuitBreaker(targetID)
}
// ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig(
configJSON string,
) (*HTTPTargetConfig, error) {
return e.parseHTTPConfig(configJSON)
}
// ExportParseSlackConfig exposes parseSlackConfig.
func (e *Engine) ExportParseSlackConfig(
configJSON string,
) (*SlackTargetConfig, error) {
return e.parseSlackConfig(configJSON)
}
// ExportDoHTTPRequest exposes doHTTPRequest.
func (e *Engine) ExportDoHTTPRequest(
ctx context.Context,
cfg *HTTPTargetConfig,
event *database.Event,
) (int, string, int64, error) {
return e.doHTTPRequest(ctx, cfg, event)
}
// ExportClientForConfig exposes clientForConfig.
func (e *Engine) ExportClientForConfig(
cfg *HTTPTargetConfig,
) *http.Client {
return e.clientForConfig(cfg)
}
// ExportClient returns the engine's shared HTTP client.
func (e *Engine) ExportClient() *http.Client {
return e.client
}
// ExportScheduleRetry exposes scheduleRetry.
func (e *Engine) ExportScheduleRetry(
task Task, delay time.Duration,
) {
e.scheduleRetry(task, delay)
}
// ExportRecoverPendingDeliveries exposes
// recoverPendingDeliveries.
func (e *Engine) ExportRecoverPendingDeliveries(
ctx context.Context,
webhookDB *gorm.DB,
webhookID string,
) {
e.recoverPendingDeliveries(
ctx, webhookDB, webhookID,
)
}
// ExportRecoverWebhookDeliveries exposes
// recoverWebhookDeliveries.
func (e *Engine) ExportRecoverWebhookDeliveries(
ctx context.Context, webhookID string,
) {
e.recoverWebhookDeliveries(ctx, webhookID)
}
// ExportRecoverInFlight exposes recoverInFlight.
func (e *Engine) ExportRecoverInFlight(
ctx context.Context,
) {
e.recoverInFlight(ctx)
}
// ExportStart exposes start for testing.
func (e *Engine) ExportStart(ctx context.Context) {
e.start(ctx)
}
// ExportStop exposes stop for testing.
func (e *Engine) ExportStop() {
e.stop()
}
// ExportDeliveryCh returns the delivery channel.
func (e *Engine) ExportDeliveryCh() chan Task {
return e.deliveryCh
}
// ExportRetryCh returns the retry channel.
func (e *Engine) ExportRetryCh() chan Task {
return e.retryCh
}
// NewTestEngine creates an Engine for unit tests without
// database dependencies.
func NewTestEngine(
log *slog.Logger,
client *http.Client,
workers int,
) *Engine {
return &Engine{
log: log,
client: client,
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: workers,
}
}
// NewTestEngineSmallRetry creates an Engine with a tiny
// retry channel buffer for overflow testing.
func NewTestEngineSmallRetry(
log *slog.Logger,
) *Engine {
return &Engine{
log: log,
retryCh: make(chan Task, 1),
}
}
// NewTestEngineWithDB creates an Engine with a real
// database and dbManager for integration tests.
func NewTestEngineWithDB(
db *database.Database,
dbMgr *database.WebhookDBManager,
log *slog.Logger,
client *http.Client,
workers int,
) *Engine {
return &Engine{
database: db,
dbManager: dbMgr,
log: log,
client: client,
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: workers,
}
}
// NewTestCircuitBreaker creates a CircuitBreaker with
// custom settings for testing.
func NewTestCircuitBreaker(
threshold int, cooldown time.Duration,
) *CircuitBreaker {
return &CircuitBreaker{
state: CircuitClosed,
threshold: threshold,
cooldown: cooldown,
}
}

View File

@@ -2,6 +2,7 @@ package delivery
import (
"context"
"errors"
"fmt"
"net"
"net/http"
@@ -10,14 +11,27 @@ import (
)
const (
// dnsResolutionTimeout is the maximum time to wait for DNS resolution
// during SSRF validation.
// dnsResolutionTimeout is the maximum time to wait for
// DNS resolution during SSRF validation.
dnsResolutionTimeout = 5 * time.Second
)
// blockedNetworks contains all private/reserved IP ranges that should be
// blocked to prevent SSRF attacks. This includes RFC 1918 private
// addresses, loopback, link-local, and IPv6 equivalents.
// Sentinel errors for SSRF validation.
var (
errNoHostname = errors.New("URL has no hostname")
errNoIPs = errors.New(
"hostname resolved to no IP addresses",
)
errBlockedIP = errors.New(
"blocked private/reserved IP range",
)
errInvalidScheme = errors.New(
"only http and https are allowed",
)
)
// blockedNetworks contains all private/reserved IP ranges
// that should be blocked to prevent SSRF attacks.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet
@@ -25,129 +39,184 @@ var blockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() {
cidrs := []string{
// IPv4 private/reserved ranges
"127.0.0.0/8", // Loopback
"10.0.0.0/8", // RFC 1918 Class A private
"172.16.0.0/12", // RFC 1918 Class B private
"192.168.0.0/16", // RFC 1918 Class C private
"169.254.0.0/16", // Link-local (cloud metadata)
"0.0.0.0/8", // "This" network
"100.64.0.0/10", // Shared address space (CGN)
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"198.18.0.0/15", // Benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"224.0.0.0/4", // Multicast
"240.0.0.0/4", // Reserved for future use
// IPv6 private/reserved ranges
"::1/128", // Loopback
"fc00::/7", // Unique local addresses
"fe80::/10", // Link-local
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"0.0.0.0/8",
"100.64.0.0/10",
"192.0.0.0/24",
"192.0.2.0/24",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
"::1/128",
"fc00::/7",
"fe80::/10",
}
for _, cidr := range cidrs {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
panic(fmt.Sprintf("ssrf: failed to parse CIDR %q: %v", cidr, err))
panic(fmt.Sprintf(
"ssrf: failed to parse CIDR %q: %v",
cidr, err,
))
}
blockedNetworks = append(blockedNetworks, network)
blockedNetworks = append(
blockedNetworks, network,
)
}
}
// isBlockedIP checks whether an IP address falls within any blocked
// private/reserved network range.
// isBlockedIP checks whether an IP address falls within
// any blocked private/reserved network range.
func isBlockedIP(ip net.IP) bool {
for _, network := range blockedNetworks {
if network.Contains(ip) {
return true
}
}
return false
}
// ValidateTargetURL checks that an HTTP delivery target URL is safe
// from SSRF attacks. It validates the URL format, resolves the hostname
// to IP addresses, and verifies that none of the resolved IPs are in
// blocked private/reserved ranges.
//
// Returns nil if the URL is safe, or an error describing the issue.
func ValidateTargetURL(targetURL string) error {
// ValidateTargetURL checks that an HTTP delivery target
// URL is safe from SSRF attacks.
func ValidateTargetURL(
ctx context.Context, targetURL string,
) error {
parsed, err := url.Parse(targetURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Only allow http and https schemes
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("unsupported URL scheme %q: only http and https are allowed", parsed.Scheme)
err = validateScheme(parsed.Scheme)
if err != nil {
return err
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("URL has no hostname")
return errNoHostname
}
// Check if the host is a raw IP address first
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("target IP %s is in a blocked private/reserved range", ip)
}
return nil
return checkBlockedIP(ip)
}
// Resolve hostname to IPs and check each one
ctx, cancel := context.WithTimeout(context.Background(), dnsResolutionTimeout)
return validateHostname(ctx, host)
}
func validateScheme(scheme string) error {
if scheme != "http" && scheme != "https" {
return fmt.Errorf(
"unsupported URL scheme %q: %w",
scheme, errInvalidScheme,
)
}
return nil
}
func checkBlockedIP(ip net.IP) error {
if isBlockedIP(ip) {
return fmt.Errorf(
"target IP %s is in a blocked "+
"private/reserved range: %w",
ip, errBlockedIP,
)
}
return nil
}
func validateHostname(
ctx context.Context, host string,
) error {
dnsCtx, cancel := context.WithTimeout(
ctx, dnsResolutionTimeout,
)
defer cancel()
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
ips, err := net.DefaultResolver.LookupIPAddr(
dnsCtx, host,
)
if err != nil {
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
return fmt.Errorf(
"failed to resolve hostname %q: %w",
host, err,
)
}
if len(ips) == 0 {
return fmt.Errorf("hostname %q resolved to no IP addresses", host)
return fmt.Errorf(
"hostname %q: %w", host, errNoIPs,
)
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return fmt.Errorf("hostname %q resolves to blocked IP %s (private/reserved range)", host, ipAddr.IP)
return fmt.Errorf(
"hostname %q resolves to blocked "+
"IP %s: %w",
host, ipAddr.IP, errBlockedIP,
)
}
}
return nil
}
// NewSSRFSafeTransport creates an http.Transport with a custom DialContext
// that blocks connections to private/reserved IP addresses. This provides
// defense-in-depth SSRF protection at the network layer, catching cases
// where DNS records change between target creation and delivery time
// (DNS rebinding attacks).
// NewSSRFSafeTransport creates an http.Transport with a
// custom DialContext that blocks connections to
// private/reserved IP addresses.
func NewSSRFSafeTransport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("ssrf: invalid address %q: %w", addr, err)
}
// Resolve hostname to IPs
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("ssrf: DNS resolution failed for %q: %w", host, err)
}
// Check all resolved IPs
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return nil, fmt.Errorf("ssrf: connection to %s (%s) blocked — private/reserved IP range", host, ipAddr.IP)
}
}
// Connect to the first allowed IP
var dialer net.Dialer
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
},
DialContext: ssrfDialContext,
}
}
func ssrfDialContext(
ctx context.Context,
network, addr string,
) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf(
"ssrf: invalid address %q: %w",
addr, err,
)
}
ips, err := net.DefaultResolver.LookupIPAddr(
ctx, host,
)
if err != nil {
return nil, fmt.Errorf(
"ssrf: DNS resolution failed for %q: %w",
host, err,
)
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return nil, fmt.Errorf(
"ssrf: connection to %s (%s) "+
"blocked: %w",
host, ipAddr.IP, errBlockedIP,
)
}
}
var dialer net.Dialer
return dialer.DialContext(
ctx, network,
net.JoinHostPort(ips[0].IP.String(), port),
)
}

View File

@@ -1,11 +1,13 @@
package delivery
package delivery_test
import (
"context"
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/delivery"
)
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
@@ -16,56 +18,52 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
ip string
blocked bool
}{
// Loopback
{"loopback 127.0.0.1", "127.0.0.1", true},
{"loopback 127.0.0.2", "127.0.0.2", true},
{"loopback 127.255.255.255", "127.255.255.255", true},
// RFC 1918 - Class A
{"10.0.0.0", "10.0.0.0", true},
{"10.0.0.1", "10.0.0.1", true},
{"10.255.255.255", "10.255.255.255", true},
// RFC 1918 - Class B
{"172.16.0.1", "172.16.0.1", true},
{"172.31.255.255", "172.31.255.255", true},
{"172.15.255.255", "172.15.255.255", false},
{"172.32.0.0", "172.32.0.0", false},
// RFC 1918 - Class C
{"192.168.0.1", "192.168.0.1", true},
{"192.168.255.255", "192.168.255.255", true},
// Link-local / cloud metadata
{"169.254.0.1", "169.254.0.1", true},
{"169.254.169.254", "169.254.169.254", true},
// Public IPs (should NOT be blocked)
{"8.8.8.8", "8.8.8.8", false},
{"1.1.1.1", "1.1.1.1", false},
{"93.184.216.34", "93.184.216.34", false},
// IPv6 loopback
{"::1", "::1", true},
// IPv6 unique local
{"fd00::1", "fd00::1", true},
{"fc00::1", "fc00::1", true},
// IPv6 link-local
{"fe80::1", "fe80::1", true},
// IPv6 public (should NOT be blocked)
{"2607:f8b0:4004:800::200e", "2607:f8b0:4004:800::200e", false},
{
"2607:f8b0:4004:800::200e",
"2607:f8b0:4004:800::200e",
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tt.ip)
require.NotNil(t, ip, "failed to parse IP %s", tt.ip)
assert.Equal(t, tt.blocked, isBlockedIP(ip),
"isBlockedIP(%s) = %v, want %v", tt.ip, isBlockedIP(ip), tt.blocked)
require.NotNil(t, ip,
"failed to parse IP %s", tt.ip,
)
assert.Equal(t,
tt.blocked,
delivery.ExportIsBlockedIP(ip),
"isBlockedIP(%s) = %v, want %v",
tt.ip,
delivery.ExportIsBlockedIP(ip),
tt.blocked,
)
})
}
}
@@ -89,8 +87,14 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
for _, u := range blockedURLs {
t.Run(u, func(t *testing.T) {
t.Parallel()
err := ValidateTargetURL(u)
assert.Error(t, err, "URL %s should be blocked", u)
err := delivery.ValidateTargetURL(
context.Background(), u,
)
assert.Error(t, err,
"URL %s should be blocked", u,
)
})
}
}
@@ -98,7 +102,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel()
// These are public IPs and should be allowed
allowedURLs := []string{
"https://example.com/hook",
"http://93.184.216.34/webhook",
@@ -108,35 +111,62 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
for _, u := range allowedURLs {
t.Run(u, func(t *testing.T) {
t.Parallel()
err := ValidateTargetURL(u)
assert.NoError(t, err, "URL %s should be allowed", u)
err := delivery.ValidateTargetURL(
context.Background(), u,
)
assert.NoError(t, err,
"URL %s should be allowed", u,
)
})
}
}
func TestValidateTargetURL_InvalidScheme(t *testing.T) {
t.Parallel()
err := ValidateTargetURL("ftp://example.com/hook")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported URL scheme")
err := delivery.ValidateTargetURL(
context.Background(), "ftp://example.com/hook",
)
require.Error(t, err)
assert.Contains(t, err.Error(),
"unsupported URL scheme",
)
}
func TestValidateTargetURL_EmptyHost(t *testing.T) {
t.Parallel()
err := ValidateTargetURL("http:///path")
err := delivery.ValidateTargetURL(
context.Background(), "http:///path",
)
assert.Error(t, err)
}
func TestValidateTargetURL_InvalidURL(t *testing.T) {
t.Parallel()
err := ValidateTargetURL("://invalid")
err := delivery.ValidateTargetURL(
context.Background(), "://invalid",
)
assert.Error(t, err)
}
func TestBlockedNetworks_Initialized(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, blockedNetworks, "blockedNetworks should be initialized")
// Should have at least the main RFC 1918 + loopback + link-local ranges
assert.GreaterOrEqual(t, len(blockedNetworks), 8,
"should have at least 8 blocked network ranges")
nets := delivery.ExportBlockedNetworks()
assert.NotEmpty(t, nets,
"blockedNetworks should be initialized",
)
assert.GreaterOrEqual(t, len(nets), 8,
"should have at least 8 blocked network ranges",
)
}

View File

@@ -5,7 +5,8 @@ import (
"go.uber.org/fx"
)
// Build-time variables populated from main() and copied into the Globals object.
// Build-time variables populated from main() and copied into the
// Globals object.
//
//nolint:gochecknoglobals // Build-time variables set by main().
var (
@@ -19,7 +20,8 @@ type Globals struct {
Version string
}
// New creates a Globals instance from the package-level build-time variables.
// New creates a Globals instance from the package-level
// build-time variables.
//
//nolint:revive // lc parameter is required by fx even if unused.
func New(lc fx.Lifecycle) (*Globals, error) {
@@ -27,5 +29,6 @@ func New(lc fx.Lifecycle) (*Globals, error) {
Appname: Appname,
Version: Version,
}
return n, nil
}

View File

@@ -1,26 +1,30 @@
package globals
package globals_test
import (
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/globals"
)
func TestNew(t *testing.T) {
// Set test values
Appname = "test-app"
Version = "1.0.0"
func TestGlobalsFields(t *testing.T) {
t.Parallel()
lc := fxtest.NewLifecycle(t)
globals, err := New(lc)
if err != nil {
t.Fatalf("New() error = %v", err)
g := &globals.Globals{
Appname: "test-app",
Version: "1.0.0",
}
if globals.Appname != "test-app" {
t.Errorf("Appname = %v, want %v", globals.Appname, "test-app")
if g.Appname != "test-app" {
t.Errorf(
"Appname = %v, want %v",
g.Appname, "test-app",
)
}
if globals.Version != "1.0.0" {
t.Errorf("Version = %v, want %v", globals.Version, "1.0.0")
if g.Version != "1.0.0" {
t.Errorf(
"Version = %v, want %v",
g.Version, "1.0.0",
)
}
}

View File

@@ -13,11 +13,12 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Render login page
data := map[string]interface{}{
data := map[string]any{
"Error": "",
}
@@ -29,12 +30,14 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Limit request body to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
// Parse form data
if err := r.ParseForm(); err != nil {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
@@ -43,85 +46,159 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
// Validate input
if username == "" || password == "" {
data := map[string]interface{}{
"Error": "Username and password are required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "login.html", data)
h.renderLoginError(
w, r,
"Username and password are required",
http.StatusBadRequest,
)
return
}
// Find user in database
var user database.User
if err := h.db.DB().Where("username = ?", username).First(&user).Error; err != nil {
h.log.Debug("user not found", "username", username)
data := map[string]interface{}{
"Error": "Invalid username or password",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, "login.html", data)
return
}
// Verify password
valid, err := database.VerifyPassword(password, user.Password)
user, err := h.authenticateUser(
w, r, username, password,
)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !valid {
h.log.Debug("invalid password", "username", username)
data := map[string]interface{}{
"Error": "Invalid username or password",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, "login.html", data)
return
}
// Get the current session (may be pre-existing / attacker-set)
oldSess, err := h.session.Get(r)
err = h.createAuthenticatedSession(w, r, user)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Regenerate the session to prevent session fixation attacks.
// This destroys the old session ID and creates a new one.
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error("failed to regenerate session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Set user in session
h.session.SetUser(sess, user.ID, user.Username)
// Save session
if err := h.session.Save(r, w, sess); err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.log.Info("user logged in", "username", username, "user_id", user.ID)
h.log.Info(
"user logged in",
"username", username,
"user_id", user.ID,
)
// Redirect to home page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// renderLoginError renders the login page with an error message.
func (h *Handlers) renderLoginError(
w http.ResponseWriter,
r *http.Request,
msg string,
status int,
) {
data := map[string]any{
"Error": msg,
}
w.WriteHeader(status)
h.renderTemplate(w, r, "login.html", data)
}
// authenticateUser looks up and verifies a user's credentials.
// On failure it writes an HTTP response and returns an error.
func (h *Handlers) authenticateUser(
w http.ResponseWriter,
r *http.Request,
username, password string,
) (database.User, error) {
var user database.User
err := h.db.DB().Where(
"username = ?", username,
).First(&user).Error
if err != nil {
h.log.Debug("user not found", "username", username)
h.renderLoginError(
w, r,
"Invalid username or password",
http.StatusUnauthorized,
)
return user, err
}
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return user, err
}
if !valid {
h.log.Debug("invalid password", "username", username)
h.renderLoginError(
w, r,
"Invalid username or password",
http.StatusUnauthorized,
)
return user, errInvalidPassword
}
return user, nil
}
// createAuthenticatedSession regenerates the session and stores
// user info. On failure it writes an HTTP response and returns
// an error.
func (h *Handlers) createAuthenticatedSession(
w http.ResponseWriter,
r *http.Request,
user database.User,
) error {
oldSess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
h.session.SetUser(sess, user.ID, user.Username)
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return err
}
return nil
}
// HandleLogout handles user logout
func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
@@ -129,8 +206,12 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
h.session.Destroy(sess)
// Save the destroyed session
if err := h.session.Save(r, w, sess); err != nil {
h.log.Error("failed to save destroyed session", "error", err)
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error(
"failed to save destroyed session",
"error", err,
)
}
// Redirect to login page

View File

@@ -0,0 +1,24 @@
package handlers
import "net/http"
// RenderTemplateForTest exposes renderTemplate for use in the
// handlers_test package.
func (s *Handlers) RenderTemplateForTest(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
) {
s.renderTemplate(w, r, pageTemplate, data)
}
// BuildSlackTargetConfigForTest exposes buildSlackTargetConfig
// for use in the handlers_test package.
func (s *Handlers) BuildSlackTargetConfigForTest(
w http.ResponseWriter,
r *http.Request,
targetURL string,
) (string, error) {
return s.buildSlackTargetConfig(w, r, targetURL)
}

View File

@@ -1,9 +1,11 @@
// Package handlers provides HTTP request handlers for the webhooker web UI and API.
// Package handlers provides HTTP request handlers for the
// webhooker web UI and API.
package handlers
import (
"context"
"encoding/json"
"errors"
"html/template"
"log/slog"
"net/http"
@@ -19,9 +21,24 @@ import (
"sneak.berlin/go/webhooker/templates"
)
const (
// maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20
// recentEventLimit is the number of recent events to show.
recentEventLimit = 20
// defaultRetentionDays is the default event retention period.
defaultRetentionDays = 30
// paginationPerPage is the number of items per page.
paginationPerPage = 25
)
// errInvalidPassword is returned when a password does not match.
var errInvalidPassword = errors.New("invalid password")
//nolint:revive // HandlersParams is a standard fx naming convention.
type HandlersParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Database *database.Database
@@ -31,7 +48,8 @@ type HandlersParams struct {
Notifier delivery.Notifier
}
// Handlers provides HTTP handler methods for all application routes.
// Handlers provides HTTP handler methods for all application
// routes.
type Handlers struct {
params *HandlersParams
log *slog.Logger
@@ -43,20 +61,29 @@ type Handlers struct {
templates map[string]*template.Template
}
// parsePageTemplate parses a page-specific template set from the embedded FS.
// Each page template is combined with the shared base, htmlheader, and navbar templates.
// The page file must be listed first so that its root action ({{template "base" .}})
// becomes the template set's entry point. If a shared partial (e.g. htmlheader.html)
// is listed first, its {{define}} block becomes the root — which is empty — and
// Execute() produces no output.
// parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared
// base, htmlheader, and navbar templates. The page file must be
// listed first so that its root action ({{template "base" .}})
// becomes the template set's entry point.
func parsePageTemplate(pageFile string) *template.Template {
return template.Must(
template.ParseFS(templates.Templates, pageFile, "base.html", "htmlheader.html", "navbar.html"),
template.ParseFS(
templates.Templates,
pageFile,
"base.html",
"htmlheader.html",
"navbar.html",
),
)
}
// New creates a Handlers instance, parsing all page templates at startup.
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
// New creates a Handlers instance, parsing all page templates at
// startup.
func New(
lc fx.Lifecycle,
params HandlersParams,
) (*Handlers, error) {
s := new(Handlers)
s.params = &params
s.log = params.Logger.Get()
@@ -68,7 +95,6 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
// Parse all page templates once at startup
s.templates = map[string]*template.Template{
"index.html": parsePageTemplate("index.html"),
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
@@ -83,13 +109,19 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
return nil
},
})
return s, nil
}
//nolint:unparam // r parameter will be used in the future for request context.
func (s *Handlers) respondJSON(w http.ResponseWriter, _ *http.Request, data interface{}, status int) {
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
err := json.NewEncoder(w).Encode(data)
if err != nil {
@@ -98,9 +130,15 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, _ *http.Request, data inte
}
}
//nolint:unparam,unused // will be used for handling JSON requests.
func (s *Handlers) decodeJSON(_ http.ResponseWriter, r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
// serverError logs an error and sends a 500 response.
func (s *Handlers) serverError(
w http.ResponseWriter, msg string, err error,
) {
s.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// UserInfo represents user information for templates
@@ -109,48 +147,66 @@ type UserInfo struct {
Username string
}
// renderTemplate renders a pre-parsed template with common data
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
tmpl, ok := s.templates[pageTemplate]
if !ok {
s.log.Error("template not found", "template", pageTemplate)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get user from session if available
var userInfo *UserInfo
sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) {
if username, ok := s.session.GetUsername(sess); ok {
if userID, ok := s.session.GetUserID(sess); ok {
userInfo = &UserInfo{
ID: userID,
Username: username,
}
}
}
}
// Get CSRF token from request context (set by CSRF middleware)
csrfToken := middleware.CSRFToken(r)
// If data is a map, merge user info and CSRF token into it
if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo
m["CSRFToken"] = csrfToken
if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Wrap data with base template data
type templateDataWrapper struct {
// templateDataWrapper wraps non-map data with common fields.
type templateDataWrapper struct {
User *UserInfo
CSRFToken string
Data interface{}
Data any
}
// getUserInfo extracts user info from the session.
func (s *Handlers) getUserInfo(
r *http.Request,
) *UserInfo {
sess, err := s.session.Get(r)
if err != nil || !s.session.IsAuthenticated(sess) {
return nil
}
username, ok := s.session.GetUsername(sess)
if !ok {
return nil
}
userID, ok := s.session.GetUserID(sess)
if !ok {
return nil
}
return &UserInfo{ID: userID, Username: username}
}
// renderTemplate renders a pre-parsed template with common
// data
func (s *Handlers) renderTemplate(
w http.ResponseWriter,
r *http.Request,
pageTemplate string,
data any,
) {
tmpl, ok := s.templates[pageTemplate]
if !ok {
s.log.Error(
"template not found",
"template", pageTemplate,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r)
if m, ok := data.(map[string]any); ok {
m["User"] = userInfo
m["CSRFToken"] = csrfToken
s.executeTemplate(w, tmpl, m)
return
}
wrapper := templateDataWrapper{
@@ -159,8 +215,23 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTe
Data: data,
}
if err := tmpl.Execute(w, wrapper); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
s.executeTemplate(w, tmpl, wrapper)
}
// executeTemplate runs the template and handles errors.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
tmpl *template.Template,
data any,
) {
err := tmpl.Execute(w, data)
if err != nil {
s.log.Error(
"failed to execute template", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
}

View File

@@ -1,10 +1,10 @@
package handlers
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -14,20 +14,23 @@ import (
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// noopNotifier is a no-op delivery.Notifier for tests.
type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.Task) {}
func TestHandleIndex(t *testing.T) {
var h *Handlers
func newTestApp(
t *testing.T,
targets ...any,
) *fxtest.App {
t.Helper()
app := fxtest.New(
return fxtest.New(
t,
fx.Provide(
globals.New,
@@ -41,92 +44,145 @@ func TestHandleIndex(t *testing.T) {
database.NewWebhookDBManager,
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
New,
func() delivery.Notifier {
return &noopNotifier{}
},
handlers.New,
),
fx.Populate(&h),
fx.Populate(targets...),
)
app.RequireStart()
defer app.RequireStop()
}
func TestHandleIndex_Unauthenticated(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w := httptest.NewRecorder()
// Since we can't test actual template rendering without templates,
// let's test that the handler is created and doesn't panic
handler := h.HandleIndex()
assert.NotNil(t, handler)
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, "/pages/login", w.Header().Get("Location"),
)
}
func TestHandleIndex_Authenticated(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s, err := sess.Get(req)
require.NoError(t, err)
sess.SetUser(s, "test-user-id", "testuser")
err = sess.Save(req, w, s)
require.NoError(t, err)
req2 := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
for _, cookie := range w.Result().Cookies() {
req2.AddCookie(cookie)
}
w2 := httptest.NewRecorder()
h.HandleIndex().ServeHTTP(w2, req2)
assert.Equal(t, http.StatusSeeOther, w2.Code)
assert.Equal(
t, "/sources", w2.Header().Get("Location"),
)
}
func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://93.184.216.34/services/T00/B00/xxx",
)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, cfg, "webhookUrl")
}
func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, "/", nil)
w := httptest.NewRecorder()
cfg, err := h.BuildSlackTargetConfigForTest(
w, req, "http://169.254.169.254/latest/meta-data/",
)
require.Error(t, err)
assert.Empty(t, cfg)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRenderTemplate(t *testing.T) {
var h *Handlers
t.Parallel()
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{
DataDir: t.TempDir(),
}
},
database.New,
database.NewWebhookDBManager,
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
New,
),
fx.Populate(&h),
)
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
defer app.RequireStop()
t.Run("handles missing templates gracefully", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
t.Cleanup(app.RequireStop)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w := httptest.NewRecorder()
data := map[string]interface{}{
"Version": "1.0.0",
}
data := map[string]any{"Version": "1.0.0"}
// When a non-existent template name is requested, renderTemplate
// should return an internal server error
h.renderTemplate(w, req, "nonexistent.html", data)
h.RenderTemplateForTest(
w, req, "nonexistent.html", data,
)
// Should return internal server error when template is not found
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
}
func TestFormatUptime(t *testing.T) {
tests := []struct {
name string
duration string
expected string
}{
{
name: "minutes only",
duration: "45m",
expected: "45m",
},
{
name: "hours and minutes",
duration: "2h30m",
expected: "2h 30m",
},
{
name: "days, hours and minutes",
duration: "25h45m",
expected: "1d 1h 45m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, err := time.ParseDuration(tt.duration)
require.NoError(t, err)
result := formatUptime(d)
assert.Equal(t, tt.expected, result)
})
}
assert.Equal(
t, http.StatusInternalServerError, w.Code,
)
}

View File

@@ -4,10 +4,13 @@ import (
"net/http"
)
// HandleHealthCheck returns an HTTP handler that reports application health.
const httpStatusOK = 200
// HandleHealthCheck returns an HTTP handler that reports
// application health.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
resp := s.hc.Healthcheck()
s.respondJSON(w, req, resp, 200)
s.respondJSON(w, req, resp, httpStatusOK)
}
}

View File

@@ -1,50 +1,21 @@
package handlers
import (
"fmt"
"net/http"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleIndex returns an HTTP handler that renders the application dashboard.
// HandleIndex returns a handler for the root path that redirects
// based on authentication state: authenticated users go to /sources
// (the dashboard), unauthenticated users go to the login page.
func (s *Handlers) HandleIndex() http.HandlerFunc {
// Calculate server start time
startTime := time.Now()
return func(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/sources", http.StatusSeeOther)
return func(w http.ResponseWriter, req *http.Request) {
// Calculate uptime
uptime := time.Since(startTime)
uptimeStr := formatUptime(uptime)
// Get user count from database
var userCount int64
s.db.DB().Model(&database.User{}).Count(&userCount)
// Prepare template data
data := map[string]interface{}{
"Version": s.params.Globals.Version,
"Uptime": uptimeStr,
"UserCount": userCount,
return
}
// Render the template
s.renderTemplate(w, req, "index.html", data)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
}
}
// formatUptime formats a duration into a human-readable string
func formatUptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}

View File

@@ -13,14 +13,18 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
http.NotFound(w, r)
return
}
// Get session
// Get session. RequireAuth middleware guarantees an
// authenticated session before this handler runs, so we
// only need to guard against an unexpected retrieval error.
sess, err := h.session.Get(r)
if err != nil || !h.session.IsAuthenticated(sess) {
// Redirect to login if not authenticated
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -29,6 +33,7 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
if !ok {
h.log.Error("authenticated session missing username")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -36,17 +41,19 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
if !ok {
h.log.Error("authenticated session missing user ID")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// For now, only allow users to view their own profile
if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Prepare data for template
data := map[string]interface{}{
data := map[string]any{
"User": &UserInfo{
ID: sessionUserID,
Username: sessionUsername,

View File

@@ -0,0 +1,159 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi"
"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/logger"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session"
)
// authenticatedCookies creates an authenticated session for the given
// user and returns the resulting cookies for use on a later request.
func authenticatedCookies(
t *testing.T,
sess *session.Session,
userID, username string,
) []*http.Cookie {
t.Helper()
setupReq := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/setup", nil,
)
setupW := httptest.NewRecorder()
s, err := sess.Get(setupReq)
require.NoError(t, err)
sess.SetUser(s, userID, username)
require.NoError(t, sess.Save(setupReq, setupW, s))
cookies := setupW.Result().Cookies()
require.NotEmpty(t, cookies, "session cookie should be set")
return cookies
}
// profileRequest builds a GET request for the given profile username,
// attaching the supplied cookies and the chi URL parameter that the
// handler reads via chi.URLParam.
func profileRequest(
username string,
cookies []*http.Cookie,
) *http.Request {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/user/"+username, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add("username", username)
return req.WithContext(
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
)
}
func TestHandleProfile_OwnProfile_OK(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
req := profileRequest("testuser", cookies)
w := httptest.NewRecorder()
h.HandleProfile().ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
var sess *session.Session
app := newTestApp(t, &h, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
req := profileRequest("otheruser", cookies)
w := httptest.NewRecorder()
h.HandleProfile().ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
// TestUserRoute_Unauthenticated_RedirectedByMiddleware exercises the
// /user/{username} route group's middleware chain (CSRF then
// RequireAuth, matching setupUserRoutes) and proves that an
// unauthenticated request is redirected to /pages/login at the
// middleware layer, never reaching the endpoint handler.
func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
t.Parallel()
var log *logger.Logger
var cfg *config.Config
var sess *session.Session
app := newTestApp(t, &log, &cfg, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
mw := middleware.NewForTest(log.Get(), cfg, sess)
var handlerReached bool
router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF())
r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
})
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/user/testuser", nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.False(
t, handlerReached,
"handler must not be reached for unauthenticated request",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,31 +6,36 @@ import (
"net/http"
"github.com/go-chi/chi"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
const (
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
maxWebhookBodySize = 1 << 20
// maxWebhookBodySize is the maximum allowed webhook
// request body (1 MB).
maxWebhookBodySize = 1 << maxBodyShift
)
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
// Only POST requests are accepted; all other methods return 405 Method Not Allowed.
// Events and deliveries are stored in the per-webhook database. The handler
// builds self-contained Task structs with all target and event data
// so the delivery engine can process them without additional DB reads.
// HandleWebhook handles incoming webhook requests at entrypoint
// URLs.
func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
http.Error(
w,
"Method Not Allowed",
http.StatusMethodNotAllowed,
)
return
}
entrypointUUID := chi.URLParam(r, "uuid")
if entrypointUUID == "" {
http.NotFound(w, r)
return
}
@@ -40,69 +45,241 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
"remote_addr", r.RemoteAddr,
)
// Look up entrypoint by path (from main application DB)
var entrypoint database.Entrypoint
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint)
if result.Error != nil {
h.log.Debug("entrypoint not found", "path", entrypointUUID)
http.NotFound(w, r)
entrypoint, ok := h.lookupEntrypoint(
w, r, entrypointUUID,
)
if !ok {
return
}
// Check if active
if !entrypoint.Active {
http.Error(w, "Gone", http.StatusGone)
return
}
// Read body with size limit
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil {
h.log.Error("failed to read request body", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
h.processWebhookRequest(w, r, entrypoint)
}
if len(body) > maxWebhookBodySize {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
}
// processWebhookRequest reads the body, serializes headers,
// loads targets, and delivers the event.
func (h *Handlers) processWebhookRequest(
w http.ResponseWriter,
r *http.Request,
entrypoint database.Entrypoint,
) {
body, ok := h.readWebhookBody(w, r)
if !ok {
return
}
// Serialize headers as JSON
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.log.Error("failed to serialize headers", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
h.serverError(w, "failed to serialize headers", err)
return
}
// Find all active targets for this webhook (from main application DB)
var targets []database.Target
if targetErr := h.db.DB().Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; targetErr != nil {
h.log.Error("failed to query targets", "error", targetErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get the per-webhook database for event storage
webhookDB, err := h.dbMgr.GetDB(entrypoint.WebhookID)
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil {
h.log.Error("failed to get webhook database",
"webhook_id", entrypoint.WebhookID,
"error", err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
h.serverError(w, "failed to query targets", err)
return
}
// Create the event and deliveries in a transaction on the per-webhook DB
h.createAndDeliverEvent(
w, r, entrypoint, body, headersJSON, targets,
)
}
// loadActiveTargets returns all active targets for a webhook.
func (h *Handlers) loadActiveTargets(
webhookID string,
) ([]database.Target, error) {
var targets []database.Target
err := h.db.DB().Where(
"webhook_id = ? AND active = ?",
webhookID, true,
).Find(&targets).Error
return targets, err
}
// lookupEntrypoint finds an entrypoint by UUID path.
func (h *Handlers) lookupEntrypoint(
w http.ResponseWriter,
r *http.Request,
entrypointUUID string,
) (database.Entrypoint, bool) {
var entrypoint database.Entrypoint
result := h.db.DB().Where(
"path = ?", entrypointUUID,
).First(&entrypoint)
if result.Error != nil {
h.log.Debug(
"entrypoint not found",
"path", entrypointUUID,
)
http.NotFound(w, r)
return entrypoint, false
}
return entrypoint, true
}
// readWebhookBody reads and validates the request body size.
func (h *Handlers) readWebhookBody(
w http.ResponseWriter,
r *http.Request,
) ([]byte, bool) {
body, err := io.ReadAll(
io.LimitReader(r.Body, maxWebhookBodySize+1),
)
if err != nil {
h.log.Error(
"failed to read request body", "error", err,
)
http.Error(
w, "Bad request", http.StatusBadRequest,
)
return nil, false
}
if len(body) > maxWebhookBodySize {
http.Error(
w,
"Request body too large",
http.StatusRequestEntityTooLarge,
)
return nil, false
}
return body, true
}
// createAndDeliverEvent creates the event and delivery records
// then notifies the delivery engine.
func (h *Handlers) createAndDeliverEvent(
w http.ResponseWriter,
r *http.Request,
entrypoint database.Entrypoint,
body, headersJSON []byte,
targets []database.Target,
) {
tx, err := h.beginWebhookTx(w, entrypoint.WebhookID)
if err != nil {
return
}
event := h.buildEvent(r, entrypoint, headersJSON, body)
err = tx.Create(event).Error
if err != nil {
tx.Rollback()
h.serverError(w, "failed to create event", err)
return
}
bodyPtr := inlineBody(body)
tasks := h.buildDeliveryTasks(
w, tx, event, entrypoint, targets, bodyPtr,
)
if tasks == nil {
return
}
err = tx.Commit().Error
if err != nil {
h.serverError(w, "failed to commit transaction", err)
return
}
h.finishWebhookResponse(w, event, entrypoint, tasks)
}
// beginWebhookTx opens a transaction on the per-webhook DB.
func (h *Handlers) beginWebhookTx(
w http.ResponseWriter,
webhookID string,
) (*gorm.DB, error) {
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
h.serverError(
w, "failed to get webhook database", err,
)
return nil, err
}
tx := webhookDB.Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
h.serverError(
w, "failed to begin transaction", tx.Error,
)
return nil, tx.Error
}
event := &database.Event{
return tx, nil
}
// inlineBody returns a pointer to body as a string if it fits
// within the inline size limit, or nil otherwise.
func inlineBody(body []byte) *string {
if len(body) < delivery.MaxInlineBodySize {
s := string(body)
return &s
}
return nil
}
// finishWebhookResponse notifies the delivery engine, logs the
// event, and writes the HTTP response.
func (h *Handlers) finishWebhookResponse(
w http.ResponseWriter,
event *database.Event,
entrypoint database.Entrypoint,
tasks []delivery.Task,
) {
if len(tasks) > 0 {
h.notifier.Notify(tasks)
}
h.log.Info("webhook event created",
"event_id", event.ID,
"webhook_id", entrypoint.WebhookID,
"entrypoint_id", entrypoint.ID,
"target_count", len(tasks),
)
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`{"status":"ok"}`))
if err != nil {
h.log.Error(
"failed to write response", "error", err,
)
}
}
// buildEvent creates a new Event struct from request data.
func (h *Handlers) buildEvent(
r *http.Request,
entrypoint database.Entrypoint,
headersJSON, body []byte,
) *database.Event {
return &database.Event{
WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID,
Method: r.Method,
@@ -110,38 +287,42 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
Body: string(body),
ContentType: r.Header.Get("Content-Type"),
}
}
if err := tx.Create(event).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create event", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Prepare body pointer for inline transport (≤16KB bodies are
// included in the Task so the engine needs no DB read).
var bodyPtr *string
if len(body) < delivery.MaxInlineBodySize {
bodyStr := string(body)
bodyPtr = &bodyStr
}
// Create delivery records and build self-contained delivery tasks
// buildDeliveryTasks creates delivery records in the
// transaction and returns tasks for the delivery engine.
// Returns nil if an error occurred.
func (h *Handlers) buildDeliveryTasks(
w http.ResponseWriter,
tx *gorm.DB,
event *database.Event,
entrypoint database.Entrypoint,
targets []database.Target,
bodyPtr *string,
) []delivery.Task {
tasks := make([]delivery.Task, 0, len(targets))
for i := range targets {
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targets[i].ID,
Status: database.DeliveryStatusPending,
}
if err := tx.Create(dlv).Error; err != nil {
err := tx.Create(dlv).Error
if err != nil {
tx.Rollback()
h.log.Error("failed to create delivery",
h.log.Error(
"failed to create delivery",
"target_id", targets[i].ID,
"error", err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return nil
}
tasks = append(tasks, delivery.Task{
@@ -161,31 +342,5 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
})
}
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit transaction", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Notify the delivery engine with self-contained delivery tasks.
// Each task carries all target config and event data inline so
// the engine can deliver without touching any database (in the
// ≤16KB happy path). The engine only writes to the DB to record
// delivery results after each attempt.
if len(tasks) > 0 {
h.notifier.Notify(tasks)
}
h.log.Info("webhook event created",
"event_id", event.ID,
"webhook_id", entrypoint.WebhookID,
"entrypoint_id", entrypoint.ID,
"target_count", len(targets),
)
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
h.log.Error("failed to write response", "error", err)
}
}
return tasks
}

View File

@@ -16,6 +16,7 @@ import (
//nolint:revive // HealthcheckParams is a standard fx naming convention.
type HealthcheckParams struct {
fx.In
Globals *globals.Globals
Config *config.Config
Logger *logger.Logger
@@ -29,43 +30,34 @@ type Healthcheck struct {
params *HealthcheckParams
}
// New creates a Healthcheck that records the startup time on fx start.
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
// New creates a Healthcheck that records the startup time on fx
// start.
func New(
lc fx.Lifecycle,
params HealthcheckParams,
) (*Healthcheck, error) {
s := new(Healthcheck)
s.params = &params
s.log = params.Logger.Get()
lc.Append(fx.Hook{
//nolint:revive // ctx unused but required by fx.
OnStart: func(_ context.Context) error {
s.StartupTime = time.Now()
return nil
},
OnStop: func(_ context.Context) error {
return nil
},
})
return s, nil
}
//nolint:revive // HealthcheckResponse is a clear, descriptive name.
type HealthcheckResponse struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptime_seconds"`
UptimeHuman string `json:"uptime_human"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenance_mode"`
}
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}
// Healthcheck returns the current health status of the application.
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
resp := &HealthcheckResponse{
// Healthcheck returns the current health status of the
// application.
func (s *Healthcheck) Healthcheck() *Response {
resp := &Response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(s.uptime().Seconds()),
@@ -74,5 +66,21 @@ func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
Version: s.params.Globals.Version,
Maintenance: s.params.Config.MaintenanceMode,
}
return resp
}
// Response contains the JSON-serialised health status.
type Response struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptimeSeconds"`
UptimeHuman string `json:"uptimeHuman"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"`
}
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}

View File

@@ -1,4 +1,5 @@
// Package logger provides structured logging with dynamic level control.
// Package logger provides structured logging with dynamic level
// control.
package logger
import (
@@ -14,17 +15,20 @@ import (
//nolint:revive // LoggerParams is a standard fx naming convention.
type LoggerParams struct {
fx.In
Globals *globals.Globals
}
// Logger wraps slog with dynamic level control and structured output.
// Logger wraps slog with dynamic level control and structured
// output.
type Logger struct {
logger *slog.Logger
levelVar *slog.LevelVar
params LoggerParams
}
// New creates a Logger that outputs text (TTY) or JSON (non-TTY) to stdout.
// New creates a Logger that outputs text (TTY) or JSON (non-TTY)
// to stdout.
//
//nolint:revive // lc parameter is required by fx even if unused.
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
@@ -48,11 +52,15 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
if t, ok := a.Value.Any().(time.Time); ok {
return slog.Time(slog.TimeKey, t.UTC())
}
return a
}
return a
}
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: l.levelVar,
ReplaceAttr: replaceAttr,
@@ -93,7 +101,8 @@ func (l *Logger) Identify() {
)
}
// Writer returns an io.Writer suitable for standard library loggers.
// Writer returns an io.Writer suitable for standard library
// loggers.
func (l *Logger) Writer() io.Writer {
return os.Stdout
}

View File

@@ -1,63 +1,59 @@
package logger
package logger_test
import (
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
func testGlobals() *globals.Globals {
return &globals.Globals{
Appname: "test-app",
Version: "1.0.0",
}
}
func TestNew(t *testing.T) {
// Set up globals
globals.Appname = "test-app"
globals.Version = "1.0.0"
t.Parallel()
lc := fxtest.NewLifecycle(t)
g, err := globals.New(lc)
if err != nil {
t.Fatalf("globals.New() error = %v", err)
params := logger.LoggerParams{
Globals: testGlobals(),
}
params := LoggerParams{
Globals: g,
}
logger, err := New(lc, params)
l, err := logger.New(lc, params)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if logger.Get() == nil {
if l.Get() == nil {
t.Error("Get() returned nil logger")
}
// Test that we can log without panic
logger.Get().Info("test message", "key", "value")
l.Get().Info("test message", "key", "value")
}
func TestEnableDebugLogging(t *testing.T) {
// Set up globals
globals.Appname = "test-app"
globals.Version = "1.0.0"
t.Parallel()
lc := fxtest.NewLifecycle(t)
g, err := globals.New(lc)
if err != nil {
t.Fatalf("globals.New() error = %v", err)
params := logger.LoggerParams{
Globals: testGlobals(),
}
params := LoggerParams{
Globals: g,
}
logger, err := New(lc, params)
l, err := logger.New(lc, params)
if err != nil {
t.Fatalf("New() error = %v", err)
}
// Enable debug logging should not panic
logger.EnableDebugLogging()
l.EnableDebugLogging()
// Test debug logging
logger.Get().Debug("debug message", "test", true)
l.Get().Debug("debug message", "test", true)
}

View File

@@ -12,6 +12,13 @@ func CSRFToken(r *http.Request) string {
return csrf.Token(r)
}
// isClientTLS reports whether the client-facing connection uses TLS.
// It checks for a direct TLS connection (r.TLS) or a TLS-terminating
// reverse proxy that sets the standard X-Forwarded-Proto header.
func isClientTLS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
// CSRF returns middleware that provides CSRF protection using the
// gorilla/csrf library. The middleware uses the session authentication
// key to sign a CSRF cookie and validates a masked token submitted via
@@ -19,17 +26,22 @@ func CSRFToken(r *http.Request) string {
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token receive a 403 Forbidden response.
//
// In development mode, requests are marked as plaintext HTTP so that
// gorilla/csrf skips the strict Referer-origin check (which is only
// meaningful over TLS).
// The middleware detects the client-facing transport protocol per-request
// using r.TLS and the X-Forwarded-Proto header. This allows correct
// behavior in all deployment scenarios:
//
// - Direct HTTPS: strict Referer/Origin checks, Secure cookies.
// - Behind a TLS-terminating reverse proxy: strict checks (the
// browser is on HTTPS, so Origin/Referer headers use https://),
// Secure cookies (the browser sees HTTPS from the proxy).
// - Direct HTTP: relaxed Referer/Origin checks via PlaintextHTTPRequest,
// non-Secure cookies so the browser sends them over HTTP.
//
// Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
protect := csrf.Protect(
m.session.GetKey(),
csrf.FieldName("csrf_token"),
csrf.Secure(!m.params.Config.IsDev()),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.Path("/"),
csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.log.Warn("csrf: token validation failed",
"method", r.Method,
"path", r.URL.Path,
@@ -37,20 +49,36 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
"reason", csrf.FailureReason(r),
)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
})),
)
})
key := m.session.GetKey()
baseOpts := []csrf.Option{
csrf.FieldName("csrf_token"),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.Path("/"),
csrf.ErrorHandler(csrfErrorHandler),
}
// Two middleware instances with different Secure flags but the
// same signing key, so cookies are interchangeable between them.
tlsProtect := csrf.Protect(key, append(baseOpts, csrf.Secure(true))...)
httpProtect := csrf.Protect(key, append(baseOpts, csrf.Secure(false))...)
// In development (plaintext HTTP), signal gorilla/csrf to skip
// the strict TLS Referer check by injecting the PlaintextHTTP
// context key before the CSRF handler sees the request.
if m.params.Config.IsDev() {
return func(next http.Handler) http.Handler {
csrfHandler := protect(next)
tlsCSRF := tlsProtect(next)
httpCSRF := httpProtect(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
csrfHandler.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
if isClientTLS(r) {
// Client is on TLS (directly or via reverse proxy).
// Use Secure cookies and strict Origin/Referer checks.
tlsCSRF.ServeHTTP(w, r)
} else {
// Plaintext HTTP: use non-Secure cookies and tell
// gorilla/csrf to use "http" for scheme comparisons,
// skipping the strict Referer check that assumes TLS.
httpCSRF.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
}
})
}
}
return protect
}

View File

@@ -1,6 +1,8 @@
package middleware
package middleware_test
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"net/url"
@@ -10,148 +12,483 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
)
// csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf"
// csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies.
func csrfGetToken(
t *testing.T,
csrfMW func(http.Handler) http.Handler,
getReq *http.Request,
) (string, []*http.Cookie) {
t.Helper()
var token string
getHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
token = middleware.CSRFToken(r)
},
))
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
require.NotEmpty(t, cookies, "CSRF cookie should be set")
require.NotEmpty(t, token, "CSRF token should be set")
return token, cookies
}
// csrfPostWithToken performs a POST request with the given CSRF
// token and cookies through the middleware. Returns whether the
// handler was called and the response code.
func csrfPostWithToken(
t *testing.T,
csrfMW func(http.Handler) http.Handler,
postReq *http.Request,
token string,
cookies []*http.Cookie,
) (bool, int) {
t.Helper()
var called bool
postHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
form := url.Values{"csrf_token": {token}}
postReq.Body = http.NoBody
postReq.Body = nil
// Rebuild the request with the form body
rebuilt := httptest.NewRequestWithContext(
context.Background(),
postReq.Method, postReq.URL.String(),
strings.NewReader(form.Encode()),
)
rebuilt.Header = postReq.Header.Clone()
rebuilt.TLS = postReq.TLS
rebuilt.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range cookies {
rebuilt.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, rebuilt)
return called, postW.Code
}
func TestCSRF_GETSetsToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var gotToken string
handler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotToken = CSRFToken(r)
}))
req := httptest.NewRequest(http.MethodGet, "/form", nil)
handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r)
},
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.NotEmpty(t, gotToken, "CSRF token should be set in context on GET")
assert.NotEmpty(
t, gotToken,
"CSRF token should be set in context on GET",
)
}
func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
// Capture the token from a GET request
var token string
csrfMiddleware := m.CSRF()
getHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
token = CSRFToken(r)
}))
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/form", nil,
)
token, cookies := csrfGetToken(t, csrfMW, getReq)
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form", nil,
)
called, _ := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
cookies := getW.Result().Cookies()
require.NotEmpty(t, cookies)
require.NotEmpty(t, token)
// POST with valid token and cookies from the GET response
var called bool
postHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
form := url.Values{"csrf_token": {token}}
postReq := httptest.NewRequest(http.MethodPost, "/form", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.True(t, called, "handler should be called with valid CSRF token")
assert.True(
t, called,
"handler should be called with valid CSRF token",
)
}
func TestCSRF_POSTWithoutToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
// csrfPOSTWithoutTokenTest is a shared helper for testing POST
// requests without a CSRF token in both dev and prod modes.
func csrfPOSTWithoutTokenTest(
t *testing.T,
env string,
msg string,
) {
t.Helper()
csrfMiddleware := m.CSRF()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
getReq := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST without CSRF token
var called bool
postHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
postReq := httptest.NewRequest(http.MethodPost, "/form", nil)
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form", nil,
)
postReq.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, "handler should NOT be called without CSRF token")
assert.False(t, called, msg)
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_POSTWithoutToken(t *testing.T) {
t.Parallel()
csrfPOSTWithoutTokenTest(
t,
config.EnvironmentDev,
"handler should NOT be called without CSRF token",
)
}
func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMiddleware := m.CSRF()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
// GET to establish the CSRF cookie
getHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {},
))
getReq := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST with wrong CSRF token
var called bool
postHandler := csrfMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
postHandler := csrfMW(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
},
))
form := url.Values{"csrf_token": {"invalid-token-value"}}
postReq := httptest.NewRequest(http.MethodPost, "/form", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form",
strings.NewReader(form.Encode()),
)
postReq.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, "handler should NOT be called with invalid CSRF token")
assert.False(
t, called,
"handler should NOT be called with invalid CSRF token",
)
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_GETDoesNotValidate(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// GET requests should pass through without CSRF validation
req := httptest.NewRequest(http.MethodGet, "/form", nil)
handler := m.CSRF()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "GET requests should pass through CSRF middleware")
assert.True(
t, called,
"GET requests should pass through CSRF middleware",
)
}
func TestCSRFToken_NoMiddleware(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Empty(t, CSRFToken(req), "CSRFToken should return empty string when middleware has not run")
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
assert.Empty(
t, middleware.CSRFToken(req),
"CSRFToken should return empty string when "+
"middleware has not run",
)
}
// --- TLS Detection Tests ---
func TestIsClientTLS_DirectTLS(t *testing.T) {
t.Parallel()
r := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
r.TLS = &tls.ConnectionState{}
assert.True(
t, middleware.IsClientTLS(r),
"should detect direct TLS connection",
)
}
func TestIsClientTLS_XForwardedProto(t *testing.T) {
t.Parallel()
r := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
r.Header.Set("X-Forwarded-Proto", "https")
assert.True(
t, middleware.IsClientTLS(r),
"should detect TLS via X-Forwarded-Proto",
)
}
func TestIsClientTLS_PlaintextHTTP(t *testing.T) {
t.Parallel()
r := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
assert.False(
t, middleware.IsClientTLS(r),
"should detect plaintext HTTP",
)
}
func TestIsClientTLS_XForwardedProtoHTTP(t *testing.T) {
t.Parallel()
r := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
r.Header.Set("X-Forwarded-Proto", "http")
assert.False(
t, middleware.IsClientTLS(r),
"should detect plaintext when X-Forwarded-Proto is http",
)
}
// --- Production Mode: POST over plaintext HTTP ---
func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t *testing.T,
) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/form", nil,
)
token, cookies := csrfGetToken(t, csrfMW, getReq)
// Verify cookie is NOT Secure (plaintext HTTP in prod)
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.False(t, c.Secure,
"CSRF cookie should not be Secure "+
"over plaintext HTTP")
}
}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/form", nil,
)
called, code := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(t, called,
"handler should be called -- prod mode over "+
"plaintext HTTP must work")
assert.NotEqual(t, http.StatusForbidden, code,
"should not return 403")
}
// --- Production Mode: POST with X-Forwarded-Proto ---
func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t *testing.T,
) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "http://example.com/form", nil,
)
getReq.Header.Set("X-Forwarded-Proto", "https")
token, cookies := csrfGetToken(t, csrfMW, getReq)
// Verify cookie IS Secure (X-Forwarded-Proto: https)
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.True(t, c.Secure,
"CSRF cookie should be Secure behind "+
"TLS proxy")
}
}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "http://example.com/form", nil,
)
postReq.Header.Set("X-Forwarded-Proto", "https")
postReq.Header.Set("Origin", "https://example.com")
called, code := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(t, called,
"handler should be called -- prod mode behind "+
"TLS proxy must work")
assert.NotEqual(t, http.StatusForbidden, code,
"should not return 403")
}
// --- Production Mode: direct TLS ---
func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t *testing.T,
) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
getReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "https://example.com/form", nil,
)
getReq.TLS = &tls.ConnectionState{}
token, cookies := csrfGetToken(t, csrfMW, getReq)
// Verify cookie IS Secure (direct TLS)
for _, c := range cookies {
if c.Name == csrfCookieName {
assert.True(t, c.Secure,
"CSRF cookie should be Secure over "+
"direct TLS")
}
}
postReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "https://example.com/form", nil,
)
postReq.TLS = &tls.ConnectionState{}
postReq.Header.Set("Origin", "https://example.com")
called, code := csrfPostWithToken(
t, csrfMW, postReq, token, cookies,
)
assert.True(t, called,
"handler should be called -- direct TLS must work")
assert.NotEqual(t, http.StatusForbidden, code,
"should not return 403")
}
// --- Production Mode: POST without token still rejects ---
func TestCSRF_ProdMode_PlaintextHTTP_POSTWithoutToken(
t *testing.T,
) {
t.Parallel()
csrfPOSTWithoutTokenTest(
t,
config.EnvironmentProd,
"handler should NOT be called without CSRF token "+
"even in prod+plaintext",
)
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
)
// NewLoggingResponseWriterForTest wraps newLoggingResponseWriter
// for use in external test packages.
func NewLoggingResponseWriterForTest(
w http.ResponseWriter,
) *loggingResponseWriter {
return newLoggingResponseWriter(w)
}
// LoggingResponseWriterStatusCode returns the status code
// captured by the loggingResponseWriter.
func LoggingResponseWriterStatusCode(
lrw *loggingResponseWriter,
) int {
return lrw.statusCode
}
// IPFromHostPort exposes ipFromHostPort for testing.
func IPFromHostPort(hp string) string {
return ipFromHostPort(hp)
}
// IsClientTLS exposes isClientTLS for testing.
func IsClientTLS(r *http.Request) bool {
return isClientTLS(r)
}
// LoginRateLimitConst exposes the loginRateLimit constant.
const LoginRateLimitConst = loginRateLimit

View File

@@ -1,4 +1,5 @@
// Package middleware provides HTTP middleware for logging, auth, CORS, and metrics.
// Package middleware provides HTTP middleware for logging, auth,
// CORS, and metrics.
package middleware
import (
@@ -20,16 +21,24 @@ import (
"sneak.berlin/go/webhooker/internal/session"
)
const (
// corsMaxAge is the maximum time (in seconds) that a
// preflight response can be cached.
corsMaxAge = 300
)
//nolint:revive // MiddlewareParams is a standard fx naming convention.
type MiddlewareParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Session *session.Session
}
// Middleware provides HTTP middleware for logging, CORS, auth, and metrics.
// Middleware provides HTTP middleware for logging, CORS, auth, and
// metrics.
type Middleware struct {
log *slog.Logger
params *MiddlewareParams
@@ -39,11 +48,15 @@ type Middleware struct {
// New creates a Middleware from the provided fx parameters.
//
//nolint:revive // lc parameter is required by fx even if unused.
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
func New(
lc fx.Lifecycle,
params MiddlewareParams,
) (*Middleware, error) {
s := new(Middleware)
s.params = &params
s.log = params.Logger.Get()
s.session = params.Session
return s, nil
}
@@ -55,19 +68,24 @@ func ipFromHostPort(hp string) string {
if err != nil {
return ""
}
if len(h) > 0 && h[0] == '[' {
return h[1 : len(h)-1]
}
return h
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
// nolint:revive // unexported type is only used internally
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
// newLoggingResponseWriter wraps w and records status codes.
func newLoggingResponseWriter(
w http.ResponseWriter,
) *loggingResponseWriter {
return &loggingResponseWriter{w, http.StatusOK}
}
@@ -76,21 +94,30 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code)
}
// Logging returns middleware that logs each HTTP request with timing and metadata.
// Logging returns middleware that logs each HTTP request with
// timing and metadata.
func (s *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
start := time.Now()
lrw := NewLoggingResponseWriter(w)
lrw := newLoggingResponseWriter(w)
ctx := r.Context()
defer func() {
latency := time.Since(start)
requestID := ""
if reqID := ctx.Value(middleware.RequestIDKey); reqID != nil {
if reqID := ctx.Value(
middleware.RequestIDKey,
); reqID != nil {
if id, ok := reqID.(string); ok {
requestID = id
}
}
s.log.Info("http request",
"request_start", start,
"method", r.Method,
@@ -110,21 +137,29 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
}
}
// CORS returns middleware that sets CORS headers (permissive in dev, no-op in prod).
// CORS returns middleware that sets CORS headers (permissive in
// dev, no-op in prod).
func (s *Middleware) CORS() func(http.Handler) http.Handler {
if s.params.Config.IsDev() {
// In development, allow any origin for local testing.
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
MaxAge: corsMaxAge,
})
}
// In production, the web UI is server-rendered so cross-origin
// requests are not expected. Return a no-op middleware.
// In production, the web UI is server-rendered so
// cross-origin requests are not expected. Return a no-op
// middleware.
return func(next http.Handler) http.Handler {
return next
}
@@ -134,20 +169,33 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
// Unauthenticated users are redirected to the login page.
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
sess, err := s.session.Get(r)
if err != nil {
s.log.Debug("auth middleware: failed to get session", "error", err)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
s.log.Debug(
"auth middleware: failed to get session",
"error", err,
)
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
if !s.session.IsAuthenticated(sess) {
s.log.Debug("auth middleware: unauthenticated request",
s.log.Debug(
"auth middleware: unauthenticated request",
"path", r.URL.Path,
"method", r.Method,
)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
http.Redirect(
w, r, "/pages/login", http.StatusSeeOther,
)
return
}
@@ -161,12 +209,14 @@ func (s *Middleware) Metrics() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{}),
})
return func(next http.Handler) http.Handler {
return std.Handler("", mdlw, next)
}
}
// MetricsAuth returns middleware that protects metrics endpoints with basic auth.
// MetricsAuth returns middleware that protects metrics endpoints
// with basic auth.
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
return basicauth.New(
"metrics",
@@ -178,33 +228,63 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
)
}
// SecurityHeaders returns middleware that sets production security headers
// on every response: HSTS, X-Content-Type-Options, X-Frame-Options, CSP,
// Referrer-Policy, and Permissions-Policy.
// SecurityHeaders returns middleware that sets production security
// headers on every response: HSTS, X-Content-Type-Options,
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
w.Header().Set("X-Content-Type-Options", "nosniff")
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
w.Header().Set(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload",
)
w.Header().Set(
"X-Content-Type-Options", "nosniff",
)
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set(
"Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'",
)
w.Header().Set(
"Referrer-Policy",
"strict-origin-when-cross-origin",
)
w.Header().Set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
)
next.ServeHTTP(w, r)
})
}
}
// MaxBodySize returns middleware that limits the request body size for POST
// requests. If the body exceeds the given limit in bytes, the server returns
// 413 Request Entity Too Large. This prevents clients from sending arbitrarily
// large form bodies.
func (s *Middleware) MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
// MaxBodySize returns middleware that limits the request body size
// for POST requests. If the body exceeds the given limit in
// bytes, the server returns 413 Request Entity Too Large. This
// prevents clients from sending arbitrarily large form bodies.
func (s *Middleware) MaxBodySize(
maxBytes int64,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
if r.Method == http.MethodPost ||
r.Method == http.MethodPut ||
r.Method == http.MethodPatch {
r.Body = http.MaxBytesReader(
w, r.Body, maxBytes,
)
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,6 +1,7 @@
package middleware
package middleware_test
import (
"context"
"encoding/base64"
"log/slog"
"net/http"
@@ -12,25 +13,37 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session"
)
// testMiddleware creates a Middleware with minimal dependencies for testing.
// It uses a real session.Session backed by an in-memory cookie store.
func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) {
const testKeySize = 32
// testMiddleware creates a Middleware with minimal dependencies
// for testing. It uses a real session.Session backed by an
// in-memory cookie store.
func testMiddleware(
t *testing.T,
env string,
) (*middleware.Middleware, *session.Session) {
t.Helper()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
cfg := &config.Config{
Environment: env,
}
// Create a real session manager with a known key
key := make([]byte, 32)
key := make([]byte, testKeySize)
for i := range key {
key[i] = byte(i)
}
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{
Path: "/",
@@ -40,40 +53,33 @@ func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) {
SameSite: http.SameSiteLaxMode,
}
sessManager := newTestSession(t, store, cfg, log, key)
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
m := middleware.NewForTest(log, cfg, sessManager)
return m, sessManager
}
// newTestSession creates a session.Session with a pre-configured cookie store
// for testing. This avoids needing the fx lifecycle and database.
func newTestSession(t *testing.T, store *sessions.CookieStore, cfg *config.Config, log *slog.Logger, key []byte) *session.Session {
t.Helper()
return session.NewForTest(store, cfg, log, key)
}
// --- Logging Middleware Tests ---
func TestLogging_SetsStatusCode(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := m.Logging()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
if _, err := w.Write([]byte("created")); err != nil {
_, err := w.Write([]byte("created"))
if err != nil {
return
}
}))
},
))
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -84,15 +90,20 @@ func TestLogging_SetsStatusCode(t *testing.T) {
func TestLogging_DefaultStatusOK(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte("ok")); err != nil {
handler := m.Logging()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte("ok"))
if err != nil {
return
}
}))
},
))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -103,20 +114,31 @@ func TestLogging_DefaultStatusOK(t *testing.T) {
func TestLogging_PassesThroughToNext(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/webhook", nil)
handler := m.Logging()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/api/webhook", nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "logging middleware should call the next handler")
assert.True(
t, called,
"logging middleware should call the next handler",
)
}
// --- LoggingResponseWriter Tests ---
@@ -125,24 +147,33 @@ func TestLoggingResponseWriter_CapturesStatusCode(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
lrw := NewLoggingResponseWriter(w)
lrw := middleware.NewLoggingResponseWriterForTest(w)
// Default should be 200
assert.Equal(t, http.StatusOK, lrw.statusCode)
assert.Equal(
t, http.StatusOK,
middleware.LoggingResponseWriterStatusCode(lrw),
)
// WriteHeader should capture the status code
lrw.WriteHeader(http.StatusNotFound)
assert.Equal(t, http.StatusNotFound, lrw.statusCode)
assert.Equal(
t, http.StatusNotFound,
middleware.LoggingResponseWriterStatusCode(lrw),
)
// Underlying writer should also get the status code
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(t *testing.T) {
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(
t *testing.T,
) {
t.Parallel()
w := httptest.NewRecorder()
lrw := NewLoggingResponseWriter(w)
lrw := middleware.NewLoggingResponseWriterForTest(w)
n, err := lrw.Write([]byte("hello world"))
require.NoError(t, err)
@@ -154,79 +185,124 @@ func TestLoggingResponseWriter_WriteDelegatesToUnderlying(t *testing.T) {
func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := m.CORS()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
},
))
// Preflight request
req := httptest.NewRequest(http.MethodOptions, "/api/test", nil)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodOptions, "/api/test", nil,
)
req.Header.Set("Origin", "http://localhost:3000")
req.Header.Set("Access-Control-Request-Method", "POST")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// In dev mode, CORS should allow any origin
assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(
t, "*",
w.Header().Get("Access-Control-Allow-Origin"),
)
}
func TestCORS_ProdMode_NoOp(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
var called bool
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
handler := m.CORS()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/api/test", nil,
)
req.Header.Set("Origin", "http://evil.com")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "prod CORS middleware should pass through to handler")
assert.True(
t, called,
"prod CORS middleware should pass through to handler",
)
// In prod, no CORS headers should be set (no-op middleware)
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"),
"prod mode should not set CORS headers")
assert.Empty(
t,
w.Header().Get("Access-Control-Allow-Origin"),
"prod mode should not set CORS headers",
)
}
// --- RequireAuth Middleware Tests ---
func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.RequireAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/dashboard", nil,
)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, called, "handler should not be called for unauthenticated request")
assert.False(
t, called,
"handler should not be called for "+
"unauthenticated request",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
func TestRequireAuth_AuthenticatedSession_PassesThrough(t *testing.T) {
func TestRequireAuth_AuthenticatedSession_PassesThrough(
t *testing.T,
) {
t.Parallel()
m, sessManager := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.RequireAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// Create an authenticated session by making a request, setting session data,
// and saving the session cookie
setupReq := httptest.NewRequest(http.MethodGet, "/setup", nil)
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
// Create an authenticated session by making a request,
// setting session data, and saving the session cookie
setupReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/setup", nil,
)
setupW := httptest.NewRecorder()
sess, err := sessManager.Get(setupReq)
@@ -239,47 +315,74 @@ func TestRequireAuth_AuthenticatedSession_PassesThrough(t *testing.T) {
require.NotEmpty(t, cookies, "session cookie should be set")
// Make the actual request with the session cookie
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/dashboard", nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "handler should be called for authenticated request")
assert.True(
t, called,
"handler should be called for authenticated request",
)
}
func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(t *testing.T) {
func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
t *testing.T,
) {
t.Parallel()
m, sessManager := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.RequireAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
handler := m.RequireAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
},
))
// Create a session but don't authenticate it
setupReq := httptest.NewRequest(http.MethodGet, "/setup", nil)
setupReq := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/setup", nil,
)
setupW := httptest.NewRecorder()
sess, err := sessManager.Get(setupReq)
require.NoError(t, err)
// Don't call SetUser session exists but is not authenticated
// Don't call SetUser -- session exists but is not
// authenticated
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
cookies := setupW.Result().Cookies()
require.NotEmpty(t, cookies)
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/dashboard", nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, called, "handler should not be called for unauthenticated session")
assert.False(
t, called,
"handler should not be called for "+
"unauthenticated session",
)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
}
@@ -304,7 +407,9 @@ func TestIpFromHostPort(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := ipFromHostPort(tt.input)
result := middleware.IPFromHostPort(tt.input)
assert.Equal(t, tt.expected, result)
})
}
@@ -312,122 +417,124 @@ func TestIpFromHostPort(t *testing.T) {
// --- MetricsAuth Tests ---
func TestMetricsAuth_ValidCredentials(t *testing.T) {
t.Parallel()
// metricsAuthMiddleware creates a Middleware configured for
// metrics auth testing. This helper de-duplicates the setup in
// metrics auth test functions.
func metricsAuthMiddleware(
t *testing.T,
) *middleware.Middleware {
t.Helper()
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
cfg := &config.Config{
Environment: config.EnvironmentDev,
MetricsUsername: "admin",
MetricsPassword: "secret",
}
key := make([]byte, 32)
key := make([]byte, testKeySize)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
return middleware.NewForTest(log, cfg, sessManager)
}
func TestMetricsAuth_ValidCredentials(t *testing.T) {
t.Parallel()
m := metricsAuthMiddleware(t)
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
handler := m.MetricsAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/metrics", nil,
)
req.SetBasicAuth("admin", "secret")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "handler should be called with valid basic auth")
assert.True(
t, called,
"handler should be called with valid basic auth",
)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
cfg := &config.Config{
Environment: config.EnvironmentDev,
MetricsUsername: "admin",
MetricsPassword: "secret",
}
key := make([]byte, 32)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
m := metricsAuthMiddleware(t)
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
handler := m.MetricsAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/metrics", nil,
)
req.SetBasicAuth("admin", "wrong-password")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, called, "handler should not be called with invalid basic auth")
assert.False(
t, called,
"handler should not be called with invalid basic auth",
)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestMetricsAuth_NoCredentials(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
cfg := &config.Config{
Environment: config.EnvironmentDev,
MetricsUsername: "admin",
MetricsPassword: "secret",
}
key := make([]byte, 32)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log, key)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
m := metricsAuthMiddleware(t)
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
handler := m.MetricsAuth()(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
))
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/metrics", nil,
)
// No basic auth header
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, called, "handler should not be called without credentials")
assert.False(
t, called,
"handler should not be called without credentials",
)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
@@ -435,16 +542,23 @@ func TestMetricsAuth_NoCredentials(t *testing.T) {
func TestCORS_DevMode_AllowsMethods(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.CORS()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := m.CORS()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
},
))
// Preflight for POST
req := httptest.NewRequest(http.MethodOptions, "/api/webhooks", nil)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodOptions, "/api/webhooks", nil,
)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", "POST")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
@@ -458,14 +572,17 @@ func TestCORS_DevMode_AllowsMethods(t *testing.T) {
func TestSessionKeyFormat(t *testing.T) {
t.Parallel()
// Verify that the session initialization correctly validates key format.
// A proper 32-byte key encoded as base64 should work.
key := make([]byte, 32)
// Verify that the session initialization correctly validates
// key format. A proper 32-byte key encoded as base64 should
// work.
key := make([]byte, testKeySize)
for i := range key {
key[i] = byte(i + 1)
}
encoded := base64.StdEncoding.EncodeToString(key)
decoded, err := base64.StdEncoding.DecodeString(encoded)
require.NoError(t, err)
assert.Len(t, decoded, 32)
assert.Len(t, decoded, testKeySize)
}

View File

@@ -8,40 +8,56 @@ import (
)
const (
// loginRateLimit is the maximum number of login attempts per interval.
// loginRateLimit is the maximum number of login attempts
// per interval.
loginRateLimit = 5
// loginRateInterval is the time window for the rate limit.
loginRateInterval = 1 * time.Minute
)
// LoginRateLimit returns middleware that enforces per-IP rate limiting
// on login attempts using go-chi/httprate. Only POST requests are
// rate-limited; GET requests (rendering the login form) pass through
// unaffected. When the rate limit is exceeded, a 429 Too Many Requests
// response is returned. IP extraction honours X-Forwarded-For,
// X-Real-IP, and True-Client-IP headers for reverse-proxy setups.
// LoginRateLimit returns middleware that enforces per-IP rate
// limiting on login attempts using go-chi/httprate. Only POST
// requests are rate-limited; GET requests (rendering the login
// form) pass through unaffected. When the rate limit is exceeded,
// a 429 Too Many Requests response is returned. IP extraction
// honours X-Forwarded-For, X-Real-IP, and True-Client-IP headers
// for reverse-proxy setups.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
limiter := httprate.Limit(
loginRateLimit,
loginRateInterval,
httprate.WithKeyFuncs(httprate.KeyByRealIP),
httprate.WithLimitHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httprate.WithLimitHandler(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
m.log.Warn("login rate limit exceeded",
"path", r.URL.Path,
)
http.Error(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
})),
http.Error(
w,
"Too many login attempts. "+
"Please try again later.",
http.StatusTooManyRequests,
)
},
)),
)
return func(next http.Handler) http.Handler {
limited := limiter(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only rate-limit POST requests (actual login attempts)
return http.HandlerFunc(func(
w http.ResponseWriter,
r *http.Request,
) {
// Only rate-limit POST requests (actual login
// attempts)
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
limited.ServeHTTP(w, r)
})
}

View File

@@ -1,90 +1,147 @@
package middleware
package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/middleware"
)
func TestLoginRateLimit_AllowsGET(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var callCount int
handler := m.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := m.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
},
))
// GET requests should never be rate-limited
for i := 0; i < 20; i++ {
req := httptest.NewRequest(http.MethodGet, "/pages/login", nil)
for i := range 20 {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/pages/login", nil,
)
req.RemoteAddr = "192.168.1.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "GET request %d should pass", i)
assert.Equal(
t, http.StatusOK, w.Code,
"GET request %d should pass", i,
)
}
assert.Equal(t, 20, callCount)
}
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var callCount int
handler := m.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := m.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
},
))
// First loginRateLimit POST requests should succeed
for i := 0; i < loginRateLimit; i++ {
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
for i := range middleware.LoginRateLimitConst {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
)
req.RemoteAddr = "10.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "POST request %d should pass", i)
assert.Equal(
t, http.StatusOK, w.Code,
"POST request %d should pass", i,
)
}
// Next POST should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
)
req.RemoteAddr = "10.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code, "POST after limit should be 429")
assert.Equal(t, loginRateLimit, callCount)
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"POST after limit should be 429",
)
assert.Equal(t, middleware.LoginRateLimitConst, callCount)
}
func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := m.LoginRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
},
))
// Exhaust limit for IP1
for i := 0; i < loginRateLimit; i++ {
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
for range middleware.LoginRateLimitConst {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
)
req.RemoteAddr = "1.2.3.4:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
}
// IP1 should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
)
req.RemoteAddr = "1.2.3.4:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
// IP2 should still be allowed
req2 := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req2 := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
)
req2.RemoteAddr = "5.6.7.8:12345"
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code, "different IP should not be affected")
assert.Equal(
t, http.StatusOK, w2.Code,
"different IP should not be affected",
)
}

View File

@@ -0,0 +1,24 @@
package middleware
import (
"log/slog"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/session"
)
// NewForTest creates a Middleware with the minimum dependencies
// needed for testing. This bypasses the fx lifecycle.
func NewForTest(
log *slog.Logger,
cfg *config.Config,
sess *session.Session,
) *Middleware {
return &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sess,
}
}

View File

@@ -1,18 +1,36 @@
package server
import (
"errors"
"fmt"
"net/http"
"time"
)
const (
// httpReadTimeout is the maximum duration for reading the
// entire request, including the body.
httpReadTimeout = 10 * time.Second
// httpWriteTimeout is the maximum duration before timing out
// writes of the response. It must stay above the router's
// requestTimeout (60s, in routes.go) so the middleware timeout
// fires first and returns a clean 503, rather than the transport
// cutting the connection at the socket write deadline.
httpWriteTimeout = 65 * time.Second
// httpMaxHeaderBytes is the maximum number of bytes the
// server will read parsing the request headers.
httpMaxHeaderBytes = 1 << 20
)
func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{
Addr: listenAddr,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
MaxHeaderBytes: httpMaxHeaderBytes,
Handler: s,
}
@@ -21,14 +39,21 @@ func (s *Server) serveUntilShutdown() {
s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", listenAddr)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err)
if s.cancelFunc != nil {
s.cancelFunc()
}
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ServeHTTP delegates to the router.
func (s *Server) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
s.router.ServeHTTP(w, r)
}

View File

@@ -11,16 +11,24 @@ import (
"sneak.berlin/go/webhooker/static"
)
// maxFormBodySize is the maximum allowed request body size (in bytes) for
// form POST endpoints. 1 MB is generous for any form submission while
// preventing abuse from oversized payloads.
// maxFormBodySize is the maximum allowed request body size (in
// bytes) for form POST endpoints. 1 MB is generous for any form
// submission while preventing abuse from oversized payloads.
const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB
// SetupRoutes configures all HTTP routes and middleware on the server's router.
// requestTimeout is the maximum time allowed for a single HTTP
// request.
const requestTimeout = 60 * time.Second
// SetupRoutes configures all HTTP routes and middleware on the
// server's router.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
s.setupGlobalMiddleware()
s.setupRoutes()
}
// Global middleware stack — applied to every request.
func (s *Server) setupGlobalMiddleware() {
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.SecurityHeaders())
@@ -32,24 +40,28 @@ func (s *Server) SetupRoutes() {
}
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(60 * time.Second))
s.router.Use(middleware.Timeout(requestTimeout))
// Sentry error reporting (if SENTRY_DSN is set). Repanic is true
// so panics still bubble up to the Recoverer middleware above.
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
// true so panics still bubble up to the Recoverer middleware.
if s.sentryEnabled {
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
s.router.Use(sentryHandler.Handle)
}
}
// Routes
func (s *Server) setupRoutes() {
s.router.Get("/", s.h.HandleIndex())
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
s.router.Mount(
"/s",
http.StripPrefix("/s", http.FileServer(http.FS(static.Static))),
)
s.router.Route("/api/v1", func(_ chi.Router) {
// TODO: Add API routes here
// API routes will be added here.
})
s.router.Get(
@@ -61,62 +73,90 @@ func (s *Server) SetupRoutes() {
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
r.Get(
"/metrics",
http.HandlerFunc(
promhttp.Handler().ServeHTTP,
),
)
})
}
// pages that are rendered server-side — CSRF-protected, body-size
// limited, and with per-IP rate limiting on the login endpoint.
s.setupPageRoutes()
s.setupUserRoutes()
s.setupSourceRoutes()
s.setupWebhookRoutes()
}
func (s *Server) setupPageRoutes() {
s.router.Route("/pages", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Use(s.mw.MaxBodySize(maxFormBodySize))
// Login page — rate-limited to prevent brute-force attacks
r.Group(func(r chi.Router) {
r.Use(s.mw.LoginRateLimit())
r.Get("/login", s.h.HandleLoginPage())
r.Post("/login", s.h.HandleLoginSubmit())
})
// Logout (auth required)
r.Post("/logout", s.h.HandleLogout())
})
}
// User profile routes
func (s *Server) setupUserRoutes() {
s.router.Route("/user/{username}", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile())
})
}
// Webhook management routes (require authentication, CSRF-protected)
func (s *Server) setupSourceRoutes() {
s.router.Route("/sources", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Use(s.mw.RequireAuth())
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Get("/", s.h.HandleSourceList()) // List all webhooks
r.Get("/new", s.h.HandleSourceCreate()) // Show create form
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
r.Get("/", s.h.HandleSourceList())
r.Get("/new", s.h.HandleSourceCreate())
r.Post("/new", s.h.HandleSourceCreateSubmit())
})
s.router.Route("/source/{sourceID}", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Use(s.mw.RequireAuth())
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Get("/", s.h.HandleSourceDetail()) // View webhook details
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint
r.Post("/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete()) // Delete entrypoint
r.Post("/entrypoints/{entrypointID}/toggle", s.h.HandleEntrypointToggle()) // Toggle entrypoint active
r.Post("/targets", s.h.HandleTargetCreate()) // Add target
r.Post("/targets/{targetID}/delete", s.h.HandleTargetDelete()) // Delete target
r.Post("/targets/{targetID}/toggle", s.h.HandleTargetToggle()) // Toggle target active
r.Get("/", s.h.HandleSourceDetail())
r.Get("/edit", s.h.HandleSourceEdit())
r.Post("/edit", s.h.HandleSourceEditSubmit())
r.Post("/delete", s.h.HandleSourceDelete())
r.Get("/logs", s.h.HandleSourceLogs())
r.Post(
"/entrypoints",
s.h.HandleEntrypointCreate(),
)
r.Post(
"/entrypoints/{entrypointID}/delete",
s.h.HandleEntrypointDelete(),
)
r.Post(
"/entrypoints/{entrypointID}/toggle",
s.h.HandleEntrypointToggle(),
)
r.Post("/targets", s.h.HandleTargetCreate())
r.Post(
"/targets/{targetID}/delete",
s.h.HandleTargetDelete(),
)
r.Post(
"/targets/{targetID}/toggle",
s.h.HandleTargetToggle(),
)
})
// Entrypoint endpoint — accepts incoming webhook POST requests only.
// Using HandleFunc so the handler itself can return 405 for non-POST
// methods (chi's Method routing returns 405 without Allow header).
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
}
func (s *Server) setupWebhookRoutes() {
s.router.HandleFunc(
"/webhook/{uuid}",
s.h.HandleWebhook(),
)
}

View File

@@ -1,4 +1,5 @@
// Package server wires up HTTP routes and manages the application lifecycle.
// Package server wires up HTTP routes and manages the
// application lifecycle.
package server
import (
@@ -22,9 +23,20 @@ import (
"github.com/go-chi/chi"
)
const (
// shutdownTimeout is the maximum time to wait for the HTTP
// server to finish in-flight requests during shutdown.
shutdownTimeout = 5 * time.Second
// sentryFlushTimeout is the maximum time to wait for Sentry
// to flush pending events during shutdown.
sentryFlushTimeout = 2 * time.Second
)
//nolint:revive // ServerParams is a standard fx naming convention.
type ServerParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
@@ -32,13 +44,13 @@ type ServerParams struct {
Handlers *handlers.Handlers
}
// Server is the main HTTP server that wires up routes and manages graceful shutdown.
// Server is the main HTTP server that wires up routes and manages
// graceful shutdown.
type Server struct {
startupTime time.Time
exitCode int
sentryEnabled bool
log *slog.Logger
ctx context.Context
cancelFunc context.CancelFunc
httpServer *http.Server
router *chi.Mux
@@ -47,7 +59,8 @@ type Server struct {
h *handlers.Handlers
}
// New creates a Server that starts the HTTP listener on fx start and stops it gracefully.
// New creates a Server that starts the HTTP listener on fx start
// and stops it gracefully.
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
s := new(Server)
s.params = params
@@ -59,13 +72,16 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
OnStart: func(_ context.Context) error {
s.startupTime = time.Now()
go s.Run()
return nil
},
OnStop: func(_ context.Context) error {
s.cleanShutdown()
OnStop: func(ctx context.Context) error {
s.cleanShutdown(ctx)
return nil
},
})
return s, nil
}
@@ -79,6 +95,12 @@ func (s *Server) Run() {
s.serve()
}
// MaintenanceMode returns whether the server is in maintenance
// mode.
func (s *Server) MaintenanceMode() bool {
return s.params.Config.MaintenanceMode
}
func (s *Server) enableSentry() {
s.sentryEnabled = false
@@ -88,28 +110,36 @@ func (s *Server) enableSentry() {
err := sentry.Init(sentry.ClientOptions{
Dsn: s.params.Config.SentryDSN,
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
Release: fmt.Sprintf(
"%s-%s",
s.params.Globals.Appname,
s.params.Globals.Version,
),
})
if err != nil {
s.log.Error("sentry init failure", "error", err)
// Don't use fatal since we still want the service to run
return
}
s.log.Info("sentry error reporting activated")
s.sentryEnabled = true
}
func (s *Server) serve() int {
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
ctx, cancelFunc := context.WithCancel(context.Background())
s.cancelFunc = cancelFunc
// signal watcher
go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
// block and wait for signal
sig := <-c
s.log.Info("signal received", "signal", sig.String())
if s.cancelFunc != nil {
// cancelling the main context will trigger a clean
// shutdown via the fx OnStop hook.
@@ -119,9 +149,9 @@ func (s *Server) serve() int {
go s.serveUntilShutdown()
<-s.ctx.Done()
<-ctx.Done()
// Shutdown is handled by the fx OnStop hook (cleanShutdown).
// Do not call cleanShutdown() here to avoid a double invocation.
// Do not call cleanShutdown() here to avoid double invocation.
return s.exitCode
}
@@ -129,28 +159,29 @@ func (s *Server) cleanupForExit() {
s.log.Info("cleaning up")
}
func (s *Server) cleanShutdown() {
func (s *Server) cleanShutdown(ctx context.Context) {
// initiate clean shutdown
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, shutdownTimeout,
)
defer shutdownCancel()
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
s.log.Error("server clean shutdown failed", "error", err)
err := s.httpServer.Shutdown(ctxShutdown)
if err != nil {
s.log.Error(
"server clean shutdown failed", "error", err,
)
}
s.cleanupForExit()
if s.sentryEnabled {
sentry.Flush(2 * time.Second)
sentry.Flush(sentryFlushTimeout)
}
}
// MaintenanceMode returns whether the server is in maintenance mode.
func (s *Server) MaintenanceMode() bool {
return s.params.Config.MaintenanceMode
}
func (s *Server) configure() {
// identify ourselves in the logs
s.params.Logger.Identify()

View File

@@ -1,11 +1,14 @@
// Package session manages HTTP session storage and authentication state.
// Package session manages HTTP session storage and authentication
// state.
package session
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"github.com/gorilla/sessions"
@@ -16,28 +19,44 @@ import (
)
const (
// SessionName is the name of the session cookie
// SessionName is the name of the session cookie.
SessionName = "webhooker_session"
// UserIDKey is the session key for user ID
// UserIDKey is the session key for user ID.
UserIDKey = "user_id"
// UsernameKey is the session key for username
// UsernameKey is the session key for username.
UsernameKey = "username"
// AuthenticatedKey is the session key for authentication status
// AuthenticatedKey is the session key for authentication
// status.
AuthenticatedKey = "authenticated"
// sessionKeyLength is the required length in bytes for the
// session authentication key.
sessionKeyLength = 32
// sessionMaxAgeDays is the session cookie lifetime in days.
sessionMaxAgeDays = 7
// secondsPerDay is the number of seconds in a day.
secondsPerDay = 86400
)
// nolint:revive // SessionParams is a standard fx naming convention
type SessionParams struct {
// ErrSessionKeyLength is returned when the decoded session key
// does not have the expected length.
var ErrSessionKeyLength = errors.New("session key length mismatch")
// Params holds dependencies injected by fx.
type Params struct {
fx.In
Config *config.Config
Database *database.Database
Logger *logger.Logger
}
// Session manages encrypted session storage
// Session manages encrypted session storage.
type Session struct {
store *sessions.CookieStore
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
@@ -45,29 +64,44 @@ type Session struct {
config *config.Config
}
// New creates a new session manager. The cookie store is initialized
// during the fx OnStart phase after the database is connected, using
// a session key that is auto-generated and stored in the database.
func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
// New creates a new session manager. The cookie store is
// initialized during the fx OnStart phase after the database is
// connected, using a session key that is auto-generated and stored
// in the database.
func New(
lc fx.Lifecycle,
params Params,
) (*Session, error) {
s := &Session{
log: params.Logger.Get(),
config: params.Config,
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
OnStart: func(_ context.Context) error {
sessionKey, err := params.Database.GetOrCreateSessionKey()
if err != nil {
return fmt.Errorf("failed to get session key: %w", err)
return fmt.Errorf(
"failed to get session key: %w", err,
)
}
keyBytes, err := base64.StdEncoding.DecodeString(sessionKey)
keyBytes, err := base64.StdEncoding.DecodeString(
sessionKey,
)
if err != nil {
return fmt.Errorf("invalid session key format: %w", err)
return fmt.Errorf(
"invalid session key format: %w", err,
)
}
if len(keyBytes) != 32 {
return fmt.Errorf("session key must be 32 bytes (got %d)", len(keyBytes))
if len(keyBytes) != sessionKeyLength {
return fmt.Errorf(
"%w: want %d, got %d",
ErrSessionKeyLength,
sessionKeyLength,
len(keyBytes),
)
}
store := sessions.NewCookieStore(keyBytes)
@@ -75,15 +109,16 @@ func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
// Configure cookie options for security
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
MaxAge: secondsPerDay * sessionMaxAgeDays,
HttpOnly: true,
Secure: !params.Config.IsDev(), // HTTPS in production
Secure: !params.Config.IsDev(),
SameSite: http.SameSiteLaxMode,
}
s.key = keyBytes
s.store = store
s.log.Info("session manager initialized")
return nil
},
})
@@ -91,99 +126,126 @@ func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
return s, nil
}
// Get retrieves a session for the request
func (s *Session) Get(r *http.Request) (*sessions.Session, error) {
// Get retrieves a session for the request.
func (s *Session) Get(
r *http.Request,
) (*sessions.Session, error) {
return s.store.Get(r, SessionName)
}
// GetKey returns the raw 32-byte authentication key used for session
// encryption. This key is also suitable for CSRF cookie signing.
// GetKey returns the raw 32-byte authentication key used for
// session encryption. This key is also suitable for CSRF cookie
// signing.
func (s *Session) GetKey() []byte {
return s.key
}
// Save saves the session
func (s *Session) Save(r *http.Request, w http.ResponseWriter, sess *sessions.Session) error {
// Save saves the session.
func (s *Session) Save(
r *http.Request,
w http.ResponseWriter,
sess *sessions.Session,
) error {
return sess.Save(r, w)
}
// SetUser sets the user information in the session
func (s *Session) SetUser(sess *sessions.Session, userID, username string) {
// SetUser sets the user information in the session.
func (s *Session) SetUser(
sess *sessions.Session,
userID, username string,
) {
sess.Values[UserIDKey] = userID
sess.Values[UsernameKey] = username
sess.Values[AuthenticatedKey] = true
}
// ClearUser removes user information from the session
// ClearUser removes user information from the session.
func (s *Session) ClearUser(sess *sessions.Session) {
delete(sess.Values, UserIDKey)
delete(sess.Values, UsernameKey)
delete(sess.Values, AuthenticatedKey)
}
// IsAuthenticated checks if the session has an authenticated user
// IsAuthenticated checks if the session has an authenticated
// user.
func (s *Session) IsAuthenticated(sess *sessions.Session) bool {
auth, ok := sess.Values[AuthenticatedKey].(bool)
return ok && auth
}
// GetUserID retrieves the user ID from the session
func (s *Session) GetUserID(sess *sessions.Session) (string, bool) {
// GetUserID retrieves the user ID from the session.
func (s *Session) GetUserID(
sess *sessions.Session,
) (string, bool) {
userID, ok := sess.Values[UserIDKey].(string)
return userID, ok
}
// GetUsername retrieves the username from the session
func (s *Session) GetUsername(sess *sessions.Session) (string, bool) {
// GetUsername retrieves the username from the session.
func (s *Session) GetUsername(
sess *sessions.Session,
) (string, bool) {
username, ok := sess.Values[UsernameKey].(string)
return username, ok
}
// Destroy invalidates the session
// Destroy invalidates the session.
func (s *Session) Destroy(sess *sessions.Session) {
sess.Options.MaxAge = -1
s.ClearUser(sess)
}
// Regenerate creates a new session with the same values but a fresh ID.
// The old session is destroyed (MaxAge = -1) and saved, then a new session
// is created. This prevents session fixation attacks by ensuring the
// session ID changes after privilege escalation (e.g. login).
func (s *Session) Regenerate(r *http.Request, w http.ResponseWriter, oldSess *sessions.Session) (*sessions.Session, error) {
// Regenerate creates a new session with the same values but a
// fresh ID. The old session is destroyed (MaxAge = -1) and saved,
// then a new session is created. This prevents session fixation
// attacks by ensuring the session ID changes after privilege
// escalation (e.g. login).
func (s *Session) Regenerate(
r *http.Request,
w http.ResponseWriter,
oldSess *sessions.Session,
) (*sessions.Session, error) {
// Copy the values from the old session
oldValues := make(map[interface{}]interface{})
for k, v := range oldSess.Values {
oldValues[k] = v
}
oldValues := make(map[any]any)
maps.Copy(oldValues, oldSess.Values)
// Destroy the old session
oldSess.Options.MaxAge = -1
s.ClearUser(oldSess)
if err := oldSess.Save(r, w); err != nil {
return nil, fmt.Errorf("failed to destroy old session: %w", err)
err := oldSess.Save(r, w)
if err != nil {
return nil, fmt.Errorf(
"failed to destroy old session: %w", err,
)
}
// Create a new session (gorilla/sessions generates a new ID)
newSess, err := s.store.New(r, SessionName)
if err != nil {
// store.New may return an error alongside a new empty session
// if the old cookie is now invalid. That is expected after we
// destroyed it above. Only fail on a nil session.
// store.New may return an error alongside a new empty
// session if the old cookie is now invalid. That is
// expected after we destroyed it above. Only fail on a
// nil session.
if newSess == nil {
return nil, fmt.Errorf("failed to create new session: %w", err)
return nil, fmt.Errorf(
"failed to create new session: %w", err,
)
}
}
// Restore the copied values into the new session
for k, v := range oldValues {
newSess.Values[k] = v
}
maps.Copy(newSess.Values, oldValues)
// Apply the standard session options (the destroyed old session had
// MaxAge = -1, which store.New might inherit from the cookie).
// Apply the standard session options (the destroyed old
// session had MaxAge = -1, which store.New might inherit
// from the cookie).
newSess.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
MaxAge: secondsPerDay * sessionMaxAgeDays,
HttpOnly: true,
Secure: !s.config.IsDev(),
SameSite: http.SameSiteLaxMode,

View File

@@ -1,6 +1,7 @@
package session
package session_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
@@ -11,15 +12,22 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/session"
)
// testSession creates a Session with a real cookie store for testing.
func testSession(t *testing.T) *Session {
const testKeySize = 32
// testSession creates a Session with a real cookie store for
// testing.
func testSession(t *testing.T) *session.Session {
t.Helper()
key := make([]byte, 32)
key := make([]byte, testKeySize)
for i := range key {
key[i] = byte(i + 42)
}
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{
Path: "/",
@@ -32,34 +40,47 @@ func testSession(t *testing.T) *Session {
cfg := &config.Config{
Environment: config.EnvironmentDev,
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
return NewForTest(store, cfg, log, key)
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
return session.NewForTest(store, cfg, log, key)
}
// --- Get and Save Tests ---
func TestGet_NewSession(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
require.NotNil(t, sess)
assert.True(t, sess.IsNew, "session should be new when no cookie is present")
assert.True(
t, sess.IsNew,
"session should be new when no cookie is present",
)
}
func TestGet_ExistingSession(t *testing.T) {
t.Parallel()
s := testSession(t)
// Create and save a session
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1 := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w1 := httptest.NewRecorder()
sess1, err := s.Get(req1)
require.NoError(t, err)
sess1.Values["test_key"] = "test_value"
require.NoError(t, s.Save(req1, w1, sess1))
@@ -68,26 +89,34 @@ func TestGet_ExistingSession(t *testing.T) {
require.NotEmpty(t, cookies)
// Make a new request with the session cookie
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2 := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
for _, c := range cookies {
req2.AddCookie(c)
}
sess2, err := s.Get(req2)
require.NoError(t, err)
assert.False(t, sess2.IsNew, "session should not be new when cookie is present")
assert.False(
t, sess2.IsNew,
"session should not be new when cookie is present",
)
assert.Equal(t, "test_value", sess2.Values["test_key"])
}
func TestSave_SetsCookie(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w := httptest.NewRecorder()
sess, err := s.Get(req)
require.NoError(t, err)
sess.Values["key"] = "value"
err = s.Save(req, w, sess)
@@ -98,48 +127,73 @@ func TestSave_SetsCookie(t *testing.T) {
// Verify the cookie has the expected name
var found bool
for _, c := range cookies {
if c.Name == SessionName {
if c.Name == session.SessionName {
found = true
assert.True(t, c.HttpOnly, "session cookie should be HTTP-only")
assert.True(
t, c.HttpOnly,
"session cookie should be HTTP-only",
)
break
}
}
assert.True(t, found, "should find a cookie named %s", SessionName)
assert.True(
t, found,
"should find a cookie named %s", session.SessionName,
)
}
// --- SetUser and User Retrieval Tests ---
func TestSetUser_SetsAllFields(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
s.SetUser(sess, "user-abc-123", "alice")
assert.Equal(t, "user-abc-123", sess.Values[UserIDKey])
assert.Equal(t, "alice", sess.Values[UsernameKey])
assert.Equal(t, true, sess.Values[AuthenticatedKey])
assert.Equal(
t, "user-abc-123", sess.Values[session.UserIDKey],
)
assert.Equal(
t, "alice", sess.Values[session.UsernameKey],
)
assert.Equal(
t, true, sess.Values[session.AuthenticatedKey],
)
}
func TestGetUserID(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
// Before setting user
userID, ok := s.GetUserID(sess)
assert.False(t, ok, "should return false when no user ID is set")
assert.False(
t, ok, "should return false when no user ID is set",
)
assert.Empty(t, userID)
// After setting user
s.SetUser(sess, "user-xyz", "bob")
userID, ok = s.GetUserID(sess)
assert.True(t, ok)
assert.Equal(t, "user-xyz", userID)
@@ -147,19 +201,25 @@ func TestGetUserID(t *testing.T) {
func TestGetUsername(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
// Before setting user
username, ok := s.GetUsername(sess)
assert.False(t, ok, "should return false when no username is set")
assert.False(
t, ok, "should return false when no username is set",
)
assert.Empty(t, username)
// After setting user
s.SetUser(sess, "user-xyz", "bob")
username, ok = s.GetUsername(sess)
assert.True(t, ok)
assert.Equal(t, "bob", username)
@@ -169,20 +229,29 @@ func TestGetUsername(t *testing.T) {
func TestIsAuthenticated_NoSession(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
assert.False(t, s.IsAuthenticated(sess), "new session should not be authenticated")
assert.False(
t, s.IsAuthenticated(sess),
"new session should not be authenticated",
)
}
func TestIsAuthenticated_AfterSetUser(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
@@ -192,9 +261,12 @@ func TestIsAuthenticated_AfterSetUser(t *testing.T) {
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
@@ -202,52 +274,71 @@ func TestIsAuthenticated_AfterClearUser(t *testing.T) {
require.True(t, s.IsAuthenticated(sess))
s.ClearUser(sess)
assert.False(t, s.IsAuthenticated(sess), "should not be authenticated after ClearUser")
assert.False(
t, s.IsAuthenticated(sess),
"should not be authenticated after ClearUser",
)
}
func TestIsAuthenticated_WrongType(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
// Set authenticated to a non-bool value
sess.Values[AuthenticatedKey] = "yes"
assert.False(t, s.IsAuthenticated(sess), "should return false for non-bool authenticated value")
sess.Values[session.AuthenticatedKey] = "yes"
assert.False(
t, s.IsAuthenticated(sess),
"should return false for non-bool authenticated value",
)
}
// --- ClearUser Tests ---
func TestClearUser_RemovesAllKeys(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
s.SetUser(sess, "user-123", "alice")
s.ClearUser(sess)
_, hasUserID := sess.Values[UserIDKey]
_, hasUserID := sess.Values[session.UserIDKey]
assert.False(t, hasUserID, "UserIDKey should be removed")
_, hasUsername := sess.Values[UsernameKey]
_, hasUsername := sess.Values[session.UsernameKey]
assert.False(t, hasUsername, "UsernameKey should be removed")
_, hasAuth := sess.Values[AuthenticatedKey]
assert.False(t, hasAuth, "AuthenticatedKey should be removed")
_, hasAuth := sess.Values[session.AuthenticatedKey]
assert.False(
t, hasAuth, "AuthenticatedKey should be removed",
)
}
// --- Destroy Tests ---
func TestDestroy_InvalidatesSession(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
@@ -255,11 +346,18 @@ func TestDestroy_InvalidatesSession(t *testing.T) {
s.Destroy(sess)
// After Destroy: MaxAge should be -1 (delete cookie) and user data cleared
assert.Equal(t, -1, sess.Options.MaxAge, "Destroy should set MaxAge to -1")
assert.False(t, s.IsAuthenticated(sess), "should not be authenticated after Destroy")
// After Destroy: MaxAge should be -1 (delete cookie) and
// user data cleared
assert.Equal(
t, -1, sess.Options.MaxAge,
"Destroy should set MaxAge to -1",
)
assert.False(
t, s.IsAuthenticated(sess),
"should not be authenticated after Destroy",
)
_, hasUserID := sess.Values[UserIDKey]
_, hasUserID := sess.Values[session.UserIDKey]
assert.False(t, hasUserID, "Destroy should clear user ID")
}
@@ -267,10 +365,12 @@ func TestDestroy_InvalidatesSession(t *testing.T) {
func TestSessionPersistence_RoundTrip(t *testing.T) {
t.Parallel()
s := testSession(t)
// Step 1: Create session, set user, save
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1 := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w1 := httptest.NewRecorder()
sess1, err := s.Get(req1)
@@ -281,8 +381,13 @@ func TestSessionPersistence_RoundTrip(t *testing.T) {
cookies := w1.Result().Cookies()
require.NotEmpty(t, cookies)
// Step 2: New request with cookies session data should persist
req2 := httptest.NewRequest(http.MethodGet, "/profile", nil)
// Step 2: New request with cookies -- session data should
// persist
req2 := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/profile", nil,
)
for _, c := range cookies {
req2.AddCookie(c)
}
@@ -290,7 +395,10 @@ func TestSessionPersistence_RoundTrip(t *testing.T) {
sess2, err := s.Get(req2)
require.NoError(t, err)
assert.True(t, s.IsAuthenticated(sess2), "session should be authenticated after round-trip")
assert.True(
t, s.IsAuthenticated(sess2),
"session should be authenticated after round-trip",
)
userID, ok := s.GetUserID(sess2)
assert.True(t, ok)
@@ -305,19 +413,23 @@ func TestSessionPersistence_RoundTrip(t *testing.T) {
func TestSessionConstants(t *testing.T) {
t.Parallel()
assert.Equal(t, "webhooker_session", SessionName)
assert.Equal(t, "user_id", UserIDKey)
assert.Equal(t, "username", UsernameKey)
assert.Equal(t, "authenticated", AuthenticatedKey)
assert.Equal(t, "webhooker_session", session.SessionName)
assert.Equal(t, "user_id", session.UserIDKey)
assert.Equal(t, "username", session.UsernameKey)
assert.Equal(t, "authenticated", session.AuthenticatedKey)
}
// --- Edge Cases ---
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
@@ -338,10 +450,12 @@ func TestSetUser_OverwritesPreviousUser(t *testing.T) {
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
t.Parallel()
s := testSession(t)
// Create a session
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1 := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
w1 := httptest.NewRecorder()
sess, err := s.Get(req1)
@@ -353,10 +467,15 @@ func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
require.NotEmpty(t, cookies)
// Destroy and save
req2 := httptest.NewRequest(http.MethodGet, "/logout", nil)
req2 := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/logout", nil,
)
for _, c := range cookies {
req2.AddCookie(c)
}
w2 := httptest.NewRecorder()
sess2, err := s.Get(req2)
@@ -364,15 +483,25 @@ func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
s.Destroy(sess2)
require.NoError(t, s.Save(req2, w2, sess2))
// The cookie should have MaxAge = -1 (browser should delete it)
// The cookie should have MaxAge = -1 (browser should delete)
responseCookies := w2.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range responseCookies {
if c.Name == SessionName {
if c.Name == session.SessionName {
sessionCookie = c
break
}
}
require.NotNil(t, sessionCookie, "should have a session cookie in response")
assert.True(t, sessionCookie.MaxAge < 0, "destroyed session cookie should have negative MaxAge")
require.NotNil(
t, sessionCookie,
"should have a session cookie in response",
)
assert.Negative(
t, sessionCookie.MaxAge,
"destroyed session cookie should have negative MaxAge",
)
}

121
script/bootstrap Executable file
View File

@@ -0,0 +1,121 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# 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 packaged in nix, brew, and apk; on apt
# it is installed from a hash-verified GitHub release archive (never
# curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.11.3"
# sha256 of golangci-lint-2.11.3-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="87bb8cddbcc825d5778b64e8a91b46c0526b247f4e2f2904dea74ec7450475d1"
GOLANGCI_LINT_SHA256_ARM64="ee3d95f301359e7d578e6d99c8ad5aeadbabc5a13009a30b2b0df11c8058afe9"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
main() {
cd "$ROOT"
# Base tooling
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
# Go toolchain and linter
if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint
go mod download
echo "bootstrap complete"
}
main "$@"

15
script/check Executable file
View File

@@ -0,0 +1,15 @@
#!/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.
# Generic: usually needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

15
script/cibuild Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs the checks
# (make fmt-check, lint, test), so a successful build implies a green
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
# push.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

15
script/docker Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

15
script/fmt Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
gofmt -s -w .
if command -v goimports >/dev/null 2>&1; then
goimports -w .
fi
}
main "$@"

17
script/fmt-check Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ -n "$(gofmt -s -l .)" ]; then
echo "gofmt needed on:"
gofmt -s -l .
exit 1
fi
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
# Generic: needs no adaptation.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

12
script/lint Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --config .golangci.yml ./...
}
main "$@"

21
script/precommit Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
# extras: go mod tidy must not change go.mod/go.sum.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
git diff --exit-code -- go.mod go.sum || {
echo "precommit: go mod tidy changed go.mod/go.sum;" \
"stage the changes and retry" >&2
exit 1
}
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "webhooker"
}
main "$@"

14
script/setup Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies (script/bootstrap) and the git pre-commit hook.
# Add any repo-specific initialization (db init, .env template) here.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

12
script/test Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -timeout 30s ./...
}
main "$@"

View File

@@ -1,67 +0,0 @@
{{template "base" .}}
{{define "title"}}Home - Webhooker{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12">
<div class="text-center mb-10">
<h1 class="text-4xl font-medium text-gray-900">Welcome to Webhooker</h1>
<p class="mt-3 text-lg text-gray-500">A reliable webhook proxy service for event delivery</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Server Status Card -->
<div class="card-elevated p-6">
<div class="flex items-center mb-4">
<div class="rounded-full bg-success-50 p-3 mr-4">
<svg class="w-6 h-6 text-success-500" fill="currentColor" viewBox="0 0 16 16">
<path d="M1.333 2.667C1.333 1.194 4.318 0 8 0s6.667 1.194 6.667 2.667V4c0 1.473-2.985 2.667-6.667 2.667S1.333 5.473 1.333 4V2.667z"/>
<path d="M1.333 6.334v3C1.333 10.805 4.318 12 8 12s6.667-1.194 6.667-2.667V6.334a6.51 6.51 0 0 1-1.458.79C11.81 7.684 9.967 8 8 8c-1.966 0-3.809-.317-5.208-.876a6.508 6.508 0 0 1-1.458-.79z"/>
<path d="M14.667 11.668a6.51 6.51 0 0 1-1.458.789c-1.4.56-3.242.876-5.21.876-1.966 0-3.809-.316-5.208-.876a6.51 6.51 0 0 1-1.458-.79v1.666C1.333 14.806 4.318 16 8 16s6.667-1.194 6.667-2.667v-1.665z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-medium text-gray-900">Server Status</h2>
<span class="badge-success">Online</span>
</div>
</div>
<div class="space-y-3">
<div>
<p class="text-sm text-gray-500">Uptime</p>
<p class="text-2xl font-medium text-gray-900">{{.Uptime}}</p>
</div>
<div>
<p class="text-sm text-gray-500">Version</p>
<p class="font-mono text-sm text-gray-700">{{.Version}}</p>
</div>
</div>
</div>
<!-- Users Card -->
<div class="card-elevated p-6">
<div class="flex items-center mb-4">
<div class="rounded-full bg-primary-50 p-3 mr-4">
<svg class="w-6 h-6 text-primary-500" fill="currentColor" viewBox="0 0 16 16">
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
</div>
<div>
<h2 class="text-lg font-medium text-gray-900">Users</h2>
<p class="text-sm text-gray-500">Registered accounts</p>
</div>
</div>
<div>
<p class="text-4xl font-medium text-gray-900">{{.UserCount}}</p>
<p class="text-sm text-gray-500 mt-1">Total users</p>
</div>
</div>
</div>
{{if not .User}}
<div class="text-center mt-10">
<p class="text-gray-500 mb-4">Ready to get started?</p>
<a href="/pages/login" class="btn-primary">Login to your account</a>
</div>
{{end}}
</div>
{{end}}