Compare commits
17 Commits
7f4c40caca
...
feat/recei
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf9d0525a | |||
| 81413c56e9 | |||
| f6b929f2d7 | |||
| 8ea7f76540 | |||
| 752d6beead | |||
| b1f43c9520 | |||
| 07fc63d9fa | |||
| 0c9c885d51 | |||
| 2cc8723997 | |||
| e0b1e7cf54 | |||
| afe88c601a | |||
| d771fe14df | |||
| 33e2140a5a | |||
| f003ec7141 | |||
| 17e740a45f | |||
| 60786c5019 | |||
| 8d702a16c6 |
@@ -12,4 +12,4 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||||
- name: Build Docker image (runs make check)
|
- name: Build Docker image (runs make check)
|
||||||
run: docker build .
|
run: script/cibuild
|
||||||
|
|||||||
@@ -1,46 +1,32 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
tests: true
|
modules-download-mode: readonly
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
default: all
|
||||||
- gofmt
|
disable:
|
||||||
- revive
|
# Genuinely incompatible with project patterns
|
||||||
- govet
|
- exhaustruct # Requires all struct fields
|
||||||
- errcheck
|
- depguard # Dependency allow/block lists
|
||||||
- staticcheck
|
- godot # Requires comments to end with periods
|
||||||
- unused
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- gosimple
|
- wrapcheck # Too verbose for internal packages
|
||||||
- ineffassign
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
- typecheck
|
|
||||||
- gosec
|
|
||||||
- misspell
|
|
||||||
- unparam
|
|
||||||
- prealloc
|
|
||||||
- copyloopvar
|
|
||||||
- gocritic
|
|
||||||
- gochecknoinits
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
gofmt:
|
lll:
|
||||||
simplify: true
|
line-length: 88
|
||||||
revive:
|
funlen:
|
||||||
confidence: 0.8
|
lines: 80
|
||||||
govet:
|
statements: 50
|
||||||
enable:
|
cyclop:
|
||||||
- shadow
|
max-complexity: 15
|
||||||
errcheck:
|
dupl:
|
||||||
check-type-assertions: true
|
threshold: 100
|
||||||
check-blank: true
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-rules:
|
exclude-use-default: false
|
||||||
# Exclude globals check for version variables in main
|
max-issues-per-linter: 0
|
||||||
- path: cmd/webhooker/main.go
|
max-same-issues: 0
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
# Exclude globals check for version variables in globals package
|
|
||||||
- path: internal/globals/globals.go
|
|
||||||
linters:
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|||||||
89
Dockerfile
89
Dockerfile
@@ -1,49 +1,58 @@
|
|||||||
# golang:1.24 (bookworm) — 2026-03-01
|
# Lint stage
|
||||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
# golangci/golangci-lint:v2.11.3 (Debian-based), 2026-03-17
|
||||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||||
FROM golang@sha256:d2d2bc1c84f7e60d7d2438a3836ae7d0c847f4888464e7ec9ba3a1339a1ee804 AS builder
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
|
FROM golangci/golangci-lint:v2.11.3@sha256:e838e8ab68aaefe83e2408691510867ade9329c0e0b895a3fb35eb93d1c2a4ba AS lint
|
||||||
|
|
||||||
# gcc is pre-installed in the Debian-based golang image
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /src
|
||||||
|
|
||||||
# Install golangci-lint v1.64.8 — 2026-03-01
|
# Copy go mod files first for better layer caching
|
||||||
# Using v1.x because the repo's .golangci.yml uses v1 config format.
|
|
||||||
RUN set -eux; \
|
|
||||||
GOLANGCI_VERSION="1.64.8"; \
|
|
||||||
ARCH="$(uname -m)"; \
|
|
||||||
case "${ARCH}" in \
|
|
||||||
x86_64) \
|
|
||||||
GOARCH="amd64"; \
|
|
||||||
GOLANGCI_SHA256="b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e"; \
|
|
||||||
;; \
|
|
||||||
aarch64) \
|
|
||||||
GOARCH="arm64"; \
|
|
||||||
GOLANGCI_SHA256="a6ab58ebcb1c48572622146cdaec2956f56871038a54ed1149f1386e287789a5"; \
|
|
||||||
;; \
|
|
||||||
*) echo "unsupported architecture: ${ARCH}" && exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
wget -q "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_VERSION}/golangci-lint-${GOLANGCI_VERSION}-linux-${GOARCH}.tar.gz" \
|
|
||||||
-O /tmp/golangci-lint.tar.gz; \
|
|
||||||
echo "${GOLANGCI_SHA256} /tmp/golangci-lint.tar.gz" | sha256sum -c -; \
|
|
||||||
tar -xzf /tmp/golangci-lint.tar.gz -C /tmp; \
|
|
||||||
mv "/tmp/golangci-lint-${GOLANGCI_VERSION}-linux-${GOARCH}/golangci-lint" /usr/local/bin/; \
|
|
||||||
rm -rf /tmp/golangci-lint*; \
|
|
||||||
golangci-lint --version
|
|
||||||
|
|
||||||
# Copy go module files and download dependencies
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run all checks (fmt-check, lint, test, build)
|
# Run formatting check and linter
|
||||||
RUN make check
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
# alpine:3.21 — 2026-03-01
|
# Build stage
|
||||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||||
|
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
||||||
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||||
|
|
||||||
|
# Depend on lint stage passing
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Run tests and build
|
||||||
|
RUN make test
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
# Rebuild with static linking for Alpine runtime.
|
||||||
|
# make build already verified compilation.
|
||||||
|
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||||
|
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||||
|
# the binary runs on Alpine without glibc.
|
||||||
|
RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
# alpine:3.21, 2026-03-17
|
||||||
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
@@ -54,13 +63,13 @@ RUN addgroup -g 1000 -S webhooker && \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /build/bin/webhooker .
|
COPY --from=builder /build/bin/webhooker /app/webhooker
|
||||||
|
|
||||||
# Create data directory for all SQLite databases (main app DB +
|
# Create data directory for all SQLite databases (main app DB +
|
||||||
# per-webhook event DBs). DATA_DIR defaults to /data in production.
|
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /var/lib/webhooker
|
||||||
|
|
||||||
RUN chown -R webhooker:webhooker /app /data
|
RUN chown -R webhooker:webhooker /app /var/lib/webhooker
|
||||||
|
|
||||||
USER webhooker
|
USER webhooker
|
||||||
|
|
||||||
@@ -69,4 +78,4 @@ EXPOSE 8080
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
||||||
|
|
||||||
CMD ["./webhooker"]
|
CMD ["/app/webhooker"]
|
||||||
|
|||||||
26
Makefile
26
Makefile
@@ -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 target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@script/bootstrap
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race -timeout 30s ./...
|
@script/test
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run --config .golangci.yml ./...
|
@script/lint
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
gofmt -s -w .
|
@script/fmt
|
||||||
@command -v goimports >/dev/null 2>&1 && goimports -w . || true
|
|
||||||
|
|
||||||
fmt-check:
|
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:
|
build:
|
||||||
go build -o bin/webhooker ./cmd/webhooker
|
go build -o bin/webhooker ./cmd/webhooker
|
||||||
@@ -32,15 +38,13 @@ deps:
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t webhooker:latest .
|
@script/docker
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
@script/install-precommit
|
||||||
@chmod +x .git/hooks/pre-commit
|
|
||||||
@echo "pre-commit hook installed"
|
|
||||||
|
|
||||||
css:
|
css:
|
||||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
|||||||
232
README.md
232
README.md
@@ -11,8 +11,8 @@ with retry support, logging, and observability. Category: infrastructure
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Go 1.24+
|
- Go 1.26+
|
||||||
- golangci-lint v1.64+
|
- golangci-lint v2.11+
|
||||||
- Docker (for containerized deployment)
|
- Docker (for containerized deployment)
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
@@ -38,14 +38,16 @@ make docker
|
|||||||
### Development Commands
|
### Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
make bootstrap # Install all dependencies (idempotent)
|
||||||
|
make setup # Bootstrap + install git pre-commit hook
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make lint # Run golangci-lint
|
make lint # Run golangci-lint
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
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 build # Build binary to bin/webhooker
|
||||||
make dev # go run ./cmd/webhooker
|
make dev # go run ./cmd/webhooker
|
||||||
make docker # Build Docker image
|
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
|
### Configuration
|
||||||
@@ -55,17 +57,42 @@ you can place variables in a `.env` file in the project root (loaded
|
|||||||
automatically via `godotenv/autoload`).
|
automatically via `godotenv/autoload`).
|
||||||
|
|
||||||
The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev`
|
The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev`
|
||||||
or `prod` (default: `dev`).
|
or `prod` (default: `dev`). The setting controls several behaviors:
|
||||||
|
|
||||||
|
| Behavior | `dev` | `prod` |
|
||||||
|
| --------------------- | -------------------------------- | ------------------------------- |
|
||||||
|
| 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.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ----------------------- | ----------------------------------- | -------- |
|
| ----------------------- | ----------------------------------- | -------- |
|
||||||
| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `dev` |
|
| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `dev` |
|
||||||
| `PORT` | HTTP listen port | `8080` |
|
| `PORT` | HTTP listen port | `8080` |
|
||||||
| `DATA_DIR` | Directory for all SQLite databases | `./data` (dev) / `/data` (prod) |
|
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
||||||
| `DEBUG` | Enable debug logging | `false` |
|
| `DEBUG` | Enable debug logging | `false` |
|
||||||
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
||||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
||||||
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint | `120` |
|
||||||
|
|
||||||
On first startup, webhooker automatically generates a cryptographically
|
On first startup, webhooker automatically generates a cryptographically
|
||||||
secure session encryption key and stores it in the database. This key
|
secure session encryption key and stores it in the database. This key
|
||||||
@@ -80,18 +107,43 @@ is only displayed once.
|
|||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-v /path/to/data:/data \
|
-v /path/to/data:/var/lib/webhooker \
|
||||||
-e WEBHOOKER_ENVIRONMENT=prod \
|
-e WEBHOOKER_ENVIRONMENT=prod \
|
||||||
webhooker:latest
|
webhooker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The container runs as a non-root user (`webhooker`, UID 1000), exposes
|
The container runs as a non-root user (`webhooker`, UID 1000), exposes
|
||||||
port 8080, and includes a health check against
|
port 8080, and includes a health check against
|
||||||
`/.well-known/healthcheck`. The `/data` volume holds all SQLite
|
`/.well-known/healthcheck`. The `/var/lib/webhooker` volume holds all
|
||||||
databases: the main application database (`webhooker.db`) and the
|
SQLite databases: the main application database (`webhooker.db`) and
|
||||||
per-webhook event databases (`events-{uuid}.db`). Mount this as a
|
the per-webhook event databases (`events-{uuid}.db`). Mount this as a
|
||||||
persistent volume to preserve data across container restarts.
|
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
|
## Rationale
|
||||||
|
|
||||||
Webhook integrations between services are inherently fragile. The
|
Webhook integrations between services are inherently fragile. The
|
||||||
@@ -157,6 +209,10 @@ It uses:
|
|||||||
logging with TTY detection (text for dev, JSON for prod)
|
logging with TTY detection (text for dev, JSON for prod)
|
||||||
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for
|
- **[gorilla/sessions](https://github.com/gorilla/sessions)** for
|
||||||
encrypted cookie-based session management
|
encrypted cookie-based session management
|
||||||
|
- **[gorilla/csrf](https://github.com/gorilla/csrf)** for CSRF
|
||||||
|
protection (cookie-based double-submit tokens)
|
||||||
|
- **[go-chi/httprate](https://github.com/go-chi/httprate)** for
|
||||||
|
per-IP login rate limiting (sliding window counter)
|
||||||
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
- **[Prometheus](https://prometheus.io)** for metrics, served at
|
||||||
`/metrics` behind basic auth
|
`/metrics` behind basic auth
|
||||||
- **[Sentry](https://sentry.io)** for optional error reporting
|
- **[Sentry](https://sentry.io)** for optional error reporting
|
||||||
@@ -291,7 +347,7 @@ events should be forwarded.
|
|||||||
| `id` | UUID | Primary key |
|
| `id` | UUID | Primary key |
|
||||||
| `webhook_id` | UUID | Foreign key → Webhook |
|
| `webhook_id` | UUID | Foreign key → Webhook |
|
||||||
| `name` | string | Human-readable name |
|
| `name` | string | Human-readable name |
|
||||||
| `type` | TargetType | One of: `http`, `database`, `log` |
|
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
||||||
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
||||||
| `config` | JSON text | Type-specific configuration |
|
| `config` | JSON text | Type-specific configuration |
|
||||||
| `max_retries` | integer | Maximum retry attempts for HTTP targets (0 = fire-and-forget, >0 = retries with backoff) |
|
| `max_retries` | integer | Maximum retry attempts for HTTP targets (0 = fire-and-forget, >0 = retries with backoff) |
|
||||||
@@ -463,6 +519,16 @@ target simply marks the delivery as immediately successful. The
|
|||||||
per-webhook DB IS the dedicated event database — that's the whole point
|
per-webhook DB IS the dedicated event database — that's the whole point
|
||||||
of the database target type.
|
of the database target type.
|
||||||
|
|
||||||
|
The **Slack target type** sends webhook events as formatted messages to
|
||||||
|
any Slack-compatible incoming webhook URL (works with Slack, Mattermost,
|
||||||
|
and other compatible services). Each message includes event metadata
|
||||||
|
(HTTP method, content type, timestamp, body size) and the payload
|
||||||
|
pretty-printed in a code block. JSON payloads are automatically
|
||||||
|
formatted with indentation for readability; non-JSON payloads are shown
|
||||||
|
as raw text. Large payloads are truncated to keep messages reasonable.
|
||||||
|
Config stores `webhook_url` — the Slack/Mattermost incoming webhook
|
||||||
|
endpoint.
|
||||||
|
|
||||||
The database uses the
|
The database uses the
|
||||||
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at
|
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at
|
||||||
runtime, though CGO is required at build time due to the transitive
|
runtime, though CGO is required at build time due to the transitive
|
||||||
@@ -601,8 +667,8 @@ fine — startup recovery rescans the database anyway).
|
|||||||
|
|
||||||
**Scope:** Circuit breakers only apply to **HTTP targets with
|
**Scope:** Circuit breakers only apply to **HTTP targets with
|
||||||
`max_retries` > 0**. Fire-and-forget HTTP targets (`max_retries` == 0),
|
`max_retries` > 0**. Fire-and-forget HTTP targets (`max_retries` == 0),
|
||||||
database targets (local operations), and log targets (stdout) do not use
|
Slack targets, database targets (local operations), and log
|
||||||
circuit breakers.
|
targets (stdout) do not use circuit breakers.
|
||||||
|
|
||||||
When a circuit is open and a new delivery arrives, the engine marks the
|
When a circuit is open and a new delivery arrives, the engine marks the
|
||||||
delivery as `retrying` and schedules a retry timer for after the
|
delivery as `retrying` and schedules a retry timer for after the
|
||||||
@@ -611,17 +677,24 @@ just delayed until the target is healthy again.
|
|||||||
|
|
||||||
### Rate Limiting
|
### Rate Limiting
|
||||||
|
|
||||||
Global rate limiting middleware (e.g., per-IP throttling applied at the
|
Global blanket rate limiting middleware (e.g., a per-IP throttle shared
|
||||||
router level) **must not** apply to webhook receiver endpoints. Webhook
|
with the web UI) **must not** apply to webhook receiver endpoints.
|
||||||
endpoints receive automated traffic from external services at
|
Webhook endpoints receive automated traffic from external services at
|
||||||
unpredictable rates, and blanket rate limits would cause legitimate
|
unpredictable rates, and blanket limits shared with other routes would
|
||||||
deliveries to be dropped.
|
cause legitimate deliveries to be dropped.
|
||||||
|
|
||||||
Instead, each webhook has its own individually configurable rate limit,
|
The receiver instead has its own dedicated abuse limit, scoped to the
|
||||||
applied within the webhook handler itself. By default, no rate limit is
|
`/webhook/{uuid}` route only and keyed per client IP per entrypoint: one
|
||||||
applied — webhook endpoints accept traffic as fast as it arrives. Rate
|
misbehaving sender is throttled without affecting other senders of the
|
||||||
limits can be configured per-webhook when needed (e.g., to protect
|
same entrypoint or the same sender's other entrypoints. The limit is
|
||||||
against a misbehaving sender).
|
`RECEIVER_RATE_LIMIT` requests per minute (default 120, generous for
|
||||||
|
legitimate webhook senders). Requests over the limit receive HTTP 429
|
||||||
|
with a `Retry-After` header. A set-but-invalid `RECEIVER_RATE_LIMIT`
|
||||||
|
value aborts startup rather than silently falling back to the default.
|
||||||
|
|
||||||
|
Finer-grained per-webhook rate limits (configured in the web UI and
|
||||||
|
enforced in the webhook handler) can layer on top of this env-level
|
||||||
|
abuse limit later; they are tracked as future work.
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
@@ -629,7 +702,7 @@ against a misbehaving sender).
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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` | `/.well-known/healthcheck` | Health check (JSON: status, uptime, version) |
|
||||||
| `GET` | `/s/*` | Static file serving (embedded CSS, JS) |
|
| `GET` | `/s/*` | Static file serving (embedded CSS, JS) |
|
||||||
| `ANY` | `/webhook/{uuid}` | Webhook receiver endpoint (accepts all methods) |
|
| `ANY` | `/webhook/{uuid}` | Webhook receiver endpoint (accepts all methods) |
|
||||||
@@ -710,7 +783,8 @@ webhooker/
|
|||||||
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
│ │ └── globals.go # Build-time variables (appname, version, arch)
|
||||||
│ ├── delivery/
|
│ ├── delivery/
|
||||||
│ │ ├── engine.go # Event-driven delivery engine (channel + timer based)
|
│ │ ├── engine.go # Event-driven delivery engine (channel + timer based)
|
||||||
│ │ └── circuit_breaker.go # Per-target circuit breaker for HTTP targets with retries
|
│ │ ├── circuit_breaker.go # Per-target circuit breaker for HTTP targets with retries
|
||||||
|
│ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport)
|
||||||
│ ├── handlers/
|
│ ├── handlers/
|
||||||
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
|
│ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering
|
||||||
│ │ ├── auth.go # Login, logout handlers
|
│ │ ├── auth.go # Login, logout handlers
|
||||||
@@ -724,7 +798,9 @@ webhooker/
|
|||||||
│ ├── logger/
|
│ ├── logger/
|
||||||
│ │ └── logger.go # slog setup with TTY detection
|
│ │ └── logger.go # slog setup with TTY detection
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── middleware.go # Logging, CORS, Auth, Metrics, MetricsAuth, SecurityHeaders, MaxBodySize
|
│ │ ├── middleware.go # Logging, CORS, Auth, Metrics, MetricsAuth, SecurityHeaders, MaxBodySize
|
||||||
|
│ │ ├── csrf.go # CSRF protection middleware (gorilla/csrf)
|
||||||
|
│ │ └── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate)
|
||||||
│ ├── server/
|
│ ├── server/
|
||||||
│ │ ├── server.go # Server struct, fx lifecycle, signal handling
|
│ │ ├── server.go # Server struct, fx lifecycle, signal handling
|
||||||
│ │ ├── http.go # HTTP server setup with timeouts
|
│ │ ├── http.go # HTTP server setup with timeouts
|
||||||
@@ -736,7 +812,7 @@ webhooker/
|
|||||||
│ ├── css/style.css # Custom stylesheet (system font stack, card effects, layout)
|
│ ├── css/style.css # Custom stylesheet (system font stack, card effects, layout)
|
||||||
│ └── js/app.js # Client-side JavaScript (minimal bootstrap)
|
│ └── js/app.js # Client-side JavaScript (minimal bootstrap)
|
||||||
├── templates/ # Go HTML templates (base, index, login, etc.)
|
├── templates/ # Go HTML templates (base, index, login, etc.)
|
||||||
├── Dockerfile # Multi-stage: build + check, then Alpine runtime
|
├── Dockerfile # Multi-stage: lint, build+test, then Alpine runtime
|
||||||
├── Makefile # fmt, lint, test, check, build, docker targets
|
├── Makefile # fmt, lint, test, check, build, docker targets
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
└── .golangci.yml # Linter configuration
|
└── .golangci.yml # Linter configuration
|
||||||
@@ -811,6 +887,21 @@ Additionally, form endpoints (`/pages`, `/sources`, `/source/*`) apply a
|
|||||||
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy,
|
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy,
|
||||||
and Permissions-Policy
|
and Permissions-Policy
|
||||||
- Request body size limits (1 MB) on all form POST endpoints
|
- Request body size limits (1 MB) on all form POST endpoints
|
||||||
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
|
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). 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
|
||||||
|
(custom HTTP transport with SSRF-safe dialer that validates resolved
|
||||||
|
IPs before connecting, preventing DNS rebinding attacks)
|
||||||
|
- **Login rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate):
|
||||||
|
per-IP sliding-window rate limiter on the login endpoint (5 POST
|
||||||
|
attempts per minute per IP) to prevent brute-force attacks
|
||||||
- Prometheus metrics behind basic auth
|
- Prometheus metrics behind basic auth
|
||||||
- Static assets embedded in binary (no filesystem access needed at
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
runtime)
|
runtime)
|
||||||
@@ -825,8 +916,8 @@ The Dockerfile uses a multi-stage build:
|
|||||||
golangci-lint, downloads dependencies, copies source, runs `make
|
golangci-lint, downloads dependencies, copies source, runs `make
|
||||||
check` (format verification, linting, tests, compilation).
|
check` (format verification, linting, tests, compilation).
|
||||||
2. **Runtime stage** (`alpine:3.21`) — copies the binary, creates the
|
2. **Runtime stage** (`alpine:3.21`) — copies the binary, creates the
|
||||||
`/data` directory for all SQLite databases, runs as non-root user,
|
`/var/lib/webhooker` directory for all SQLite databases, runs as
|
||||||
exposes port 8080, includes a health check.
|
non-root user, exposes port 8080, includes a health check.
|
||||||
|
|
||||||
The builder uses Debian rather than Alpine because GORM's SQLite
|
The builder uses Debian rather than Alpine because GORM's SQLite
|
||||||
dialect pulls in CGO-dependent headers at compile time. The runtime
|
dialect pulls in CGO-dependent headers at compile time. The runtime
|
||||||
@@ -837,88 +928,7 @@ linted, tested, and compiled.
|
|||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
### Completed: Code Quality (Phase 1 of MVP)
|
See [TODO.md](TODO.md).
|
||||||
- [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] 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)
|
|
||||||
- [ ] CSRF protection for forms
|
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
250
REPO_POLICIES.md
250
REPO_POLICIES.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-07-06
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
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 file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
- 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 bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/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.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
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
|
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
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
`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
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||||
a successful build implies all checks pass.
|
Dockerfile already runs `make check`, a successful build implies all checks
|
||||||
|
pass.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
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,
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
target to install the pre-commit hook.
|
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
|
- All repos with software must have tests that run via the platform-standard
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
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
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
Makefile.
|
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.
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
- `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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
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 use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- 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
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
`PORT`.
|
`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:
|
- `README.md` is the primary documentation. Required sections:
|
||||||
- **Description**: First line must include the project name, purpose,
|
- **Description**: First line must include the project name, purpose,
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
- **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?
|
- **Rationale**: Why does this exist?
|
||||||
- **Design**: How is the program structured?
|
- **Design**: How is the program structured?
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
- **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
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary.
|
the binary.
|
||||||
- `000_migration.sql` — contains ONLY the creation of the migrations tracking
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
table itself. Nothing else.
|
tracking table itself. Nothing else.
|
||||||
- `001_schema.sql` — the full application schema.
|
- `001_schema.sql` — the full application schema.
|
||||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
is no installed base to migrate. Edit `001_schema.sql` directly.
|
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.
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
Never edit existing migrations after release.
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
@@ -181,6 +398,9 @@ style conventions are in separate documents:
|
|||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
- `Makefile`
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
- `Dockerfile`, `.dockerignore`
|
- `Dockerfile`, `.dockerignore`
|
||||||
- `.gitea/workflows/check.yml`
|
- `.gitea/workflows/check.yml`
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
|||||||
78
TODO.md
Normal file
78
TODO.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# 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 (81413c5) is a working webhook proxy
|
||||||
|
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
|
||||||
|
policy compliance (#6), pinned lint tooling (#55), a per-webhook event
|
||||||
|
retention reaper (#63), and delivery targets behind a Target interface
|
||||||
|
(#77). Work is tracked as Gitea issues (the authoritative TODO); this
|
||||||
|
file is a summary. 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
|
||||||
|
|
||||||
|
Manual event redelivery from the web UI (replay is a core promised
|
||||||
|
capability in the README rationale).
|
||||||
|
|
||||||
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-07 Rate-limit the public webhook receiver per client IP per
|
||||||
|
entrypoint, env-configurable with fail-loud parsing (#64)
|
||||||
|
- 2026-08-07 Per-webhook event retention reaper (#63); NoCache
|
||||||
|
middleware for authenticated pages (#61); Target interface refactor
|
||||||
|
(#77)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Delivery status and retry management UI
|
||||||
|
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||||
|
plus handler enforcement, layered on the env-level receiver limit
|
||||||
|
from #64; 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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package main is the entry point for the webhooker application.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,6 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Build-time variables set via -ldflags.
|
// Build-time variables set via -ldflags.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // Build-time variables injected by the linker.
|
||||||
var (
|
var (
|
||||||
version = "dev"
|
version = "dev"
|
||||||
appname = "webhooker"
|
appname = "webhooker"
|
||||||
@@ -31,6 +34,7 @@ func main() {
|
|||||||
config.New,
|
config.New,
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
|
database.NewRetentionReaper,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
@@ -41,6 +45,13 @@ func main() {
|
|||||||
func(e *delivery.Engine) delivery.Notifier { return e },
|
func(e *delivery.Engine) delivery.Notifier { return e },
|
||||||
server.New,
|
server.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(*server.Server, *delivery.Engine) {}),
|
fx.Invoke(
|
||||||
|
func(
|
||||||
|
*server.Server,
|
||||||
|
*delivery.Engine,
|
||||||
|
*database.RetentionReaper,
|
||||||
|
) {
|
||||||
|
},
|
||||||
|
),
|
||||||
).Run()
|
).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -1,15 +1,15 @@
|
|||||||
module sneak.berlin/go/webhooker
|
module sneak.berlin/go/webhooker
|
||||||
|
|
||||||
go 1.23.0
|
go 1.26.1
|
||||||
|
|
||||||
toolchain go1.24.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/csrf v1.7.3
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/prometheus/client_golang v1.18.0
|
github.com/prometheus/client_golang v1.18.0
|
||||||
@@ -31,6 +31,7 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
@@ -40,6 +41,7 @@ require (
|
|||||||
github.com/prometheus/common v0.45.0 // indirect
|
github.com/prometheus/common v0.45.0 // indirect
|
||||||
github.com/prometheus/procfs v0.12.0 // indirect
|
github.com/prometheus/procfs v0.12.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/dig v1.17.0 // indirect
|
go.uber.org/dig v1.17.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -19,6 +19,8 @@ github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
|||||||
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -31,6 +33,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
|
|||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||||
|
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
@@ -43,6 +47,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -80,6 +86,10 @@ github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdr
|
|||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
|
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
// Package config loads application configuration from environment variables.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -17,19 +20,44 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// EnvironmentDev represents development environment
|
// EnvironmentDev represents development environment.
|
||||||
EnvironmentDev = "dev"
|
EnvironmentDev = "dev"
|
||||||
// EnvironmentProd represents production environment
|
// EnvironmentProd represents production environment.
|
||||||
EnvironmentProd = "prod"
|
EnvironmentProd = "prod"
|
||||||
|
|
||||||
|
// defaultPort is the default HTTP listen port.
|
||||||
|
defaultPort = 8080
|
||||||
|
|
||||||
|
// defaultRetentionSweepInterval is how often the retention
|
||||||
|
// reaper deletes events older than each webhook's RetentionDays.
|
||||||
|
defaultRetentionSweepInterval = time.Hour
|
||||||
|
|
||||||
|
// defaultReceiverRateLimit is the default number of requests
|
||||||
|
// per minute each client IP may send to a single webhook
|
||||||
|
// receiver entrypoint. Generous for legitimate webhook
|
||||||
|
// senders while bounding abuse of the one unauthenticated,
|
||||||
|
// internet-exposed endpoint.
|
||||||
|
defaultReceiverRateLimit = 120
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // ConfigParams is a standard fx naming convention
|
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||||
|
// contains an unrecognised value.
|
||||||
|
var ErrInvalidEnvironment = errors.New("invalid environment")
|
||||||
|
|
||||||
|
// ErrNonPositiveValue is returned when an environment variable that
|
||||||
|
// requires a positive integer is set to zero or a negative number.
|
||||||
|
var ErrNonPositiveValue = errors.New("value must be positive")
|
||||||
|
|
||||||
|
//nolint:revive // ConfigParams is a standard fx naming convention.
|
||||||
type ConfigParams struct {
|
type ConfigParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config holds all application configuration loaded from
|
||||||
|
// environment variables.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DataDir string
|
DataDir string
|
||||||
Debug bool
|
Debug bool
|
||||||
@@ -39,60 +67,157 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
|
|
||||||
|
// RetentionSweepInterval is how often the retention reaper runs.
|
||||||
|
RetentionSweepInterval time.Duration
|
||||||
|
|
||||||
|
// ReceiverRateLimit is the number of requests per minute each
|
||||||
|
// client IP may send to a single webhook receiver entrypoint.
|
||||||
|
ReceiverRateLimit int
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDev returns true if running in development environment
|
// IsDev returns true if running in development environment.
|
||||||
func (c *Config) IsDev() bool {
|
func (c *Config) IsDev() bool {
|
||||||
return c.Environment == EnvironmentDev
|
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 {
|
func (c *Config) IsProd() bool {
|
||||||
return c.Environment == EnvironmentProd
|
return c.Environment == EnvironmentProd
|
||||||
}
|
}
|
||||||
|
|
||||||
// envString returns the value of the named environment variable, or
|
// envString returns the value of the named environment variable,
|
||||||
// an empty string if not set.
|
// or an empty string if not set.
|
||||||
func envString(key string) string {
|
func envString(key string) string {
|
||||||
return os.Getenv(key)
|
return os.Getenv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// envBool returns the value of the named environment variable parsed as a
|
// envBool returns the value of the named environment variable
|
||||||
// boolean. Returns defaultValue if not set.
|
// parsed as a boolean. Returns defaultValue if not set.
|
||||||
func envBool(key string, defaultValue bool) bool {
|
func envBool(key string, defaultValue bool) bool {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return strings.EqualFold(v, "true") || v == "1"
|
return strings.EqualFold(v, "true") || v == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// envInt returns the value of the named environment variable parsed as an
|
// envInt returns the value of the named environment variable
|
||||||
// integer. Returns defaultValue if not set or unparseable.
|
// parsed as an integer. Returns defaultValue if not set or
|
||||||
|
// unparseable.
|
||||||
func envInt(key string, defaultValue int) int {
|
func envInt(key string, defaultValue int) int {
|
||||||
if v := os.Getenv(key); v != "" {
|
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 i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:revive // lc parameter is required by fx even if unused
|
// envPositiveInt returns the value of the named environment variable
|
||||||
|
// parsed as a positive integer. Returns defaultValue if not set. If
|
||||||
|
// the variable is set but cannot be parsed, or parses to less than
|
||||||
|
// one, it returns a wrapped error naming the key and the bad value,
|
||||||
|
// so startup fails loudly rather than silently falling back to the
|
||||||
|
// default.
|
||||||
|
func envPositiveInt(
|
||||||
|
key string,
|
||||||
|
defaultValue int,
|
||||||
|
) (int, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"invalid integer for %s: %q: %w", key, v, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < 1 {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: %s must be at least 1, got %q",
|
||||||
|
ErrNonPositiveValue, key, v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envDuration returns the value of the named environment variable
|
||||||
|
// parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if
|
||||||
|
// not set. If the variable is set but cannot be parsed, it returns a
|
||||||
|
// wrapped error naming the key and the bad value, so startup fails
|
||||||
|
// loudly rather than silently falling back to the default.
|
||||||
|
func envDuration(
|
||||||
|
key string,
|
||||||
|
defaultValue time.Duration,
|
||||||
|
) (time.Duration, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"invalid duration for %s: %q: %w", key, v, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Config by reading environment variables.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||||
log := params.Logger.Get()
|
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")
|
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
||||||
if environment == "" {
|
if environment == "" {
|
||||||
environment = EnvironmentDev
|
environment = EnvironmentDev
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate environment
|
// Validate environment
|
||||||
if environment != EnvironmentDev && environment != EnvironmentProd {
|
if environment != EnvironmentDev &&
|
||||||
return nil, fmt.Errorf("WEBHOOKER_ENVIRONMENT must be either '%s' or '%s', got '%s'",
|
environment != EnvironmentProd {
|
||||||
EnvironmentDev, EnvironmentProd, environment)
|
return nil, fmt.Errorf(
|
||||||
|
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
|
||||||
|
ErrInvalidEnvironment,
|
||||||
|
EnvironmentDev, EnvironmentProd, environment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the retention sweep interval; a set-but-unparseable value
|
||||||
|
// is a hard error so fx aborts startup rather than silently using
|
||||||
|
// the default.
|
||||||
|
retentionSweepInterval, err := envDuration(
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
defaultRetentionSweepInterval,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the receiver rate limit; a set-but-unparseable or
|
||||||
|
// non-positive value is a hard error so fx aborts startup
|
||||||
|
// rather than silently using the default.
|
||||||
|
receiverRateLimit, err := envPositiveInt(
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
defaultReceiverRateLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load configuration values from environment variables
|
// Load configuration values from environment variables
|
||||||
@@ -103,20 +228,20 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
Environment: environment,
|
Environment: environment,
|
||||||
MetricsUsername: envString("METRICS_USERNAME"),
|
MetricsUsername: envString("METRICS_USERNAME"),
|
||||||
MetricsPassword: envString("METRICS_PASSWORD"),
|
MetricsPassword: envString("METRICS_PASSWORD"),
|
||||||
Port: envInt("PORT", 8080),
|
Port: envInt("PORT", defaultPort),
|
||||||
SentryDSN: envString("SENTRY_DSN"),
|
SentryDSN: envString("SENTRY_DSN"),
|
||||||
|
RetentionSweepInterval: retentionSweepInterval,
|
||||||
|
ReceiverRateLimit: receiverRateLimit,
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default DataDir based on environment. All SQLite databases
|
// Set default DataDir. All SQLite databases (main application
|
||||||
// (main application DB and per-webhook event DBs) live here.
|
// 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 == "" {
|
if s.DataDir == "" {
|
||||||
if s.IsProd() {
|
s.DataDir = "/var/lib/webhooker"
|
||||||
s.DataDir = "/data"
|
|
||||||
} else {
|
|
||||||
s.DataDir = "./data"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Debug {
|
if s.Debug {
|
||||||
@@ -130,8 +255,11 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
"debug", s.Debug,
|
"debug", s.Debug,
|
||||||
"maintenanceMode", s.MaintenanceMode,
|
"maintenanceMode", s.MaintenanceMode,
|
||||||
"dataDir", s.DataDir,
|
"dataDir", s.DataDir,
|
||||||
|
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
||||||
|
"receiverRateLimit", s.ReceiverRateLimit,
|
||||||
"hasSentryDSN", s.SentryDSN != "",
|
"hasSentryDSN", s.SentryDSN != "",
|
||||||
"hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "",
|
"hasMetricsAuth",
|
||||||
|
s.MetricsUsername != "" && s.MetricsPassword != "",
|
||||||
)
|
)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package config
|
package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -23,84 +25,342 @@ func TestEnvironmentConfig(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default is dev",
|
name: "default is dev",
|
||||||
envValue: "",
|
|
||||||
envVars: map[string]string{},
|
|
||||||
expectError: false,
|
|
||||||
isDev: true,
|
isDev: true,
|
||||||
isProd: false,
|
isProd: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "explicit dev",
|
name: "explicit dev",
|
||||||
envValue: "dev",
|
envValue: "dev",
|
||||||
envVars: map[string]string{},
|
|
||||||
expectError: false,
|
|
||||||
isDev: true,
|
isDev: true,
|
||||||
isProd: false,
|
isProd: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "explicit prod",
|
name: "explicit prod",
|
||||||
envValue: "prod",
|
envValue: "prod",
|
||||||
envVars: map[string]string{},
|
|
||||||
expectError: false,
|
|
||||||
isDev: false,
|
isDev: false,
|
||||||
isProd: true,
|
isProd: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid environment",
|
name: "invalid environment",
|
||||||
envValue: "staging",
|
envValue: "staging",
|
||||||
envVars: map[string]string{},
|
|
||||||
expectError: true,
|
expectError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 != "" {
|
if tt.envValue != "" {
|
||||||
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue)
|
t.Setenv(
|
||||||
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
|
"WEBHOOKER_ENVIRONMENT", tt.envValue,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
os.Unsetenv("WEBHOOKER_ENVIRONMENT")
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"WEBHOOKER_ENVIRONMENT",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set additional environment variables
|
|
||||||
for k, v := range tt.envVars {
|
for k, v := range tt.envVars {
|
||||||
os.Setenv(k, v)
|
t.Setenv(k, v)
|
||||||
defer os.Unsetenv(k)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
// Use regular fx.New for error cases since fxtest doesn't expose errors the same way
|
testEnvironmentConfigError(t)
|
||||||
var cfg *Config
|
} else {
|
||||||
|
testEnvironmentConfigSuccess(
|
||||||
|
t, tt.isDev, tt.isProd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnvironmentConfigError(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
app := fx.New(
|
app := fx.New(
|
||||||
fx.NopLogger, // Suppress fx logs in tests
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
New,
|
config.New,
|
||||||
),
|
),
|
||||||
fx.Populate(&cfg),
|
fx.Populate(&cfg),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Error(t, app.Err())
|
assert.Error(t, app.Err())
|
||||||
} else {
|
}
|
||||||
// Use fxtest for success cases
|
|
||||||
var cfg *Config
|
func testEnvironmentConfigSuccess(
|
||||||
|
t *testing.T,
|
||||||
|
isDev, isProd bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
New,
|
config.New,
|
||||||
),
|
),
|
||||||
fx.Populate(&cfg),
|
fx.Populate(&cfg),
|
||||||
)
|
)
|
||||||
require.NoError(t, app.Err())
|
require.NoError(t, app.Err())
|
||||||
|
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
defer app.RequireStop()
|
defer app.RequireStop()
|
||||||
|
|
||||||
assert.Equal(t, tt.isDev, cfg.IsDev())
|
assert.Equal(t, isDev, cfg.IsDev())
|
||||||
assert.Equal(t, tt.isProd, cfg.IsProd())
|
assert.Equal(t, isProd, cfg.IsProd())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetentionSweepInterval(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unset uses default",
|
||||||
|
set: false,
|
||||||
|
expected: time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid value is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "15m",
|
||||||
|
expected: 15 * time.Minute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable value fails startup",
|
||||||
|
set: true,
|
||||||
|
value: "not-a-duration",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
testRetentionSweepIntervalError(t)
|
||||||
|
} else {
|
||||||
|
testRetentionSweepIntervalSuccess(t, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRetentionSweepIntervalError(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 testRetentionSweepIntervalSuccess(
|
||||||
|
t *testing.T,
|
||||||
|
expected time.Duration,
|
||||||
|
) {
|
||||||
|
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, expected, cfg.RetentionSweepInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultDataDir(t *testing.T) {
|
||||||
|
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("DATA_DIR"))
|
||||||
|
|
||||||
|
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, "/var/lib/webhooker", cfg.DataDir,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiverRateLimit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unset uses default",
|
||||||
|
set: false,
|
||||||
|
expected: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid value is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "30",
|
||||||
|
expected: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable value fails startup",
|
||||||
|
set: true,
|
||||||
|
value: "not-a-number",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero fails startup",
|
||||||
|
set: true,
|
||||||
|
value: "0",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative fails startup",
|
||||||
|
set: true,
|
||||||
|
value: "-5",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
testReceiverRateLimitError(t)
|
||||||
|
} else {
|
||||||
|
testReceiverRateLimitSuccess(t, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReceiverRateLimitError(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 testReceiverRateLimitSuccess(
|
||||||
|
t *testing.T,
|
||||||
|
expected int,
|
||||||
|
) {
|
||||||
|
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, expected, cfg.ReceiverRateLimit)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ import (
|
|||||||
// This replaces gorm.Model but uses UUID instead of uint for ID
|
// This replaces gorm.Model but uses UUID instead of uint for ID
|
||||||
type BaseModel struct {
|
type BaseModel struct {
|
||||||
ID string `gorm:"type:uuid;primary_key" json:"id"`
|
ID string `gorm:"type:uuid;primary_key" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeforeCreate hook to set UUID before creating a record
|
// BeforeCreate hook to set UUID before creating a record.
|
||||||
func (b *BaseModel) BeforeCreate(tx *gorm.DB) error {
|
func (b *BaseModel) BeforeCreate(_ *gorm.DB) error {
|
||||||
if b.ID == "" {
|
if b.ID == "" {
|
||||||
b.ID = uuid.New().String()
|
b.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package database provides SQLite persistence for webhooks, events, and users.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,30 +20,42 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // DatabaseParams is a standard fx naming convention
|
const (
|
||||||
|
dataDirPerm = 0750
|
||||||
|
randomPasswordLen = 16
|
||||||
|
sessionKeyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:revive // DatabaseParams is a standard fx naming convention.
|
||||||
type DatabaseParams struct {
|
type DatabaseParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Database manages the main SQLite connection and schema migrations.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *DatabaseParams
|
params *DatabaseParams
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
// New creates a Database that connects on fx start and disconnects on stop.
|
||||||
|
func New(
|
||||||
|
lc fx.Lifecycle,
|
||||||
|
params DatabaseParams,
|
||||||
|
) (*Database, error) {
|
||||||
d := &Database{
|
d := &Database{
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
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()
|
return d.connect()
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
|
OnStop: func(_ context.Context) error {
|
||||||
return d.close()
|
return d.close()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -50,21 +63,92 @@ func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
|||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying GORM database handle.
|
||||||
|
func (d *Database) DB() *gorm.DB {
|
||||||
|
return d.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateSessionKey retrieves the session encryption key from the
|
||||||
|
// settings table. If no key exists, a cryptographically secure random
|
||||||
|
// 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)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new cryptographically secure 32-byte key
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
return encoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) connect() error {
|
func (d *Database) connect() error {
|
||||||
// Ensure the data directory exists before opening the database.
|
// Ensure the data directory exists before opening the database.
|
||||||
dataDir := d.params.Config.DataDir
|
dataDir := d.params.Config.DataDir
|
||||||
if err := os.MkdirAll(dataDir, 0750); err != nil {
|
|
||||||
return fmt.Errorf("creating data directory %s: %w", dataDir, err)
|
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.
|
// Construct the main application database path inside DATA_DIR.
|
||||||
dbPath := filepath.Join(dataDir, "webhooker.db")
|
dbPath := filepath.Join(dataDir, "webhooker.db")
|
||||||
dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", dbPath)
|
dbURL := fmt.Sprintf(
|
||||||
|
"file:%s?cache=shared&mode=rwc",
|
||||||
|
dbPath,
|
||||||
|
)
|
||||||
|
|
||||||
// Open the database with the pure Go SQLite driver
|
// Open the database with the pure Go SQLite driver
|
||||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
sqlDB, err := sql.Open("sqlite", dbURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error("failed to open database", "error", err)
|
d.log.Error(
|
||||||
|
"failed to open database",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +157,11 @@ func (d *Database) connect() error {
|
|||||||
Conn: sqlDB,
|
Conn: sqlDB,
|
||||||
}, &gorm.Config{})
|
}, &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error("failed to connect to database", "error", err)
|
d.log.Error(
|
||||||
|
"failed to connect to database",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,34 +174,62 @@ func (d *Database) connect() error {
|
|||||||
|
|
||||||
func (d *Database) migrate() error {
|
func (d *Database) migrate() error {
|
||||||
// Run GORM auto-migrations
|
// Run GORM auto-migrations
|
||||||
if err := d.Migrate(); err != nil {
|
err := d.Migrate()
|
||||||
d.log.Error("failed to run database migrations", "error", err)
|
if err != nil {
|
||||||
|
d.log.Error(
|
||||||
|
"failed to run database migrations",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
d.log.Info("database migrations completed")
|
d.log.Info("database migrations completed")
|
||||||
|
|
||||||
// Check if admin user exists
|
// Check if admin user exists
|
||||||
var userCount int64
|
var userCount int64
|
||||||
if err := d.db.Model(&User{}).Count(&userCount).Error; err != nil {
|
|
||||||
d.log.Error("failed to count users", "error", err)
|
err = d.db.Model(&User{}).Count(&userCount).Error
|
||||||
|
if err != nil {
|
||||||
|
d.log.Error(
|
||||||
|
"failed to count users",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if userCount == 0 {
|
if userCount == 0 {
|
||||||
// Create admin user
|
return d.createAdminUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) createAdminUser() error {
|
||||||
d.log.Info("no users found, creating admin user")
|
d.log.Info("no users found, creating admin user")
|
||||||
|
|
||||||
// Generate random password
|
// Generate random password
|
||||||
password, err := GenerateRandomPassword(16)
|
password, err := GenerateRandomPassword(
|
||||||
|
randomPasswordLen,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error("failed to generate random password", "error", err)
|
d.log.Error(
|
||||||
|
"failed to generate random password",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the password
|
// Hash the password
|
||||||
hashedPassword, err := HashPassword(password)
|
hashedPassword, err := HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error("failed to hash password", "error", err)
|
d.log.Error(
|
||||||
|
"failed to hash password",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,17 +239,22 @@ func (d *Database) migrate() error {
|
|||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.db.Create(adminUser).Error; err != nil {
|
err = d.db.Create(adminUser).Error
|
||||||
d.log.Error("failed to create admin user", "error", err)
|
if err != nil {
|
||||||
|
d.log.Error(
|
||||||
|
"failed to create admin user",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
d.log.Info("admin user created",
|
d.log.Info("admin user created",
|
||||||
"username", "admin",
|
"username", "admin",
|
||||||
"password", password,
|
"password", password,
|
||||||
"message", "SAVE THIS PASSWORD - it will not be shown again!",
|
"message",
|
||||||
|
"SAVE THIS PASSWORD - it will not be shown again!",
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -144,43 +265,9 @@ func (d *Database) close() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sqlDB.Close()
|
return sqlDB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) DB() *gorm.DB {
|
|
||||||
return d.db
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrCreateSessionKey retrieves the session encryption key from the
|
|
||||||
// settings table. If no key exists, a cryptographically secure random
|
|
||||||
// 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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.log.Info("generated new session key and stored in database")
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,37 +6,37 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDatabaseConnection(t *testing.T) {
|
func setupTestDB(
|
||||||
// Set up test dependencies
|
t *testing.T,
|
||||||
|
) (*database.Database, *fxtest.Lifecycle) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
// Create globals
|
g := &globals.Globals{
|
||||||
globals.Appname = "webhooker-test"
|
Appname: "webhooker-test",
|
||||||
globals.Version = "test"
|
Version: "test",
|
||||||
|
|
||||||
g, err := globals.New(lc)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create globals: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create logger
|
l, err := logger.New(
|
||||||
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
lc,
|
||||||
|
logger.LoggerParams{Globals: g},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create logger: %v", err)
|
t.Fatalf("Failed to create logger: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create config with DataDir pointing to a temp directory
|
|
||||||
c := &config.Config{
|
c := &config.Config{
|
||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
Environment: "dev",
|
Environment: "dev",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create database
|
db, err := database.New(lc, database.DatabaseParams{
|
||||||
db, err := New(lc, DatabaseParams{
|
|
||||||
Config: c,
|
Config: c,
|
||||||
Logger: l,
|
Logger: l,
|
||||||
})
|
})
|
||||||
@@ -44,31 +44,45 @@ func TestDatabaseConnection(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create database: %v", err)
|
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()
|
ctx := context.Background()
|
||||||
err = lc.Start(ctx)
|
|
||||||
|
err := lc.Start(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to connect to database: %v", err)
|
t.Fatalf("Failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if stopErr := lc.Stop(ctx); stopErr != nil {
|
stopErr := lc.Stop(ctx)
|
||||||
t.Errorf("Failed to stop lifecycle: %v", stopErr)
|
if stopErr != nil {
|
||||||
|
t.Errorf(
|
||||||
|
"Failed to stop lifecycle: %v",
|
||||||
|
stopErr,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Verify we can get the DB instance
|
|
||||||
if db.DB() == nil {
|
if db.DB() == nil {
|
||||||
t.Error("Expected non-nil database connection")
|
t.Error("Expected non-nil database connection")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that we can perform a simple query
|
|
||||||
var result int
|
var result int
|
||||||
|
|
||||||
err = db.DB().Raw("SELECT 1").Scan(&result).Error
|
err = db.DB().Raw("SELECT 1").Scan(&result).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to execute test query: %v", err)
|
t.Fatalf("Failed to execute test query: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != 1 {
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
internal/database/export_test.go
Normal file
31
internal/database/export_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTestRetentionReaper builds a RetentionReaper backed by the given
|
||||||
|
// main database and per-webhook database manager, without the fx
|
||||||
|
// lifecycle. Intended for tests.
|
||||||
|
func NewTestRetentionReaper(
|
||||||
|
db *Database,
|
||||||
|
mgr *WebhookDBManager,
|
||||||
|
) *RetentionReaper {
|
||||||
|
return &RetentionReaper{
|
||||||
|
db: db,
|
||||||
|
dbManager: mgr,
|
||||||
|
log: slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
)),
|
||||||
|
interval: time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||||
|
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||||
|
r.sweep(ctx)
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ import "time"
|
|||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
BaseModel
|
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"`
|
Key string `gorm:"uniqueIndex;not null" json:"key"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
User User `json:"user,omitempty"`
|
User User `json:"user,omitzero"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
|
|
||||||
|
// Delivery status values.
|
||||||
const (
|
const (
|
||||||
DeliveryStatusPending DeliveryStatus = "pending"
|
DeliveryStatusPending DeliveryStatus = "pending"
|
||||||
DeliveryStatusDelivered DeliveryStatus = "delivered"
|
DeliveryStatusDelivered DeliveryStatus = "delivered"
|
||||||
@@ -14,12 +15,12 @@ const (
|
|||||||
type Delivery struct {
|
type Delivery struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null" json:"event_id"`
|
EventID string `gorm:"type:uuid;not null" json:"eventId"`
|
||||||
TargetID string `gorm:"type:uuid;not null" json:"target_id"`
|
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
|
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitempty"`
|
Event Event `json:"event,omitzero"`
|
||||||
Target Target `json:"target,omitempty"`
|
Target Target `json:"target,omitzero"`
|
||||||
DeliveryResults []DeliveryResult `json:"delivery_results,omitempty"`
|
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ package database
|
|||||||
type DeliveryResult struct {
|
type DeliveryResult struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
DeliveryID string `gorm:"type:uuid;not null" json:"delivery_id"`
|
DeliveryID string `gorm:"type:uuid;not null" json:"deliveryId"`
|
||||||
AttemptNum int `gorm:"not null" json:"attempt_num"`
|
AttemptNum int `gorm:"not null" json:"attemptNum"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
StatusCode int `json:"status_code,omitempty"`
|
StatusCode int `json:"statusCode,omitempty"`
|
||||||
ResponseBody string `gorm:"type:text" json:"response_body,omitempty"`
|
ResponseBody string `gorm:"type:text" json:"responseBody,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Duration int64 `json:"duration_ms"` // Duration in milliseconds
|
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Delivery Delivery `json:"delivery,omitempty"`
|
Delivery Delivery `json:"delivery,omitzero"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ package database
|
|||||||
type Entrypoint struct {
|
type Entrypoint struct {
|
||||||
BaseModel
|
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
|
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhook Webhook `json:"webhook,omitempty"`
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ package database
|
|||||||
type Event struct {
|
type Event struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypoint_id"`
|
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||||
|
|
||||||
// Request data
|
// Request data
|
||||||
Method string `gorm:"not null" json:"method"`
|
Method string `gorm:"not null" json:"method"`
|
||||||
Headers string `gorm:"type:text" json:"headers"` // JSON
|
Headers string `gorm:"type:text" json:"headers"` // JSON
|
||||||
Body string `gorm:"type:text" json:"body"`
|
Body string `gorm:"type:text" json:"body"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhook Webhook `json:"webhook,omitempty"`
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Entrypoint Entrypoint `json:"entrypoint,omitempty"`
|
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ package database
|
|||||||
// TargetType represents the type of delivery target
|
// TargetType represents the type of delivery target
|
||||||
type TargetType string
|
type TargetType string
|
||||||
|
|
||||||
|
// Target type values.
|
||||||
const (
|
const (
|
||||||
TargetTypeHTTP TargetType = "http"
|
TargetTypeHTTP TargetType = "http"
|
||||||
TargetTypeDatabase TargetType = "database"
|
TargetTypeDatabase TargetType = "database"
|
||||||
TargetTypeLog TargetType = "log"
|
TargetTypeLog TargetType = "log"
|
||||||
|
TargetTypeSlack TargetType = "slack"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Target represents a delivery target for a webhook
|
// Target represents a delivery target for a webhook
|
||||||
type Target struct {
|
type Target struct {
|
||||||
BaseModel
|
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"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Type TargetType `gorm:"not null" json:"type"`
|
Type TargetType `gorm:"not null" json:"type"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
@@ -22,10 +24,10 @@ type Target struct {
|
|||||||
Config string `gorm:"type:text" json:"config"` // JSON configuration
|
Config string `gorm:"type:text" json:"config"` // JSON configuration
|
||||||
|
|
||||||
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff)
|
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff)
|
||||||
MaxRetries int `json:"max_retries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"max_queue_size,omitempty"`
|
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhook Webhook `json:"webhook,omitempty"`
|
Webhook Webhook `json:"webhook,omitzero"`
|
||||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ type User struct {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ package database
|
|||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
BaseModel
|
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"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Description string `json:"description"`
|
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
|
// Relations
|
||||||
User User `json:"user,omitempty"`
|
User User `json:"user,omitzero"`
|
||||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||||
Targets []Target `json:"targets,omitempty"`
|
Targets []Target `json:"targets,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,6 +21,23 @@ const (
|
|||||||
argon2SaltLen = 16
|
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
|
// PasswordConfig holds Argon2 configuration
|
||||||
type PasswordConfig struct {
|
type PasswordConfig struct {
|
||||||
Time uint32
|
Time uint32
|
||||||
@@ -46,26 +64,44 @@ func HashPassword(password string) (string, error) {
|
|||||||
|
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
salt := make([]byte, config.SaltLen)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
|
||||||
|
_, err := rand.Read(salt)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the hash
|
// 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
|
// Encode the hash and parameters
|
||||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
|
||||||
// Format: $argon2id$v=19$m=65536,t=1,p=4$salt$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",
|
encoded := fmt.Sprintf(
|
||||||
argon2.Version, config.Memory, config.Time, config.Threads, b64Salt, b64Hash)
|
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argon2.Version,
|
||||||
|
config.Memory,
|
||||||
|
config.Time,
|
||||||
|
config.Threads,
|
||||||
|
b64Salt,
|
||||||
|
b64Hash,
|
||||||
|
)
|
||||||
|
|
||||||
return encoded, nil
|
return encoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword checks if the provided password matches the hash
|
// 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
|
// Extract parameters and hash from encoded string
|
||||||
config, salt, hash, err := decodeHash(encodedHash)
|
config, salt, hash, err := decodeHash(encodedHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,60 +109,119 @@ func VerifyPassword(password, encodedHash string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate hash of the provided password
|
// 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
|
// Compare hashes using constant time comparison
|
||||||
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
|
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeHash extracts parameters, salt, and hash from an encoded hash string
|
// decodeHash extracts parameters, salt, and hash from an
|
||||||
func decodeHash(encodedHash string) (*PasswordConfig, []byte, []byte, error) {
|
// encoded hash string.
|
||||||
|
func decodeHash(
|
||||||
|
encodedHash string,
|
||||||
|
) (*PasswordConfig, []byte, []byte, error) {
|
||||||
parts := strings.Split(encodedHash, "$")
|
parts := strings.Split(encodedHash, "$")
|
||||||
if len(parts) != 6 {
|
if len(parts) != hashParts {
|
||||||
return nil, nil, nil, fmt.Errorf("invalid hash format")
|
return nil, nil, nil, errInvalidHashFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
if parts[1] != "argon2id" {
|
if parts[1] != "argon2id" {
|
||||||
return nil, nil, nil, fmt.Errorf("invalid algorithm")
|
return nil, nil, nil, errInvalidAlgorithm
|
||||||
}
|
}
|
||||||
|
|
||||||
var version int
|
version, err := parseVersion(parts[2])
|
||||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if version != argon2.Version {
|
if version != argon2.Version {
|
||||||
return nil, nil, nil, fmt.Errorf("incompatible argon2 version")
|
return nil, nil, nil, errIncompatibleVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
config := &PasswordConfig{}
|
config, err := parseParams(parts[3])
|
||||||
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])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
hashLen := len(hash)
|
|
||||||
if hashLen < 0 || hashLen > int(^uint32(0)) {
|
config.SaltLen = uint32(len(salt)) //nolint:gosec // validated in decodeSalt
|
||||||
return nil, nil, nil, fmt.Errorf("hash length out of range")
|
|
||||||
|
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
|
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) {
|
func GenerateRandomPassword(length int) (string, error) {
|
||||||
const (
|
const (
|
||||||
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
@@ -141,27 +236,27 @@ func GenerateRandomPassword(length int) (string, error) {
|
|||||||
// Create password slice
|
// Create password slice
|
||||||
password := make([]byte, length)
|
password := make([]byte, length)
|
||||||
|
|
||||||
// Ensure at least one character from each set for password complexity
|
// Ensure at least one character from each set
|
||||||
if length >= 4 {
|
if length >= minPasswordComplexityLen {
|
||||||
// Get one character from each set
|
|
||||||
password[0] = uppercase[cryptoRandInt(len(uppercase))]
|
password[0] = uppercase[cryptoRandInt(len(uppercase))]
|
||||||
password[1] = lowercase[cryptoRandInt(len(lowercase))]
|
password[1] = lowercase[cryptoRandInt(len(lowercase))]
|
||||||
password[2] = digits[cryptoRandInt(len(digits))]
|
password[2] = digits[cryptoRandInt(len(digits))]
|
||||||
password[3] = special[cryptoRandInt(len(special))]
|
password[3] = special[cryptoRandInt(len(special))]
|
||||||
|
|
||||||
// Fill the rest randomly from all characters
|
// 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))]
|
password[i] = allChars[cryptoRandInt(len(allChars))]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shuffle the password to avoid predictable pattern
|
// Shuffle the password to avoid predictable pattern
|
||||||
for i := len(password) - 1; i > 0; i-- {
|
for i := range len(password) - 1 {
|
||||||
j := cryptoRandInt(i + 1)
|
j := cryptoRandInt(len(password) - i)
|
||||||
password[i], password[j] = password[j], password[i]
|
idx := len(password) - 1 - i
|
||||||
|
password[idx], password[j] = password[j], password[idx]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For very short passwords, just use all characters
|
// For very short passwords, just use all characters
|
||||||
for i := 0; i < length; i++ {
|
for i := range length {
|
||||||
password[i] = allChars[cryptoRandInt(len(allChars))]
|
password[i] = allChars[cryptoRandInt(len(allChars))]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,16 +264,17 @@ func GenerateRandomPassword(length int) (string, error) {
|
|||||||
return string(password), nil
|
return string(password), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cryptoRandInt generates a cryptographically secure random integer in [0, max)
|
// cryptoRandInt generates a cryptographically secure random
|
||||||
func cryptoRandInt(max int) int {
|
// integer in [0, upperBound).
|
||||||
if max <= 0 {
|
func cryptoRandInt(upperBound int) int {
|
||||||
panic("max must be positive")
|
if upperBound <= 0 {
|
||||||
|
panic("upperBound must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the maximum valid value to avoid modulo bias
|
nBig, err := rand.Int(
|
||||||
// For example, if max=200 and we have 256 possible values,
|
rand.Reader,
|
||||||
// we only accept values 0-199 (reject 200-255)
|
big.NewInt(int64(upperBound)),
|
||||||
nBig, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("crypto/rand error: %v", err))
|
panic(fmt.Sprintf("crypto/rand error: %v", err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateRandomPassword(t *testing.T) {
|
func TestGenerateRandomPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
length int
|
length int
|
||||||
@@ -18,109 +22,172 @@ func TestGenerateRandomPassword(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
password, err := GenerateRandomPassword(tt.length)
|
t.Parallel()
|
||||||
|
|
||||||
|
password, err := database.GenerateRandomPassword(
|
||||||
|
tt.length,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateRandomPassword() error = %v", err)
|
t.Fatalf(
|
||||||
|
"GenerateRandomPassword() error = %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(password) != tt.length {
|
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
|
checkPasswordComplexity(
|
||||||
if tt.length >= 4 {
|
t, password, tt.length,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestGenerateRandomPasswordUniqueness(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Generate multiple passwords and ensure they're different
|
// Generate multiple passwords and ensure they're different
|
||||||
passwords := make(map[string]bool)
|
passwords := make(map[string]bool)
|
||||||
|
|
||||||
const numPasswords = 100
|
const numPasswords = 100
|
||||||
|
|
||||||
for i := 0; i < numPasswords; i++ {
|
for range numPasswords {
|
||||||
password, err := GenerateRandomPassword(16)
|
password, err := database.GenerateRandomPassword(16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateRandomPassword() error = %v", err)
|
t.Fatalf(
|
||||||
|
"GenerateRandomPassword() error = %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if passwords[password] {
|
if passwords[password] {
|
||||||
t.Errorf("Duplicate password generated: %s", password)
|
t.Errorf(
|
||||||
|
"Duplicate password generated: %s",
|
||||||
|
password,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
passwords[password] = true
|
passwords[password] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashPassword(t *testing.T) {
|
func TestHashPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
password := "testPassword123!"
|
password := "testPassword123!"
|
||||||
|
|
||||||
hash, err := HashPassword(password)
|
hash, err := database.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword() error = %v", err)
|
t.Fatalf("HashPassword() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that hash has correct format
|
// Check that hash has correct format
|
||||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
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
|
// Verify password
|
||||||
valid, err := VerifyPassword(password, hash)
|
valid, err := database.VerifyPassword(password, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VerifyPassword() error = %v", err)
|
t.Fatalf("VerifyPassword() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
t.Error("VerifyPassword() returned false for correct password")
|
t.Error(
|
||||||
|
"VerifyPassword() returned false " +
|
||||||
|
"for correct password",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify wrong password fails
|
// Verify wrong password fails
|
||||||
valid, err = VerifyPassword("wrongPassword", hash)
|
valid, err = database.VerifyPassword(
|
||||||
|
"wrongPassword", hash,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VerifyPassword() error = %v", err)
|
t.Fatalf("VerifyPassword() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if valid {
|
if valid {
|
||||||
t.Error("VerifyPassword() returned true for wrong password")
|
t.Error(
|
||||||
|
"VerifyPassword() returned true " +
|
||||||
|
"for wrong password",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashPasswordUniqueness(t *testing.T) {
|
func TestHashPasswordUniqueness(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
password := "testPassword123!"
|
password := "testPassword123!"
|
||||||
|
|
||||||
// Same password should produce different hashes due to salt
|
// Same password should produce different hashes
|
||||||
hash1, err := HashPassword(password)
|
hash1, err := database.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword() error = %v", err)
|
t.Fatalf("HashPassword() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hash2, err := HashPassword(password)
|
hash2, err := database.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword() error = %v", err)
|
t.Fatalf("HashPassword() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hash1 == hash2 {
|
if hash1 == hash2 {
|
||||||
t.Error("Same password produced identical hashes (salt not working)")
|
t.Error(
|
||||||
|
"Same password produced identical hashes " +
|
||||||
|
"(salt not working)",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
252
internal/database/retention.go
Normal file
252
internal/database/retention.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hoursPerDay converts a RetentionDays count into hours for cutoff
|
||||||
|
// computation.
|
||||||
|
const hoursPerDay = 24
|
||||||
|
|
||||||
|
// RetentionReaperParams holds the fx dependencies for the
|
||||||
|
// RetentionReaper.
|
||||||
|
type RetentionReaperParams struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Config *config.Config
|
||||||
|
Database *Database
|
||||||
|
DBManager *WebhookDBManager
|
||||||
|
Logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionReaper periodically deletes expired events (and their
|
||||||
|
// dependent deliveries and delivery results) from each per-webhook
|
||||||
|
// database, enforcing every webhook's RetentionDays. Rows are removed
|
||||||
|
// permanently so that per-webhook SQLite files do not grow without
|
||||||
|
// bound.
|
||||||
|
type RetentionReaper struct {
|
||||||
|
db *Database
|
||||||
|
dbManager *WebhookDBManager
|
||||||
|
log *slog.Logger
|
||||||
|
interval time.Duration
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRetentionReaper creates the retention reaper and registers its
|
||||||
|
// fx lifecycle hooks. The background sweep loop starts on OnStart and
|
||||||
|
// stops cleanly on OnStop via context cancellation.
|
||||||
|
func NewRetentionReaper(
|
||||||
|
lc fx.Lifecycle,
|
||||||
|
params RetentionReaperParams,
|
||||||
|
) *RetentionReaper {
|
||||||
|
r := &RetentionReaper{
|
||||||
|
db: params.Database,
|
||||||
|
dbManager: params.DBManager,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
interval: params.Config.RetentionSweepInterval,
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
r.start(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
r.stop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RetentionReaper) start(ctx context.Context) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
r.cancel = cancel
|
||||||
|
|
||||||
|
r.wg.Add(1)
|
||||||
|
|
||||||
|
go r.run(ctx)
|
||||||
|
|
||||||
|
r.log.Info(
|
||||||
|
"retention reaper started",
|
||||||
|
"interval", r.interval.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RetentionReaper) stop() {
|
||||||
|
r.log.Info("retention reaper stopping")
|
||||||
|
|
||||||
|
if r.cancel != nil {
|
||||||
|
r.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.wg.Wait()
|
||||||
|
r.log.Info("retention reaper stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RetentionReaper) run(ctx context.Context) {
|
||||||
|
defer r.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(r.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.sweep(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep lists every webhook from the main database and reaps expired
|
||||||
|
// rows from each per-webhook database whose RetentionDays is positive.
|
||||||
|
func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||||
|
var webhooks []Webhook
|
||||||
|
|
||||||
|
err := r.db.DB().
|
||||||
|
Model(&Webhook{}).
|
||||||
|
Find(&webhooks).Error
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error(
|
||||||
|
"retention sweep: failed to list webhooks",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range webhooks {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
wh := webhooks[i]
|
||||||
|
|
||||||
|
// RetentionDays of zero or less means retain forever.
|
||||||
|
if wh.RetentionDays <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to reap if the per-webhook database has never
|
||||||
|
// been created.
|
||||||
|
if !r.dbManager.DBExists(wh.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
r.reapWebhook(wh.ID, wh.RetentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapWebhook removes every expired event (and its dependents) from a
|
||||||
|
// single webhook's database.
|
||||||
|
func (r *RetentionReaper) reapWebhook(
|
||||||
|
webhookID string,
|
||||||
|
retentionDays int,
|
||||||
|
) {
|
||||||
|
db, err := r.dbManager.GetDB(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error(
|
||||||
|
"retention sweep: failed to open webhook database",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(
|
||||||
|
-time.Duration(retentionDays*hoursPerDay) * time.Hour,
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted, err := reapExpired(db, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error(
|
||||||
|
"retention sweep: failed to reap expired events",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted > 0 {
|
||||||
|
r.log.Info(
|
||||||
|
"retention sweep: reaped expired events",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"retention_days", retentionDays,
|
||||||
|
"events_deleted", deleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||||
|
// results, deliveries, and events associated with events older than
|
||||||
|
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||||
|
// than soft-deleted, reclaiming disk. It returns the number of events
|
||||||
|
// deleted.
|
||||||
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||||
|
// Fresh subqueries are built per statement to avoid reusing a
|
||||||
|
// mutated builder across executions.
|
||||||
|
expiredEventIDs := func() *gorm.DB {
|
||||||
|
return db.Model(&Event{}).
|
||||||
|
Select("id").
|
||||||
|
Where("created_at < ?", cutoff)
|
||||||
|
}
|
||||||
|
expiredDeliveryIDs := func() *gorm.DB {
|
||||||
|
return db.Model(&Delivery{}).
|
||||||
|
Select("id").
|
||||||
|
Where("event_id IN (?)", expiredEventIDs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Delivery results whose delivery belongs to an expired event.
|
||||||
|
res := db.Unscoped().
|
||||||
|
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||||
|
Delete(&DeliveryResult{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired delivery results: %w",
|
||||||
|
res.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Deliveries belonging to an expired event.
|
||||||
|
del := db.Unscoped().
|
||||||
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
|
Delete(&Delivery{})
|
||||||
|
if del.Error != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired deliveries: %w",
|
||||||
|
del.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The expired events themselves.
|
||||||
|
ev := db.Unscoped().
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Delete(&Event{})
|
||||||
|
if ev.Error != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired events: %w",
|
||||||
|
ev.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ev.RowsAffected, nil
|
||||||
|
}
|
||||||
277
internal/database/retention_test.go
Normal file
277
internal/database/retention_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retentionTestEnv bundles the pieces a retention test drives.
|
||||||
|
type retentionTestEnv struct {
|
||||||
|
reaper *database.RetentionReaper
|
||||||
|
mainDB *database.Database
|
||||||
|
mgr *database.WebhookDBManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRetentionTest(t *testing.T) *retentionTestEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
g := &globals.Globals{
|
||||||
|
Appname: "webhooker-test",
|
||||||
|
Version: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: "dev",
|
||||||
|
}
|
||||||
|
|
||||||
|
mainDB, err := database.New(lc, database.DatabaseParams{
|
||||||
|
Config: cfg,
|
||||||
|
Logger: l,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mgr, err := database.NewWebhookDBManager(
|
||||||
|
lc,
|
||||||
|
database.WebhookDBManagerParams{Config: cfg, Logger: l},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) })
|
||||||
|
|
||||||
|
return &retentionTestEnv{
|
||||||
|
reaper: database.NewTestRetentionReaper(mainDB, mgr),
|
||||||
|
mainDB: mainDB,
|
||||||
|
mgr: mgr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWebhook inserts a webhook row into the main database with the
|
||||||
|
// given retention policy and returns its ID.
|
||||||
|
func createWebhook(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
retentionDays int,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: uuid.New().String(),
|
||||||
|
Name: "test-webhook",
|
||||||
|
RetentionDays: retentionDays,
|
||||||
|
}
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Omit(clause.Associations).Create(wh).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The RetentionDays column carries a GORM default of 30, so a
|
||||||
|
// zero (or negative) value passed to Create is replaced by that
|
||||||
|
// default. Force the requested value explicitly so the
|
||||||
|
// retain-forever (<= 0) path can be exercised.
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Model(wh).
|
||||||
|
Update("retention_days", retentionDays).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wh.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventChain is the set of row IDs seeded for a single event.
|
||||||
|
type eventChain struct {
|
||||||
|
eventID string
|
||||||
|
deliveryID string
|
||||||
|
resultID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEventChain creates an event with one delivery and one delivery
|
||||||
|
// result, all stamped with createdAt, and returns their IDs.
|
||||||
|
func seedEventChain(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
createdAt time.Time,
|
||||||
|
) eventChain {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: "POST",
|
||||||
|
Body: `{"seed": true}`,
|
||||||
|
ContentType: "application/json",
|
||||||
|
}
|
||||||
|
event.CreatedAt = createdAt
|
||||||
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
|
delivery := &database.Delivery{
|
||||||
|
EventID: event.ID,
|
||||||
|
TargetID: uuid.New().String(),
|
||||||
|
Status: database.DeliveryStatusDelivered,
|
||||||
|
}
|
||||||
|
delivery.CreatedAt = createdAt
|
||||||
|
require.NoError(t, db.Create(delivery).Error)
|
||||||
|
|
||||||
|
result := &database.DeliveryResult{
|
||||||
|
DeliveryID: delivery.ID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
Success: true,
|
||||||
|
StatusCode: 200,
|
||||||
|
Duration: 10,
|
||||||
|
}
|
||||||
|
result.CreatedAt = createdAt
|
||||||
|
require.NoError(t, db.Create(result).Error)
|
||||||
|
|
||||||
|
return eventChain{
|
||||||
|
eventID: event.ID,
|
||||||
|
deliveryID: delivery.ID,
|
||||||
|
resultID: result.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countByID returns how many rows of model match the given id,
|
||||||
|
// counting even hard-deletable rows via Unscoped.
|
||||||
|
func countByID(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
model any,
|
||||||
|
id string,
|
||||||
|
) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var n int64
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Unscoped().Model(model).
|
||||||
|
Where("id = ?", id).Count(&n).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertChainGone(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
chain eventChain,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Zero(
|
||||||
|
t,
|
||||||
|
countByID(t, db, &database.Event{}, chain.eventID),
|
||||||
|
"expired event should be removed",
|
||||||
|
)
|
||||||
|
assert.Zero(
|
||||||
|
t,
|
||||||
|
countByID(t, db, &database.Delivery{}, chain.deliveryID),
|
||||||
|
"expired delivery should be removed",
|
||||||
|
)
|
||||||
|
assert.Zero(
|
||||||
|
t,
|
||||||
|
countByID(
|
||||||
|
t, db, &database.DeliveryResult{}, chain.resultID,
|
||||||
|
),
|
||||||
|
"expired delivery result should be removed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertChainPresent(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
chain eventChain,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
int64(1),
|
||||||
|
countByID(t, db, &database.Event{}, chain.eventID),
|
||||||
|
"recent event should be retained",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
int64(1),
|
||||||
|
countByID(t, db, &database.Delivery{}, chain.deliveryID),
|
||||||
|
"recent delivery should be retained",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
int64(1),
|
||||||
|
countByID(
|
||||||
|
t, db, &database.DeliveryResult{}, chain.resultID,
|
||||||
|
),
|
||||||
|
"recent delivery result should be retained",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
const retentionDays = 30
|
||||||
|
|
||||||
|
webhookID := createWebhook(
|
||||||
|
t, env.mainDB.DB(), retentionDays,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
old := seedEventChain(
|
||||||
|
t, db, webhookID,
|
||||||
|
now.Add(-40*24*time.Hour),
|
||||||
|
)
|
||||||
|
recent := seedEventChain(
|
||||||
|
t, db, webhookID,
|
||||||
|
now.Add(-1*24*time.Hour),
|
||||||
|
)
|
||||||
|
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
assertChainGone(t, db, old)
|
||||||
|
assertChainPresent(t, db, recent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
// RetentionDays of zero means retain forever.
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 0)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ancient := seedEventChain(
|
||||||
|
t, db, webhookID,
|
||||||
|
time.Now().Add(-365*24*time.Hour),
|
||||||
|
)
|
||||||
|
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
assertChainPresent(t, db, ancient)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,87 +17,82 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"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 {
|
type WebhookDBManagerParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files for event storage.
|
// errInvalidCachedDBType indicates a type assertion failure
|
||||||
// Each webhook gets its own dedicated database containing Events, Deliveries,
|
// when retrieving a cached database connection.
|
||||||
// and DeliveryResults. Database connections are opened lazily and cached.
|
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 {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
dbs sync.Map // map[webhookID]*gorm.DB
|
dbs sync.Map // map[webhookID]*gorm.DB
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebhookDBManager creates a new WebhookDBManager and registers lifecycle hooks.
|
// NewWebhookDBManager creates a new WebhookDBManager and
|
||||||
func NewWebhookDBManager(lc fx.Lifecycle, params WebhookDBManagerParams) (*WebhookDBManager, error) {
|
// registers lifecycle hooks.
|
||||||
|
func NewWebhookDBManager(
|
||||||
|
lc fx.Lifecycle,
|
||||||
|
params WebhookDBManagerParams,
|
||||||
|
) (*WebhookDBManager, error) {
|
||||||
m := &WebhookDBManager{
|
m := &WebhookDBManager{
|
||||||
dataDir: params.Config.DataDir,
|
dataDir: params.Config.DataDir,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory if it doesn't exist
|
// Create data directory if it doesn't exist
|
||||||
if err := os.MkdirAll(m.dataDir, 0750); err != nil {
|
err := os.MkdirAll(m.dataDir, dataDirPerm)
|
||||||
return nil, fmt.Errorf("creating data directory %s: %w", m.dataDir, err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"creating data directory %s: %w",
|
||||||
|
m.dataDir,
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
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()
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbPath returns the filesystem path for a webhook's database file.
|
// GetDB returns the database connection for a webhook,
|
||||||
func (m *WebhookDBManager) dbPath(webhookID string) string {
|
// creating the database file lazily if it doesn't exist.
|
||||||
return filepath.Join(m.dataDir, fmt.Sprintf("events-%s.db", webhookID))
|
func (m *WebhookDBManager) GetDB(
|
||||||
}
|
webhookID string,
|
||||||
|
) (*gorm.DB, error) {
|
||||||
// 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) {
|
|
||||||
// Fast path: already open
|
// Fast path: already open
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
cachedDB, castOK := val.(*gorm.DB)
|
cachedDB, castOK := val.(*gorm.DB)
|
||||||
if !castOK {
|
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
|
return cachedDB, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,44 +102,61 @@ func (m *WebhookDBManager) GetDB(webhookID string) (*gorm.DB, error) {
|
|||||||
return nil, err
|
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)
|
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
|
||||||
if loaded {
|
if loaded {
|
||||||
// Another goroutine created it first; close our duplicate
|
// Another goroutine created it first; close our duplicate
|
||||||
if sqlDB, closeErr := db.DB(); closeErr == nil {
|
sqlDB, closeErr := db.DB()
|
||||||
sqlDB.Close()
|
if closeErr == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
existingDB, castOK := actual.(*gorm.DB)
|
existingDB, castOK := actual.(*gorm.DB)
|
||||||
if !castOK {
|
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 existingDB, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDB explicitly creates a new per-webhook database file and runs migrations.
|
// CreateDB explicitly creates a new per-webhook database file
|
||||||
// This is called when a new webhook is created.
|
// and runs migrations.
|
||||||
func (m *WebhookDBManager) CreateDB(webhookID string) error {
|
func (m *WebhookDBManager) CreateDB(
|
||||||
|
webhookID string,
|
||||||
|
) error {
|
||||||
_, err := m.GetDB(webhookID)
|
_, err := m.GetDB(webhookID)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBExists checks if a per-webhook database file exists on disk.
|
// DBExists checks if a per-webhook database file exists on
|
||||||
func (m *WebhookDBManager) DBExists(webhookID string) bool {
|
// disk.
|
||||||
|
func (m *WebhookDBManager) DBExists(
|
||||||
|
webhookID string,
|
||||||
|
) bool {
|
||||||
_, err := os.Stat(m.dbPath(webhookID))
|
_, err := os.Stat(m.dbPath(webhookID))
|
||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDB closes the connection and deletes the database file for a webhook.
|
// DeleteDB closes the connection and deletes the database file
|
||||||
// This performs a hard delete — the file is permanently removed.
|
// for a webhook. The file is permanently removed.
|
||||||
func (m *WebhookDBManager) DeleteDB(webhookID string) error {
|
func (m *WebhookDBManager) DeleteDB(
|
||||||
|
webhookID string,
|
||||||
|
) error {
|
||||||
// Close and remove from cache
|
// Close and remove from cache
|
||||||
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
|
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
|
||||||
if gormDB, castOK := val.(*gorm.DB); castOK {
|
if gormDB, castOK := val.(*gorm.DB); castOK {
|
||||||
if sqlDB, err := gormDB.DB(); err == nil {
|
sqlDB, err := gormDB.DB()
|
||||||
sqlDB.Close()
|
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
|
// Delete the main DB file and WAL/SHM files
|
||||||
path := m.dbPath(webhookID)
|
path := m.dbPath(webhookID)
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
if err := os.Remove(path + suffix); err != nil && !os.IsNotExist(err) {
|
err := os.Remove(path + suffix)
|
||||||
return fmt.Errorf("deleting webhook database file %s%s: %w", path, suffix, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,20 +185,97 @@ func (m *WebhookDBManager) DeleteDB(webhookID string) error {
|
|||||||
// Called during application shutdown.
|
// Called during application shutdown.
|
||||||
func (m *WebhookDBManager) CloseAll() error {
|
func (m *WebhookDBManager) CloseAll() error {
|
||||||
var lastErr 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 gormDB, castOK := value.(*gorm.DB); castOK {
|
||||||
if sqlDB, err := gormDB.DB(); err == nil {
|
sqlDB, err := gormDB.DB()
|
||||||
if closeErr := sqlDB.Close(); closeErr != nil {
|
if err == nil {
|
||||||
|
closeErr := sqlDB.Close()
|
||||||
|
if closeErr != nil {
|
||||||
lastErr = closeErr
|
lastErr = closeErr
|
||||||
m.log.Error("failed to close webhook database",
|
m.log.Error(
|
||||||
|
"failed to close webhook database",
|
||||||
"webhook_id", key,
|
"webhook_id", key,
|
||||||
"error", closeErr,
|
"error", closeErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.dbs.Delete(key)
|
m.dbs.Delete(key)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return lastErr
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package database
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,23 +10,29 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"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()
|
t.Helper()
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
globals.Appname = "webhooker-test"
|
g := &globals.Globals{
|
||||||
globals.Version = "test"
|
Appname: "webhooker-test",
|
||||||
|
Version: "test",
|
||||||
|
}
|
||||||
|
|
||||||
g, err := globals.New(lc)
|
l, err := logger.New(
|
||||||
require.NoError(t, err)
|
lc,
|
||||||
|
logger.LoggerParams{Globals: g},
|
||||||
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
dataDir := filepath.Join(t.TempDir(), "events")
|
dataDir := filepath.Join(t.TempDir(), "events")
|
||||||
@@ -35,19 +41,25 @@ func setupTestWebhookDBManager(t *testing.T) (*WebhookDBManager, *fxtest.Lifecyc
|
|||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr, err := NewWebhookDBManager(lc, WebhookDBManagerParams{
|
mgr, err := database.NewWebhookDBManager(
|
||||||
|
lc,
|
||||||
|
database.WebhookDBManagerParams{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: l,
|
Logger: l,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return mgr, lc
|
return mgr, lc
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
require.NoError(t, lc.Start(ctx))
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
@@ -68,7 +80,7 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
|||||||
require.NotNil(t, db)
|
require.NotNil(t, db)
|
||||||
|
|
||||||
// Verify we can write an event
|
// Verify we can write an event
|
||||||
event := &Event{
|
event := &database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
@@ -80,27 +92,35 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
|||||||
assert.NotEmpty(t, event.ID)
|
assert.NotEmpty(t, event.ID)
|
||||||
|
|
||||||
// Verify we can read it back
|
// Verify we can read it back
|
||||||
var readEvent Event
|
var readEvent database.Event
|
||||||
require.NoError(t, db.First(&readEvent, "id = ?", event.ID).Error)
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.First(&readEvent, "id = ?", event.ID).Error,
|
||||||
|
)
|
||||||
assert.Equal(t, webhookID, readEvent.WebhookID)
|
assert.Equal(t, webhookID, readEvent.WebhookID)
|
||||||
assert.Equal(t, "POST", readEvent.Method)
|
assert.Equal(t, "POST", readEvent.Method)
|
||||||
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
require.NoError(t, lc.Start(ctx))
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
// Create the DB and write some data
|
// Create the DB and write some data
|
||||||
require.NoError(t, mgr.CreateDB(webhookID))
|
require.NoError(t, mgr.CreateDB(webhookID))
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
db, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
event := &Event{
|
event := &database.Event{
|
||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
@@ -116,15 +136,19 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
|||||||
assert.False(t, mgr.DBExists(webhookID))
|
assert.False(t, mgr.DBExists(webhookID))
|
||||||
|
|
||||||
// Verify the file is actually gone from disk
|
// Verify the file is actually gone from disk
|
||||||
dbPath := mgr.dbPath(webhookID)
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
_, err = os.Stat(dbPath)
|
_, err = os.Stat(dbPath)
|
||||||
assert.True(t, os.IsNotExist(err))
|
assert.True(t, os.IsNotExist(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
require.NoError(t, lc.Start(ctx))
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
@@ -139,9 +163,12 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
require.NoError(t, lc.Start(ctx))
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
@@ -150,8 +177,23 @@ func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
|||||||
db, err := mgr.GetDB(webhookID)
|
db, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create an event
|
event, delivery := seedDeliveryWorkflow(
|
||||||
event := &Event{
|
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,
|
WebhookID: webhookID,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
@@ -161,25 +203,45 @@ func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
// Create a delivery
|
delivery := &database.Delivery{
|
||||||
delivery := &Delivery{
|
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
Status: DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Create(delivery).Error)
|
require.NoError(t, db.Create(delivery).Error)
|
||||||
|
|
||||||
// Query pending deliveries
|
return event, delivery
|
||||||
var pending []Delivery
|
}
|
||||||
require.NoError(t, db.Where("status = ?", DeliveryStatusPending).
|
|
||||||
Preload("Event").
|
func verifyPendingDeliveries(
|
||||||
Find(&pending).Error)
|
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)
|
require.Len(t, pending, 1)
|
||||||
assert.Equal(t, event.ID, pending[0].EventID)
|
assert.Equal(t, event.ID, pending[0].EventID)
|
||||||
assert.Equal(t, "POST", pending[0].Event.Method)
|
assert.Equal(t, "POST", pending[0].Event.Method)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a delivery result
|
func completeDelivery(
|
||||||
result := &DeliveryResult{
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
delivery *database.Delivery,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result := &database.DeliveryResult{
|
||||||
DeliveryID: delivery.ID,
|
DeliveryID: delivery.ID,
|
||||||
AttemptNum: 1,
|
AttemptNum: 1,
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -188,19 +250,40 @@ func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Create(result).Error)
|
require.NoError(t, db.Create(result).Error)
|
||||||
|
|
||||||
// Update delivery status
|
require.NoError(
|
||||||
require.NoError(t, db.Model(delivery).Update("status", DeliveryStatusDelivered).Error)
|
t,
|
||||||
|
db.Model(delivery).Update(
|
||||||
|
"status",
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
).Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify no more pending deliveries
|
func verifyNoPending(
|
||||||
var stillPending []Delivery
|
t *testing.T,
|
||||||
require.NoError(t, db.Where("status = ?", DeliveryStatusPending).Find(&stillPending).Error)
|
db *gorm.DB,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var stillPending []database.Delivery
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
db.Where(
|
||||||
|
"status = ?",
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
).Find(&stillPending).Error,
|
||||||
|
)
|
||||||
assert.Empty(t, stillPending)
|
assert.Empty(t, stillPending)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
|
func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
require.NoError(t, lc.Start(ctx))
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
webhook1 := uuid.New().String()
|
webhook1 := uuid.New().String()
|
||||||
@@ -212,34 +295,38 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
|
|||||||
|
|
||||||
db1, err := mgr.GetDB(webhook1)
|
db1, err := mgr.GetDB(webhook1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db2, err := mgr.GetDB(webhook2)
|
db2, err := mgr.GetDB(webhook2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Write events to each webhook's DB
|
// Write events to each webhook's DB
|
||||||
event1 := &Event{
|
event1 := &database.Event{
|
||||||
WebhookID: webhook1,
|
WebhookID: webhook1,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
Body: `{"webhook": 1}`,
|
Body: `{"webhook": 1}`,
|
||||||
ContentType: "application/json",
|
ContentType: "application/json",
|
||||||
}
|
}
|
||||||
event2 := &Event{
|
event2 := &database.Event{
|
||||||
WebhookID: webhook2,
|
WebhookID: webhook2,
|
||||||
EntrypointID: uuid.New().String(),
|
EntrypointID: uuid.New().String(),
|
||||||
Method: "PUT",
|
Method: "PUT",
|
||||||
Body: `{"webhook": 2}`,
|
Body: `{"webhook": 2}`,
|
||||||
ContentType: "application/json",
|
ContentType: "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, db1.Create(event1).Error)
|
require.NoError(t, db1.Create(event1).Error)
|
||||||
require.NoError(t, db2.Create(event2).Error)
|
require.NoError(t, db2.Create(event2).Error)
|
||||||
|
|
||||||
// Verify isolation: each DB only has its own events
|
// Verify isolation: each DB only has its own events
|
||||||
var count1 int64
|
var count1 int64
|
||||||
db1.Model(&Event{}).Count(&count1)
|
|
||||||
|
db1.Model(&database.Event{}).Count(&count1)
|
||||||
assert.Equal(t, int64(1), count1)
|
assert.Equal(t, int64(1), count1)
|
||||||
|
|
||||||
var count2 int64
|
var count2 int64
|
||||||
db2.Model(&Event{}).Count(&count2)
|
|
||||||
|
db2.Model(&database.Event{}).Count(&count2)
|
||||||
assert.Equal(t, int64(1), count2)
|
assert.Equal(t, int64(1), count2)
|
||||||
|
|
||||||
// Delete webhook1's DB, webhook2 should be unaffected
|
// Delete webhook1's DB, webhook2 should be unaffected
|
||||||
@@ -248,25 +335,31 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
|
|||||||
assert.True(t, mgr.DBExists(webhook2))
|
assert.True(t, mgr.DBExists(webhook2))
|
||||||
|
|
||||||
// webhook2's data should still be accessible
|
// webhook2's data should still be accessible
|
||||||
var events []Event
|
var events []database.Event
|
||||||
|
|
||||||
require.NoError(t, db2.Find(&events).Error)
|
require.NoError(t, db2.Find(&events).Error)
|
||||||
assert.Len(t, events, 1)
|
assert.Len(t, events, 1)
|
||||||
assert.Equal(t, "PUT", events[0].Method)
|
assert.Equal(t, "PUT", events[0].Method)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_CloseAll(t *testing.T) {
|
func TestWebhookDBManager_CloseAll(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
require.NoError(t, lc.Start(ctx))
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
// Create a few DBs
|
// Create a few DBs
|
||||||
for i := 0; i < 3; i++ {
|
for range 3 {
|
||||||
require.NoError(t, mgr.CreateDB(uuid.New().String()))
|
require.NoError(
|
||||||
|
t,
|
||||||
|
mgr.CreateDB(uuid.New().String()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseAll should close all connections without error
|
// CloseAll should close all connections without error
|
||||||
require.NoError(t, mgr.CloseAll())
|
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))
|
require.NoError(t, lc.Stop(ctx))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,41 +5,32 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CircuitState represents the current state of a circuit breaker.
|
// CircuitState represents the current state of a circuit
|
||||||
|
// breaker.
|
||||||
type CircuitState int
|
type CircuitState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// CircuitClosed is the normal operating state. Deliveries flow through.
|
// CircuitClosed is the normal operating state.
|
||||||
CircuitClosed CircuitState = iota
|
CircuitClosed CircuitState = iota
|
||||||
// CircuitOpen means the circuit has tripped. Deliveries are skipped
|
// CircuitOpen means the circuit has tripped.
|
||||||
// until the cooldown expires.
|
|
||||||
CircuitOpen
|
CircuitOpen
|
||||||
// CircuitHalfOpen allows a single probe delivery to test whether
|
// CircuitHalfOpen allows a single probe delivery to
|
||||||
// the target has recovered.
|
// test whether the target has recovered.
|
||||||
CircuitHalfOpen
|
CircuitHalfOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// defaultFailureThreshold is the number of consecutive failures
|
// defaultFailureThreshold is the number of consecutive
|
||||||
// before a circuit breaker trips open.
|
// failures before a circuit breaker trips open.
|
||||||
defaultFailureThreshold = 5
|
defaultFailureThreshold = 5
|
||||||
|
|
||||||
// defaultCooldown is how long a circuit stays open before
|
// defaultCooldown is how long a circuit stays open
|
||||||
// transitioning to half-open for a probe delivery.
|
// before transitioning to half-open.
|
||||||
defaultCooldown = 30 * time.Second
|
defaultCooldown = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// CircuitBreaker implements the circuit breaker pattern for a single
|
// CircuitBreaker implements the circuit breaker pattern
|
||||||
// delivery target. It tracks consecutive failures and prevents
|
// for a single delivery target.
|
||||||
// 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.
|
|
||||||
type CircuitBreaker struct {
|
type CircuitBreaker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
state CircuitState
|
state CircuitState
|
||||||
@@ -49,7 +40,8 @@ type CircuitBreaker struct {
|
|||||||
lastFailure time.Time
|
lastFailure time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCircuitBreaker creates a circuit breaker with default settings.
|
// NewCircuitBreaker creates a circuit breaker with default
|
||||||
|
// settings.
|
||||||
func NewCircuitBreaker() *CircuitBreaker {
|
func NewCircuitBreaker() *CircuitBreaker {
|
||||||
return &CircuitBreaker{
|
return &CircuitBreaker{
|
||||||
state: CircuitClosed,
|
state: CircuitClosed,
|
||||||
@@ -58,12 +50,7 @@ func NewCircuitBreaker() *CircuitBreaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow checks whether a delivery attempt should proceed. It returns
|
// Allow checks whether a delivery attempt should proceed.
|
||||||
// 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.
|
|
||||||
func (cb *CircuitBreaker) Allow() bool {
|
func (cb *CircuitBreaker) Allow() bool {
|
||||||
cb.mu.Lock()
|
cb.mu.Lock()
|
||||||
defer cb.mu.Unlock()
|
defer cb.mu.Unlock()
|
||||||
@@ -73,17 +60,15 @@ func (cb *CircuitBreaker) Allow() bool {
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
case CircuitOpen:
|
case CircuitOpen:
|
||||||
// Check if cooldown has elapsed
|
|
||||||
if time.Since(cb.lastFailure) >= cb.cooldown {
|
if time.Since(cb.lastFailure) >= cb.cooldown {
|
||||||
cb.state = CircuitHalfOpen
|
cb.state = CircuitHalfOpen
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|
||||||
case CircuitHalfOpen:
|
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
|
return false
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -91,9 +76,8 @@ func (cb *CircuitBreaker) Allow() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CooldownRemaining returns how much time is left before an open circuit
|
// CooldownRemaining returns how much time is left before
|
||||||
// transitions to half-open. Returns zero if the circuit is not open or
|
// an open circuit transitions to half-open.
|
||||||
// the cooldown has already elapsed.
|
|
||||||
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
||||||
cb.mu.Lock()
|
cb.mu.Lock()
|
||||||
defer cb.mu.Unlock()
|
defer cb.mu.Unlock()
|
||||||
@@ -106,11 +90,12 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
|||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return remaining
|
return remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordSuccess records a successful delivery and resets the circuit
|
// RecordSuccess records a successful delivery and resets
|
||||||
// breaker to closed state with zero failures.
|
// the circuit breaker to closed state.
|
||||||
func (cb *CircuitBreaker) RecordSuccess() {
|
func (cb *CircuitBreaker) RecordSuccess() {
|
||||||
cb.mu.Lock()
|
cb.mu.Lock()
|
||||||
defer cb.mu.Unlock()
|
defer cb.mu.Unlock()
|
||||||
@@ -119,8 +104,8 @@ func (cb *CircuitBreaker) RecordSuccess() {
|
|||||||
cb.state = CircuitClosed
|
cb.state = CircuitClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordFailure records a failed delivery. If the failure count reaches
|
// RecordFailure records a failed delivery. If the failure
|
||||||
// the threshold, the circuit trips open.
|
// count reaches the threshold, the circuit trips open.
|
||||||
func (cb *CircuitBreaker) RecordFailure() {
|
func (cb *CircuitBreaker) RecordFailure() {
|
||||||
cb.mu.Lock()
|
cb.mu.Lock()
|
||||||
defer cb.mu.Unlock()
|
defer cb.mu.Unlock()
|
||||||
@@ -134,20 +119,25 @@ func (cb *CircuitBreaker) RecordFailure() {
|
|||||||
cb.state = CircuitOpen
|
cb.state = CircuitOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case CircuitOpen:
|
||||||
|
// Already open; no state change needed.
|
||||||
|
|
||||||
case CircuitHalfOpen:
|
case CircuitHalfOpen:
|
||||||
// Probe failed — reopen immediately
|
// Probe failed -- reopen immediately.
|
||||||
cb.state = CircuitOpen
|
cb.state = CircuitOpen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// State returns the current circuit state. Safe for concurrent use.
|
// State returns the current circuit state.
|
||||||
func (cb *CircuitBreaker) State() CircuitState {
|
func (cb *CircuitBreaker) State() CircuitState {
|
||||||
cb.mu.Lock()
|
cb.mu.Lock()
|
||||||
defer cb.mu.Unlock()
|
defer cb.mu.Unlock()
|
||||||
|
|
||||||
return cb.state
|
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 {
|
func (s CircuitState) String() string {
|
||||||
switch s {
|
switch s {
|
||||||
case CircuitClosed:
|
case CircuitClosed:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package delivery
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
@@ -7,237 +7,304 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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()
|
t.Parallel()
|
||||||
cb := NewCircuitBreaker()
|
|
||||||
|
|
||||||
assert.Equal(t, CircuitClosed, cb.State())
|
cb := delivery.NewCircuitBreaker()
|
||||||
assert.True(t, cb.Allow(), "closed circuit should allow deliveries")
|
|
||||||
// Multiple calls should all succeed
|
assert.Equal(t, delivery.CircuitClosed, cb.State())
|
||||||
for i := 0; i < 10; i++ {
|
assert.True(t, cb.Allow(),
|
||||||
|
"closed circuit should allow deliveries",
|
||||||
|
)
|
||||||
|
|
||||||
|
for range 10 {
|
||||||
assert.True(t, cb.Allow())
|
assert.True(t, cb.Allow())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCircuitBreaker_FailureCounting(t *testing.T) {
|
func TestCircuitBreaker_FailureCounting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cb := NewCircuitBreaker()
|
|
||||||
|
|
||||||
// Record failures below threshold — circuit should stay closed
|
cb := delivery.NewCircuitBreaker()
|
||||||
for i := 0; i < defaultFailureThreshold-1; i++ {
|
|
||||||
|
for i := range delivery.ExportDefaultFailureThreshold - 1 {
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
assert.Equal(t, CircuitClosed, cb.State(),
|
|
||||||
"circuit should remain closed after %d failures", i+1)
|
assert.Equal(t,
|
||||||
assert.True(t, cb.Allow(), "should still allow after %d failures", i+1)
|
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) {
|
func TestCircuitBreaker_OpenTransition(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cb := NewCircuitBreaker()
|
|
||||||
|
|
||||||
// Record exactly threshold failures
|
cb := delivery.NewCircuitBreaker()
|
||||||
for i := 0; i < defaultFailureThreshold; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, CircuitOpen, cb.State(), "circuit should be open after threshold failures")
|
assert.Equal(t, delivery.CircuitOpen, cb.State(),
|
||||||
assert.False(t, cb.Allow(), "open circuit should reject deliveries")
|
"circuit should be open after threshold failures",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.False(t, cb.Allow(),
|
||||||
|
"open circuit should reject deliveries",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCircuitBreaker_Cooldown_StaysOpen(t *testing.T) {
|
func TestCircuitBreaker_Cooldown_StaysOpen(t *testing.T) {
|
||||||
t.Parallel()
|
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
|
cb := delivery.NewCircuitBreaker()
|
||||||
for i := 0; i < defaultFailureThreshold; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
}
|
}
|
||||||
require.Equal(t, CircuitOpen, cb.State())
|
|
||||||
|
|
||||||
// During cooldown, Allow should return false
|
require.Equal(t, delivery.CircuitOpen, cb.State())
|
||||||
assert.False(t, cb.Allow(), "should be blocked during cooldown")
|
|
||||||
|
assert.False(t, cb.Allow(),
|
||||||
|
"should be blocked during cooldown",
|
||||||
|
)
|
||||||
|
|
||||||
// CooldownRemaining should be positive
|
|
||||||
remaining := cb.CooldownRemaining()
|
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()
|
t.Parallel()
|
||||||
cb := &CircuitBreaker{
|
|
||||||
state: CircuitClosed,
|
|
||||||
threshold: defaultFailureThreshold,
|
|
||||||
cooldown: 50 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trip the circuit open
|
cb := newShortCooldownCB(t)
|
||||||
for i := 0; i < defaultFailureThreshold; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
cb.RecordFailure()
|
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)
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
|
||||||
// CooldownRemaining should be zero after cooldown
|
assert.Equal(t, time.Duration(0),
|
||||||
assert.Equal(t, time.Duration(0), cb.CooldownRemaining())
|
cb.CooldownRemaining(),
|
||||||
|
)
|
||||||
|
|
||||||
// First Allow after cooldown should succeed (probe)
|
assert.True(t, cb.Allow(),
|
||||||
assert.True(t, cb.Allow(), "should allow one probe after cooldown")
|
"should allow one probe after cooldown",
|
||||||
assert.Equal(t, CircuitHalfOpen, cb.State(), "should be half-open after probe allowed")
|
)
|
||||||
|
|
||||||
// Second Allow should be rejected (only one probe at a time)
|
assert.Equal(t,
|
||||||
assert.False(t, cb.Allow(), "should reject additional probes while half-open")
|
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()
|
t.Parallel()
|
||||||
cb := &CircuitBreaker{
|
|
||||||
state: CircuitClosed,
|
|
||||||
threshold: defaultFailureThreshold,
|
|
||||||
cooldown: 50 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trip open → wait for cooldown → allow probe
|
cb := newShortCooldownCB(t)
|
||||||
for i := 0; i < defaultFailureThreshold; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
cb.RecordFailure()
|
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()
|
cb.RecordSuccess()
|
||||||
assert.Equal(t, CircuitClosed, cb.State(), "successful probe should close circuit")
|
|
||||||
|
|
||||||
// Should allow deliveries again
|
assert.Equal(t, delivery.CircuitClosed, cb.State(),
|
||||||
assert.True(t, cb.Allow(), "closed circuit should allow deliveries")
|
"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()
|
t.Parallel()
|
||||||
cb := &CircuitBreaker{
|
|
||||||
state: CircuitClosed,
|
|
||||||
threshold: defaultFailureThreshold,
|
|
||||||
cooldown: 50 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trip open → wait for cooldown → allow probe
|
cb := newShortCooldownCB(t)
|
||||||
for i := 0; i < defaultFailureThreshold; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
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()
|
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()
|
t.Parallel()
|
||||||
cb := NewCircuitBreaker()
|
|
||||||
|
|
||||||
// Accumulate failures just below threshold
|
cb := delivery.NewCircuitBreaker()
|
||||||
for i := 0; i < defaultFailureThreshold-1; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold - 1 {
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
}
|
}
|
||||||
require.Equal(t, CircuitClosed, cb.State())
|
|
||||||
|
|
||||||
// Success should reset the failure counter
|
require.Equal(t, delivery.CircuitClosed, cb.State())
|
||||||
|
|
||||||
cb.RecordSuccess()
|
cb.RecordSuccess()
|
||||||
assert.Equal(t, CircuitClosed, cb.State())
|
|
||||||
|
|
||||||
// Now we should need another full threshold of failures to trip
|
assert.Equal(t, delivery.CircuitClosed, cb.State())
|
||||||
for i := 0; i < defaultFailureThreshold-1; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold - 1 {
|
||||||
cb.RecordFailure()
|
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()
|
cb.RecordFailure()
|
||||||
assert.Equal(t, CircuitOpen, cb.State())
|
|
||||||
|
assert.Equal(t, delivery.CircuitOpen, cb.State())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
|
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cb := NewCircuitBreaker()
|
|
||||||
|
cb := delivery.NewCircuitBreaker()
|
||||||
|
|
||||||
const goroutines = 100
|
const goroutines = 100
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
wg.Add(goroutines * 3)
|
wg.Add(goroutines * 3)
|
||||||
|
|
||||||
// Concurrent Allow calls
|
for range goroutines {
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
cb.Allow()
|
cb.Allow()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concurrent RecordFailure calls
|
for range goroutines {
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concurrent RecordSuccess calls
|
for range goroutines {
|
||||||
for i := 0; i < goroutines; i++ {
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
cb.RecordSuccess()
|
cb.RecordSuccess()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
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()
|
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()
|
t.Parallel()
|
||||||
cb := NewCircuitBreaker()
|
|
||||||
assert.Equal(t, time.Duration(0), cb.CooldownRemaining(),
|
cb := delivery.NewCircuitBreaker()
|
||||||
"closed circuit should have zero cooldown remaining")
|
|
||||||
|
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()
|
t.Parallel()
|
||||||
cb := &CircuitBreaker{
|
|
||||||
state: CircuitClosed,
|
|
||||||
threshold: defaultFailureThreshold,
|
|
||||||
cooldown: 50 * time.Millisecond,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trip open, wait, transition to half-open
|
cb := newShortCooldownCB(t)
|
||||||
for i := 0; i < defaultFailureThreshold; i++ {
|
|
||||||
|
for range delivery.ExportDefaultFailureThreshold {
|
||||||
cb.RecordFailure()
|
cb.RecordFailure()
|
||||||
}
|
}
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
require.True(t, cb.Allow()) // → half-open
|
|
||||||
|
|
||||||
assert.Equal(t, time.Duration(0), cb.CooldownRemaining(),
|
time.Sleep(60 * time.Millisecond)
|
||||||
"half-open circuit should have zero cooldown remaining")
|
|
||||||
|
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) {
|
func TestCircuitState_String(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
assert.Equal(t, "closed", CircuitClosed.String())
|
|
||||||
assert.Equal(t, "open", CircuitOpen.String())
|
assert.Equal(t, "closed", delivery.CircuitClosed.String())
|
||||||
assert.Equal(t, "half-open", CircuitHalfOpen.String())
|
assert.Equal(t, "open", delivery.CircuitOpen.String())
|
||||||
assert.Equal(t, "unknown", CircuitState(99).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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
112
internal/delivery/client_ssrf_test.go
Normal file
112
internal/delivery/client_ssrf_test.go
Normal 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
275
internal/delivery/export_test.go
Normal file
275
internal/delivery/export_test.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
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 delivers via the http target for testing.
|
||||||
|
func (e *Engine) ExportDeliverHTTP(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
) {
|
||||||
|
e.httpTarget.Deliver(ctx, webhookDB, d, task, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportDeliverDatabase delivers via the database target.
|
||||||
|
func (e *Engine) ExportDeliverDatabase(
|
||||||
|
webhookDB *gorm.DB, d *database.Delivery,
|
||||||
|
) {
|
||||||
|
e.targets[database.TargetTypeDatabase].Deliver(
|
||||||
|
context.Background(), webhookDB, d, &Task{}, e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportDeliverLog delivers via the log target for testing.
|
||||||
|
func (e *Engine) ExportDeliverLog(
|
||||||
|
webhookDB *gorm.DB, d *database.Delivery,
|
||||||
|
) {
|
||||||
|
e.targets[database.TargetTypeLog].Deliver(
|
||||||
|
context.Background(), webhookDB, d, &Task{}, e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportDeliverSlack delivers via the slack target for
|
||||||
|
// testing.
|
||||||
|
func (e *Engine) ExportDeliverSlack(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
) {
|
||||||
|
task := &Task{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
TargetID: d.TargetID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
e.targets[database.TargetTypeSlack].Deliver(
|
||||||
|
ctx, webhookDB, d, task, e,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 the http target's
|
||||||
|
// getCircuitBreaker.
|
||||||
|
func (e *Engine) ExportGetCircuitBreaker(
|
||||||
|
targetID string,
|
||||||
|
) *CircuitBreaker {
|
||||||
|
return e.httpTarget.getCircuitBreaker(targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||||
|
func (e *Engine) ExportParseHTTPConfig(
|
||||||
|
configJSON string,
|
||||||
|
) (*HTTPTargetConfig, error) {
|
||||||
|
return parseHTTPConfig(configJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportParseSlackConfig exposes parseSlackConfig.
|
||||||
|
func (e *Engine) ExportParseSlackConfig(
|
||||||
|
configJSON string,
|
||||||
|
) (*SlackTargetConfig, error) {
|
||||||
|
return parseSlackConfig(configJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportDoHTTPRequest exposes the http target's
|
||||||
|
// doHTTPRequest.
|
||||||
|
func (e *Engine) ExportDoHTTPRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *HTTPTargetConfig,
|
||||||
|
event *database.Event,
|
||||||
|
) (int, string, int64, error) {
|
||||||
|
return e.httpTarget.doHTTPRequest(ctx, cfg, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportClientForConfig exposes the http target's
|
||||||
|
// clientForConfig.
|
||||||
|
func (e *Engine) ExportClientForConfig(
|
||||||
|
cfg *HTTPTargetConfig,
|
||||||
|
) *http.Client {
|
||||||
|
return e.httpTarget.clientForConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportClient returns the http target's shared HTTP client.
|
||||||
|
func (e *Engine) ExportClient() *http.Client {
|
||||||
|
return e.httpTarget.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 {
|
||||||
|
e := &Engine{
|
||||||
|
log: log,
|
||||||
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
|
workers: workers,
|
||||||
|
}
|
||||||
|
e.initTargets(client)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestEngineSmallRetry creates an Engine with a tiny
|
||||||
|
// retry channel buffer for overflow testing.
|
||||||
|
func NewTestEngineSmallRetry(
|
||||||
|
log *slog.Logger,
|
||||||
|
) *Engine {
|
||||||
|
e := &Engine{
|
||||||
|
log: log,
|
||||||
|
retryCh: make(chan Task, 1),
|
||||||
|
}
|
||||||
|
e.initTargets(nil)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
e := &Engine{
|
||||||
|
database: db,
|
||||||
|
dbManager: dbMgr,
|
||||||
|
log: log,
|
||||||
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||||
|
retryCh: make(chan Task, retryChannelSize),
|
||||||
|
workers: workers,
|
||||||
|
}
|
||||||
|
e.initTargets(client)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
222
internal/delivery/ssrf.go
Normal file
222
internal/delivery/ssrf.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// dnsResolutionTimeout is the maximum time to wait for
|
||||||
|
// DNS resolution during SSRF validation.
|
||||||
|
dnsResolutionTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||||
|
func init() {
|
||||||
|
cidrs := []string{
|
||||||
|
"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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedNetworks = append(
|
||||||
|
blockedNetworks, network,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func ValidateTargetURL(
|
||||||
|
ctx context.Context, targetURL string,
|
||||||
|
) error {
|
||||||
|
parsed, err := url.Parse(targetURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateScheme(parsed.Scheme)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return errNoHostname
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
return checkBlockedIP(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
dnsCtx, host,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to resolve hostname %q: %w",
|
||||||
|
host, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ips) == 0 {
|
||||||
|
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: %w",
|
||||||
|
host, ipAddr.IP, errBlockedIP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
172
internal/delivery/ssrf_test.go
Normal file
172
internal/delivery/ssrf_test.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
blocked bool
|
||||||
|
}{
|
||||||
|
{"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},
|
||||||
|
{"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},
|
||||||
|
{"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},
|
||||||
|
{"192.168.0.1", "192.168.0.1", true},
|
||||||
|
{"192.168.255.255", "192.168.255.255", true},
|
||||||
|
{"169.254.0.1", "169.254.0.1", true},
|
||||||
|
{"169.254.169.254", "169.254.169.254", true},
|
||||||
|
{"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},
|
||||||
|
{"::1", "::1", true},
|
||||||
|
{"fd00::1", "fd00::1", true},
|
||||||
|
{"fc00::1", "fc00::1", true},
|
||||||
|
{"fe80::1", "fe80::1", true},
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
delivery.ExportIsBlockedIP(ip),
|
||||||
|
"isBlockedIP(%s) = %v, want %v",
|
||||||
|
tt.ip,
|
||||||
|
delivery.ExportIsBlockedIP(ip),
|
||||||
|
tt.blocked,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTargetURL_Blocked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
blockedURLs := []string{
|
||||||
|
"http://127.0.0.1/hook",
|
||||||
|
"http://127.0.0.1:8080/hook",
|
||||||
|
"https://10.0.0.1/hook",
|
||||||
|
"http://192.168.1.1/webhook",
|
||||||
|
"http://172.16.0.1/api",
|
||||||
|
"http://169.254.169.254/latest/meta-data/",
|
||||||
|
"http://[::1]/hook",
|
||||||
|
"http://[fc00::1]/hook",
|
||||||
|
"http://[fe80::1]/hook",
|
||||||
|
"http://0.0.0.0/hook",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range blockedURLs {
|
||||||
|
t.Run(u, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := delivery.ValidateTargetURL(
|
||||||
|
context.Background(), u,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Error(t, err,
|
||||||
|
"URL %s should be blocked", u,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTargetURL_Allowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
allowedURLs := []string{
|
||||||
|
"https://example.com/hook",
|
||||||
|
"http://93.184.216.34/webhook",
|
||||||
|
"https://hooks.slack.com/services/T00/B00/xxx",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range allowedURLs {
|
||||||
|
t.Run(u, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
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 := 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 := delivery.ValidateTargetURL(
|
||||||
|
context.Background(), "http:///path",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTargetURL_InvalidURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := delivery.ValidateTargetURL(
|
||||||
|
context.Background(), "://invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockedNetworks_Initialized(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
101
internal/delivery/target.go
Normal file
101
internal/delivery/target.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scheduler re-enqueues a task for a future delivery attempt.
|
||||||
|
// The engine provides one to each target so a target can own
|
||||||
|
// its retries durably: it records the attempt, marks the
|
||||||
|
// delivery retrying, and asks the Scheduler to deliver the
|
||||||
|
// next attempt after delay — exactly what the engine does for
|
||||||
|
// its own restart recovery.
|
||||||
|
type Scheduler interface {
|
||||||
|
ScheduleRetry(task Task, delay time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target delivers an event to one target type. Each type is
|
||||||
|
// an implementation. A Target owns its whole delivery: it
|
||||||
|
// makes the attempt, records the DeliveryResult and updates
|
||||||
|
// the DeliveryStatus, and — for targets that retry — decides
|
||||||
|
// whether to retry, computes its own backoff, gates with its
|
||||||
|
// own circuit breaker, and reschedules via the injected
|
||||||
|
// Scheduler. Fire-and-forget targets simply record a single
|
||||||
|
// attempt.
|
||||||
|
type Target interface {
|
||||||
|
Deliver(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rescheduler is implemented by targets that own durable
|
||||||
|
// retries. The engine's restart recovery and periodic sweep
|
||||||
|
// use it to let the target recompute the schedule for an
|
||||||
|
// orphaned retrying delivery, keeping the retry schedule
|
||||||
|
// target-owned. Fire-and-forget targets do not implement it
|
||||||
|
// and their (never-occurring) retrying deliveries are
|
||||||
|
// skipped.
|
||||||
|
type rescheduler interface {
|
||||||
|
// remainingBackoff returns how long to wait before the
|
||||||
|
// next attempt of a recovered retrying delivery.
|
||||||
|
remainingBackoff(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryID string,
|
||||||
|
attemptNum int,
|
||||||
|
) time.Duration
|
||||||
|
|
||||||
|
// backoffElapsed reports whether the backoff window for
|
||||||
|
// the last attempt has already passed, so the periodic
|
||||||
|
// sweep can re-enqueue the delivery now.
|
||||||
|
backoffElapsed(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryID string,
|
||||||
|
attemptNum int,
|
||||||
|
) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptResult is the outcome of a single delivery attempt,
|
||||||
|
// as reported by a target's per-attempt function to the
|
||||||
|
// shared retry core.
|
||||||
|
type attemptResult struct {
|
||||||
|
statusCode int
|
||||||
|
respBody string
|
||||||
|
duration int64
|
||||||
|
success bool
|
||||||
|
errMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// initTargets builds the target registry, wiring each target
|
||||||
|
// to the engine's persistence helpers and giving the HTTP and
|
||||||
|
// Slack targets the shared SSRF-safe client. It is called by
|
||||||
|
// both New and the test constructors so the registry is
|
||||||
|
// always populated.
|
||||||
|
func (e *Engine) initTargets(client *http.Client) {
|
||||||
|
httpT := &httpTarget{
|
||||||
|
httpCore: &httpCore{eng: e},
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
|
||||||
|
slackT := &slackTarget{
|
||||||
|
httpCore: &httpCore{eng: e},
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
|
||||||
|
e.httpTarget = httpT
|
||||||
|
|
||||||
|
e.targets = map[database.TargetType]Target{
|
||||||
|
database.TargetTypeHTTP: httpT,
|
||||||
|
database.TargetTypeSlack: slackT,
|
||||||
|
database.TargetTypeDatabase: &databaseTarget{eng: e},
|
||||||
|
database.TargetTypeLog: &logTarget{eng: e},
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/delivery/target_database.go
Normal file
34
internal/delivery/target_database.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// databaseTarget is a fire-and-forget target: the event is
|
||||||
|
// already persisted in the per-webhook database by the time
|
||||||
|
// delivery runs, so the target records a single successful
|
||||||
|
// attempt. (Durable archiving to a separate store is tracked
|
||||||
|
// as its own work.)
|
||||||
|
type databaseTarget struct {
|
||||||
|
eng *Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver implements Target.
|
||||||
|
func (t *databaseTarget) Deliver(
|
||||||
|
_ context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
_ *Task,
|
||||||
|
_ Scheduler,
|
||||||
|
) {
|
||||||
|
t.eng.recordResult(
|
||||||
|
webhookDB, d, 1, true, 0, "", "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d, database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
499
internal/delivery/target_http.go
Normal file
499
internal/delivery/target_http.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors returned by the config parsers.
|
||||||
|
var (
|
||||||
|
errEmptyTargetConfig = errors.New(
|
||||||
|
"empty target config",
|
||||||
|
)
|
||||||
|
errMissingTargetURL = errors.New(
|
||||||
|
"target URL is required",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPTargetConfig holds configuration for http target
|
||||||
|
// types.
|
||||||
|
type HTTPTargetConfig struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpCore holds the retry, backoff, and circuit-breaker
|
||||||
|
// machinery shared by the HTTP and Slack targets. Each of
|
||||||
|
// those targets owns its own httpCore instance (and thus its
|
||||||
|
// own circuit breakers); the per-attempt request differs
|
||||||
|
// between them and is supplied as a closure.
|
||||||
|
type httpCore struct {
|
||||||
|
eng *Engine
|
||||||
|
|
||||||
|
// circuitBreakers stores a *CircuitBreaker per target ID.
|
||||||
|
circuitBreakers sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver runs one delivery attempt through the retry core.
|
||||||
|
// A maxRetries of 0 is fire-and-forget: a single attempt is
|
||||||
|
// recorded and no circuit breaker is consulted. A positive
|
||||||
|
// maxRetries gates the attempt on the circuit breaker and
|
||||||
|
// schedules a backed-off retry on failure.
|
||||||
|
func (c *httpCore) deliver(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
maxRetries int,
|
||||||
|
attempt func() attemptResult,
|
||||||
|
) {
|
||||||
|
if maxRetries == 0 {
|
||||||
|
c.fireAndForget(webhookDB, d, attempt())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.withRetry(
|
||||||
|
webhookDB, d, task, sched, maxRetries, attempt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpCore) fireAndForget(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
res attemptResult,
|
||||||
|
) {
|
||||||
|
c.eng.recordResult(
|
||||||
|
webhookDB, d, 1, res.success,
|
||||||
|
res.statusCode, res.respBody, res.errMsg,
|
||||||
|
res.duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
if res.success {
|
||||||
|
c.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d, database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpCore) withRetry(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
maxRetries int,
|
||||||
|
attempt func() attemptResult,
|
||||||
|
) {
|
||||||
|
cb := c.getCircuitBreaker(task.TargetID)
|
||||||
|
if c.circuitBreakerBlock(webhookDB, d, task, sched, cb) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptNum := task.AttemptNum
|
||||||
|
|
||||||
|
res := attempt()
|
||||||
|
|
||||||
|
c.eng.recordResult(
|
||||||
|
webhookDB, d, attemptNum, res.success,
|
||||||
|
res.statusCode, res.respBody, res.errMsg,
|
||||||
|
res.duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
if res.success {
|
||||||
|
cb.RecordSuccess()
|
||||||
|
|
||||||
|
c.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.RecordFailure()
|
||||||
|
|
||||||
|
c.handleRetry(
|
||||||
|
webhookDB, d, task, sched, maxRetries, attemptNum,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpCore) circuitBreakerBlock(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
cb *CircuitBreaker,
|
||||||
|
) bool {
|
||||||
|
if cb.Allow() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := cb.CooldownRemaining()
|
||||||
|
|
||||||
|
c.eng.log.Info(
|
||||||
|
"circuit breaker open, skipping delivery",
|
||||||
|
"target_id", task.TargetID,
|
||||||
|
"target_name", task.TargetName,
|
||||||
|
"delivery_id", d.ID,
|
||||||
|
"cooldown_remaining", remaining,
|
||||||
|
)
|
||||||
|
|
||||||
|
c.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
retryTask := *task
|
||||||
|
sched.ScheduleRetry(retryTask, remaining)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpCore) handleRetry(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
maxRetries int,
|
||||||
|
attemptNum int,
|
||||||
|
) {
|
||||||
|
if attemptNum >= maxRetries {
|
||||||
|
c.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d, database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
backoff := calcBackoff(attemptNum)
|
||||||
|
|
||||||
|
retryTask := *task
|
||||||
|
retryTask.AttemptNum = attemptNum + 1
|
||||||
|
sched.ScheduleRetry(retryTask, backoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpCore) getCircuitBreaker(
|
||||||
|
targetID string,
|
||||||
|
) *CircuitBreaker {
|
||||||
|
if val, ok := c.circuitBreakers.Load(targetID); ok {
|
||||||
|
cb, _ := val.(*CircuitBreaker)
|
||||||
|
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh := NewCircuitBreaker()
|
||||||
|
|
||||||
|
actual, _ := c.circuitBreakers.LoadOrStore(
|
||||||
|
targetID, fresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
cb, _ := actual.(*CircuitBreaker)
|
||||||
|
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
|
// remainingBackoff returns how long remains of the backoff
|
||||||
|
// window for the last attempt of a recovered retrying
|
||||||
|
// delivery. It implements rescheduler.
|
||||||
|
func (c *httpCore) remainingBackoff(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryID string,
|
||||||
|
attemptNum int,
|
||||||
|
) time.Duration {
|
||||||
|
var lastResult database.DeliveryResult
|
||||||
|
|
||||||
|
err := webhookDB.
|
||||||
|
Where("delivery_id = ?", deliveryID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
First(&lastResult).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff := calcBackoff(attemptNum)
|
||||||
|
elapsed := time.Since(lastResult.CreatedAt)
|
||||||
|
remaining := backoff - elapsed
|
||||||
|
|
||||||
|
return max(remaining, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoffElapsed reports whether the backoff window for the
|
||||||
|
// last attempt of a retrying delivery has passed. It
|
||||||
|
// implements rescheduler.
|
||||||
|
func (c *httpCore) backoffElapsed(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryID string,
|
||||||
|
attemptNum int,
|
||||||
|
) bool {
|
||||||
|
var lastResult database.DeliveryResult
|
||||||
|
|
||||||
|
err := webhookDB.
|
||||||
|
Where("delivery_id = ?", deliveryID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
First(&lastResult).Error
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
backoff := calcBackoff(attemptNum)
|
||||||
|
|
||||||
|
return time.Since(lastResult.CreatedAt) >= backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcBackoff(attemptNum int) time.Duration {
|
||||||
|
shift := max(attemptNum-1, 0)
|
||||||
|
shift = min(shift, maxBackoffShift)
|
||||||
|
|
||||||
|
return time.Duration(1<<uint(shift)) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpTarget delivers events to http targets. It forwards the
|
||||||
|
// event body and (filtered) request headers to the configured
|
||||||
|
// URL and owns retry, backoff, and circuit breaking through
|
||||||
|
// the shared httpCore.
|
||||||
|
type httpTarget struct {
|
||||||
|
*httpCore
|
||||||
|
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver implements Target.
|
||||||
|
func (t *httpTarget) Deliver(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
) {
|
||||||
|
cfg, err := parseHTTPConfig(d.Target.Config)
|
||||||
|
if err != nil {
|
||||||
|
t.eng.log.Error(
|
||||||
|
"invalid HTTP target config",
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.eng.recordResult(
|
||||||
|
webhookDB, d, task.AttemptNum,
|
||||||
|
false, 0, "", err.Error(), 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d, database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := func() attemptResult {
|
||||||
|
return t.attempt(ctx, cfg, &d.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.deliver(
|
||||||
|
webhookDB, d, task, sched,
|
||||||
|
d.Target.MaxRetries, attempt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt performs a single HTTP delivery attempt and derives
|
||||||
|
// the success flag and error message the same way the engine
|
||||||
|
// did: a non-2xx response is a failure but carries no error
|
||||||
|
// string; only a transport-level error does.
|
||||||
|
func (t *httpTarget) attempt(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *HTTPTargetConfig,
|
||||||
|
event *database.Event,
|
||||||
|
) attemptResult {
|
||||||
|
statusCode, respBody, duration, reqErr :=
|
||||||
|
t.doHTTPRequest(ctx, cfg, event)
|
||||||
|
|
||||||
|
success := reqErr == nil &&
|
||||||
|
statusCode >= httpSuccessMin &&
|
||||||
|
statusCode < httpSuccessMax
|
||||||
|
|
||||||
|
errMsg := ""
|
||||||
|
if reqErr != nil {
|
||||||
|
errMsg = reqErr.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return attemptResult{
|
||||||
|
statusCode: statusCode,
|
||||||
|
respBody: respBody,
|
||||||
|
duration: duration,
|
||||||
|
success: success,
|
||||||
|
errMsg: errMsg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *httpTarget) doHTTPRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *HTTPTargetConfig,
|
||||||
|
event *database.Event,
|
||||||
|
) (int, string, int64, error) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
req, reqErr := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
cfg.URL,
|
||||||
|
bytes.NewReader([]byte(event.Body)),
|
||||||
|
)
|
||||||
|
if reqErr != nil {
|
||||||
|
return 0, "", 0, fmt.Errorf(
|
||||||
|
"creating request: %w", reqErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRequestHeaders(req, event, cfg)
|
||||||
|
|
||||||
|
client := t.clientForConfig(cfg)
|
||||||
|
|
||||||
|
resp, doErr := executeHTTPRequest(client, req)
|
||||||
|
|
||||||
|
dur := time.Since(start).Milliseconds()
|
||||||
|
if doErr != nil {
|
||||||
|
return 0, "", dur, fmt.Errorf(
|
||||||
|
"sending request: %w", doErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(
|
||||||
|
io.LimitReader(resp.Body, maxBodyLog),
|
||||||
|
)
|
||||||
|
if readErr != nil {
|
||||||
|
return resp.StatusCode, "", dur,
|
||||||
|
fmt.Errorf(
|
||||||
|
"reading response body: %w", readErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, string(body), dur, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *httpTarget) clientForConfig(
|
||||||
|
cfg *HTTPTargetConfig,
|
||||||
|
) *http.Client {
|
||||||
|
if cfg.Timeout > 0 {
|
||||||
|
// Reuse the shared client's SSRF-safe transport so
|
||||||
|
// a per-target timeout does not drop the
|
||||||
|
// request-time private-IP guard. Only the timeout
|
||||||
|
// is overridden.
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Duration(
|
||||||
|
cfg.Timeout,
|
||||||
|
) * time.Second,
|
||||||
|
Transport: t.client.Transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.client
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHTTPConfig(
|
||||||
|
configJSON string,
|
||||||
|
) (*HTTPTargetConfig, error) {
|
||||||
|
if configJSON == "" {
|
||||||
|
return nil, errEmptyTargetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg HTTPTargetConfig
|
||||||
|
|
||||||
|
err := json.Unmarshal(
|
||||||
|
[]byte(configJSON), &cfg,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"parsing config JSON: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return nil, errMissingTargetURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isForwardableHeader returns true if the header should
|
||||||
|
// be forwarded to targets.
|
||||||
|
func isForwardableHeader(name string) bool {
|
||||||
|
switch http.CanonicalHeaderKey(name) {
|
||||||
|
case "Host", "Connection", "Keep-Alive",
|
||||||
|
"Transfer-Encoding", "Te", "Trailer",
|
||||||
|
"Upgrade", "Proxy-Authorization",
|
||||||
|
"Proxy-Connection", "Content-Length":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRequestHeaders(
|
||||||
|
req *http.Request,
|
||||||
|
event *database.Event,
|
||||||
|
cfg *HTTPTargetConfig,
|
||||||
|
) {
|
||||||
|
if event.ContentType != "" {
|
||||||
|
req.Header.Set(
|
||||||
|
"Content-Type", event.ContentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalHeaders map[string][]string
|
||||||
|
|
||||||
|
if event.Headers != "" {
|
||||||
|
jsonErr := json.Unmarshal(
|
||||||
|
[]byte(event.Headers),
|
||||||
|
&originalHeaders,
|
||||||
|
)
|
||||||
|
if jsonErr == nil {
|
||||||
|
for k, vals := range originalHeaders {
|
||||||
|
if isForwardableHeader(k) {
|
||||||
|
for _, v := range vals {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range cfg.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeHTTPRequest sends an HTTP request using the provided
|
||||||
|
// client. URLs are validated by the config parsers and the
|
||||||
|
// SSRF-safe transport before reaching here.
|
||||||
|
func executeHTTPRequest(
|
||||||
|
client *http.Client, req *http.Request,
|
||||||
|
) (*http.Response, error) {
|
||||||
|
return client.Do(req) //#nosec G704 -- URL validated by parseHTTPConfig/parseSlackConfig and SSRF-safe transport
|
||||||
|
}
|
||||||
47
internal/delivery/target_log.go
Normal file
47
internal/delivery/target_log.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logTarget is a fire-and-forget target that logs the entire
|
||||||
|
// inbound webhook — the full request body and headers, plus
|
||||||
|
// the method, content type, and the webhook and entrypoint
|
||||||
|
// ids — then records a single successful attempt.
|
||||||
|
type logTarget struct {
|
||||||
|
eng *Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver implements Target.
|
||||||
|
func (t *logTarget) Deliver(
|
||||||
|
_ context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
_ *Task,
|
||||||
|
_ Scheduler,
|
||||||
|
) {
|
||||||
|
t.eng.log.Info(
|
||||||
|
"webhook event delivered to log target",
|
||||||
|
"delivery_id", d.ID,
|
||||||
|
"event_id", d.EventID,
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"target_name", d.Target.Name,
|
||||||
|
"webhook_id", d.Event.WebhookID,
|
||||||
|
"entrypoint_id", d.Event.EntrypointID,
|
||||||
|
"method", d.Event.Method,
|
||||||
|
"content_type", d.Event.ContentType,
|
||||||
|
"headers", d.Event.Headers,
|
||||||
|
"body", d.Event.Body,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.eng.recordResult(
|
||||||
|
webhookDB, d, 1, true, 0, "", "", 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d, database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
}
|
||||||
299
internal/delivery/target_slack.go
Normal file
299
internal/delivery/target_slack.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errMissingWebhookURL is returned when a Slack target config
|
||||||
|
// omits its webhook URL.
|
||||||
|
var errMissingWebhookURL = errors.New(
|
||||||
|
"webhook_url is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlackTargetConfig holds configuration for slack target
|
||||||
|
// types.
|
||||||
|
type SlackTargetConfig struct {
|
||||||
|
WebhookURL string `json:"webhookUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// slackTarget delivers events to Slack incoming webhooks. It
|
||||||
|
// formats the event into a Slack message and posts it as
|
||||||
|
// JSON. It shares the retry core with the HTTP target: a
|
||||||
|
// MaxRetries of 0 stays single-attempt fire-and-forget
|
||||||
|
// (preserving existing Slack targets), while a positive
|
||||||
|
// MaxRetries adds backoff and circuit breaking.
|
||||||
|
type slackTarget struct {
|
||||||
|
*httpCore
|
||||||
|
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver implements Target.
|
||||||
|
func (t *slackTarget) Deliver(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
task *Task,
|
||||||
|
sched Scheduler,
|
||||||
|
) {
|
||||||
|
cfg, err := parseSlackConfig(d.Target.Config)
|
||||||
|
if err != nil {
|
||||||
|
t.eng.log.Error(
|
||||||
|
"invalid Slack target config",
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.failConfig(webhookDB, d, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := FormatSlackMessage(&d.Event)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(
|
||||||
|
map[string]string{"text": msg},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.eng.log.Error(
|
||||||
|
"failed to marshal Slack payload",
|
||||||
|
"target_id", d.TargetID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.failConfig(webhookDB, d, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := func() attemptResult {
|
||||||
|
return t.attempt(ctx, cfg, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.deliver(
|
||||||
|
webhookDB, d, task, sched,
|
||||||
|
d.Target.MaxRetries, attempt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failConfig records a first-attempt failure for a delivery
|
||||||
|
// that could not be prepared (bad config or unmarshalable
|
||||||
|
// payload) and marks it failed.
|
||||||
|
func (t *slackTarget) failConfig(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
t.eng.recordResult(
|
||||||
|
webhookDB, d, 1,
|
||||||
|
false, 0, "", err.Error(), 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.eng.updateDeliveryStatus(
|
||||||
|
webhookDB, d, database.DeliveryStatusFailed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt performs a single Slack POST and derives its
|
||||||
|
// outcome, preserving the engine's original semantics: a
|
||||||
|
// non-2xx response records an "HTTP <code>" error string and
|
||||||
|
// a transport error records a "sending request" error.
|
||||||
|
func (t *slackTarget) attempt(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *SlackTargetConfig,
|
||||||
|
payload []byte,
|
||||||
|
) attemptResult {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
cfg.WebhookURL,
|
||||||
|
bytes.NewReader(payload),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return attemptResult{
|
||||||
|
success: false,
|
||||||
|
errMsg: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||||
|
|
||||||
|
resp, doErr := executeHTTPRequest(t.client, req)
|
||||||
|
durationMs := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if doErr != nil {
|
||||||
|
return attemptResult{
|
||||||
|
success: false,
|
||||||
|
duration: durationMs,
|
||||||
|
errMsg: fmt.Errorf(
|
||||||
|
"sending request: %w", doErr,
|
||||||
|
).Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
return t.readSlackResponse(resp, durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *slackTarget) readSlackResponse(
|
||||||
|
resp *http.Response,
|
||||||
|
durationMs int64,
|
||||||
|
) attemptResult {
|
||||||
|
body, readErr := io.ReadAll(
|
||||||
|
io.LimitReader(resp.Body, maxBodyLog),
|
||||||
|
)
|
||||||
|
if readErr != nil {
|
||||||
|
t.eng.log.Error(
|
||||||
|
"failed to read Slack response body",
|
||||||
|
"error", readErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
success := resp.StatusCode >= httpSuccessMin &&
|
||||||
|
resp.StatusCode < httpSuccessMax
|
||||||
|
|
||||||
|
errMsg := ""
|
||||||
|
if !success {
|
||||||
|
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attemptResult{
|
||||||
|
statusCode: resp.StatusCode,
|
||||||
|
respBody: string(body),
|
||||||
|
duration: durationMs,
|
||||||
|
success: success,
|
||||||
|
errMsg: errMsg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSlackConfig(
|
||||||
|
configJSON string,
|
||||||
|
) (*SlackTargetConfig, error) {
|
||||||
|
if configJSON == "" {
|
||||||
|
return nil, errEmptyTargetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg SlackTargetConfig
|
||||||
|
|
||||||
|
err := json.Unmarshal(
|
||||||
|
[]byte(configJSON), &cfg,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"parsing config JSON: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.WebhookURL == "" {
|
||||||
|
return nil, errMissingWebhookURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSlackMessage builds a Slack-compatible message
|
||||||
|
// string from a webhook event.
|
||||||
|
func FormatSlackMessage(
|
||||||
|
event *database.Event,
|
||||||
|
) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("*Webhook Event Received*\n")
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
&b, "*Method:* `%s`\n", event.Method,
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
&b,
|
||||||
|
"*Content-Type:* `%s`\n",
|
||||||
|
event.ContentType,
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
&b,
|
||||||
|
"*Timestamp:* `%s`\n",
|
||||||
|
event.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Fprintf(
|
||||||
|
&b,
|
||||||
|
"*Body Size:* %d bytes\n",
|
||||||
|
len(event.Body),
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.Body == "" {
|
||||||
|
b.WriteString("\n_(empty body)_\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if formatted := formatJSONBody(event.Body); formatted != "" {
|
||||||
|
b.WriteString(formatted)
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
formatRawBody(&b, event.Body)
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatJSONBody(body string) string {
|
||||||
|
var parsed json.RawMessage
|
||||||
|
if json.Unmarshal([]byte(body), &parsed) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var pretty bytes.Buffer
|
||||||
|
if json.Indent(&pretty, parsed, "", " ") != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
|
||||||
|
prettyStr := pretty.String()
|
||||||
|
|
||||||
|
const maxPayloadDisplay = 3500
|
||||||
|
if len(prettyStr) > maxPayloadDisplay {
|
||||||
|
b.WriteString(prettyStr[:maxPayloadDisplay])
|
||||||
|
b.WriteString("\n... (truncated)")
|
||||||
|
} else {
|
||||||
|
b.WriteString(prettyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRawBody(b *strings.Builder, body string) {
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
|
||||||
|
const maxRawDisplay = 3500
|
||||||
|
if len(body) > maxRawDisplay {
|
||||||
|
b.WriteString(body[:maxRawDisplay])
|
||||||
|
b.WriteString("\n... (truncated)")
|
||||||
|
} else {
|
||||||
|
b.WriteString(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
}
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
|
// Package globals provides build-time variables injected via ldflags.
|
||||||
package globals
|
package globals
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// these get 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 (
|
var (
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Globals holds build-time metadata about the application.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:revive // lc parameter is required by fx even if unused
|
// 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) {
|
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||||
n := &Globals{
|
n := &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
package globals
|
package globals_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestGlobalsFields(t *testing.T) {
|
||||||
// Set test values
|
t.Parallel()
|
||||||
Appname = "test-app"
|
|
||||||
Version = "1.0.0"
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
g := &globals.Globals{
|
||||||
globals, err := New(lc)
|
Appname: "test-app",
|
||||||
if err != nil {
|
Version: "1.0.0",
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if globals.Appname != "test-app" {
|
if g.Appname != "test-app" {
|
||||||
t.Errorf("Appname = %v, want %v", globals.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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err == nil && h.session.IsAuthenticated(sess) {
|
if err == nil && h.session.IsAuthenticated(sess) {
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render login page
|
// Render login page
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"Error": "",
|
"Error": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +29,15 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|||||||
// HandleLoginSubmit handles the login form submission (POST)
|
// HandleLoginSubmit handles the login form submission (POST)
|
||||||
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Limit request body to prevent memory exhaustion
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
|
||||||
|
|
||||||
// Parse form data
|
// Parse form data
|
||||||
if err := r.ParseForm(); err != nil {
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,85 +46,159 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
data := map[string]interface{}{
|
h.renderLoginError(
|
||||||
"Error": "Username and password are required",
|
w, r,
|
||||||
}
|
"Username and password are required",
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
http.StatusBadRequest,
|
||||||
h.renderTemplate(w, r, "login.html", data)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find user in database
|
user, err := h.authenticateUser(
|
||||||
var user database.User
|
w, r, username, password,
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to verify password", "error", err)
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !valid {
|
err = h.createAuthenticatedSession(w, r, user)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to get session", "error", err)
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate the session to prevent session fixation attacks.
|
h.log.Info(
|
||||||
// This destroys the old session ID and creates a new one.
|
"user logged in",
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
"username", username,
|
||||||
if err != nil {
|
"user_id", user.ID,
|
||||||
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)
|
|
||||||
|
|
||||||
// Redirect to home page
|
// Redirect to home page
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
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
|
// HandleLogout handles user logout
|
||||||
func (h *Handlers) HandleLogout() http.HandlerFunc {
|
func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to get session", "error", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +206,12 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
h.session.Destroy(sess)
|
h.session.Destroy(sess)
|
||||||
|
|
||||||
// Save the destroyed session
|
// Save the destroyed session
|
||||||
if err := h.session.Save(r, w, sess); err != nil {
|
err = h.session.Save(r, w, sess)
|
||||||
h.log.Error("failed to save destroyed session", "error", err)
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to save destroyed session",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
// Redirect to login page
|
||||||
|
|||||||
24
internal/handlers/export_test.go
Normal file
24
internal/handlers/export_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Package handlers provides HTTP request handlers for the
|
||||||
|
// webhooker web UI and API.
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -13,13 +16,29 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
"sneak.berlin/go/webhooker/templates"
|
"sneak.berlin/go/webhooker/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // HandlersParams is a standard fx naming convention
|
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 {
|
type HandlersParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
@@ -29,6 +48,8 @@ type HandlersParams struct {
|
|||||||
Notifier delivery.Notifier
|
Notifier delivery.Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handlers provides HTTP handler methods for all application
|
||||||
|
// routes.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *HandlersParams
|
params *HandlersParams
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
@@ -40,19 +61,29 @@ type Handlers struct {
|
|||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the embedded FS.
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// Each page template is combined with the shared base, htmlheader, and navbar templates.
|
// embedded FS. Each page template is combined with the shared
|
||||||
// The page file must be listed first so that its root action ({{template "base" .}})
|
// base, htmlheader, and navbar templates. The page file must be
|
||||||
// becomes the template set's entry point. If a shared partial (e.g. htmlheader.html)
|
// listed first so that its root action ({{template "base" .}})
|
||||||
// is listed first, its {{define}} block becomes the root — which is empty — and
|
// becomes the template set's entry point.
|
||||||
// Execute() produces no output.
|
|
||||||
func parsePageTemplate(pageFile string) *template.Template {
|
func parsePageTemplate(pageFile string) *template.Template {
|
||||||
return template.Must(
|
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",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 := new(Handlers)
|
||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
@@ -64,7 +95,6 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
|||||||
|
|
||||||
// Parse all page templates once at startup
|
// Parse all page templates once at startup
|
||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"index.html": parsePageTemplate("index.html"),
|
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
@@ -75,17 +105,23 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:unparam // r parameter will be used in the future for request context
|
func (s *Handlers) respondJSON(
|
||||||
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
w http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
data any,
|
||||||
|
status int,
|
||||||
|
) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
err := json.NewEncoder(w).Encode(data)
|
err := json.NewEncoder(w).Encode(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,9 +130,15 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data inte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:unparam,unused // will be used for handling JSON requests
|
// serverError logs an error and sends a 500 response.
|
||||||
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
func (s *Handlers) serverError(
|
||||||
return json.NewDecoder(r.Body).Decode(v)
|
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
|
// UserInfo represents user information for templates
|
||||||
@@ -105,52 +147,91 @@ type UserInfo struct {
|
|||||||
Username string
|
Username string
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTemplate renders a pre-parsed template with common data
|
// templateDataWrapper wraps non-map data with common fields.
|
||||||
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
|
type templateDataWrapper struct {
|
||||||
|
User *UserInfo
|
||||||
|
CSRFToken string
|
||||||
|
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]
|
tmpl, ok := s.templates[pageTemplate]
|
||||||
if !ok {
|
if !ok {
|
||||||
s.log.Error("template not found", "template", pageTemplate)
|
s.log.Error(
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
"template not found",
|
||||||
|
"template", pageTemplate,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from session if available
|
userInfo := s.getUserInfo(r)
|
||||||
var userInfo *UserInfo
|
csrfToken := middleware.CSRFToken(r)
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If data is a map, merge user info into it
|
if m, ok := data.(map[string]any); ok {
|
||||||
if m, ok := data.(map[string]interface{}); ok {
|
|
||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
if err := tmpl.Execute(w, m); err != nil {
|
m["CSRFToken"] = csrfToken
|
||||||
s.log.Error("failed to execute template", "error", err)
|
s.executeTemplate(w, tmpl, m)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap data with base template data
|
return
|
||||||
type templateDataWrapper struct {
|
|
||||||
User *UserInfo
|
|
||||||
Data interface{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper := templateDataWrapper{
|
wrapper := templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
|
CSRFToken: csrfToken,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, wrapper); err != nil {
|
s.executeTemplate(w, tmpl, wrapper)
|
||||||
s.log.Error("failed to execute template", "error", err)
|
}
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package handlers
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -14,20 +14,23 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// noopNotifier is a no-op delivery.Notifier for tests.
|
|
||||||
type noopNotifier struct{}
|
type noopNotifier struct{}
|
||||||
|
|
||||||
func (n *noopNotifier) Notify([]delivery.DeliveryTask) {}
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||||
|
|
||||||
func TestHandleIndex(t *testing.T) {
|
func newTestApp(
|
||||||
var h *Handlers
|
t *testing.T,
|
||||||
|
targets ...any,
|
||||||
|
) *fxtest.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
app := fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
@@ -41,92 +44,145 @@ func TestHandleIndex(t *testing.T) {
|
|||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
session.New,
|
session.New,
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
func() delivery.Notifier {
|
||||||
New,
|
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()
|
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) {
|
func TestRenderTemplate(t *testing.T) {
|
||||||
var h *Handlers
|
t.Parallel()
|
||||||
|
|
||||||
app := fxtest.New(
|
var h *handlers.Handlers
|
||||||
t,
|
|
||||||
fx.Provide(
|
app := newTestApp(t, &h)
|
||||||
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),
|
|
||||||
)
|
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
defer app.RequireStop()
|
|
||||||
|
|
||||||
t.Run("handles missing templates gracefully", func(t *testing.T) {
|
t.Cleanup(app.RequireStop)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
data := map[string]interface{}{
|
data := map[string]any{"Version": "1.0.0"}
|
||||||
"Version": "1.0.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
// When a non-existent template name is requested, renderTemplate
|
h.RenderTemplateForTest(
|
||||||
// should return an internal server error
|
w, req, "nonexistent.html", data,
|
||||||
h.renderTemplate(w, req, "nonexistent.html", data)
|
)
|
||||||
|
|
||||||
// Should return internal server error when template is not found
|
assert.Equal(
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const httpStatusOK = 200
|
||||||
|
|
||||||
|
// HandleHealthCheck returns an HTTP handler that reports
|
||||||
|
// application health.
|
||||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, req *http.Request) {
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
resp := s.hc.Healthcheck()
|
resp := s.hc.Healthcheck()
|
||||||
s.respondJSON(w, req, resp, 200)
|
s.respondJSON(w, req, resp, httpStatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,21 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||||
// Calculate server start time
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
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) {
|
return
|
||||||
// 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the template
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||||
s.renderTemplate(w, req, "index.html", data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
|||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
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)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil || !h.session.IsAuthenticated(sess) {
|
if err != nil {
|
||||||
// Redirect to login if not authenticated
|
h.log.Error("failed to get session", "error", err)
|
||||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
|||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,17 +41,19 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
|||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now, only allow users to view their own profile
|
// For now, only allow users to view their own profile
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare data for template
|
// Prepare data for template
|
||||||
data := map[string]interface{}{
|
data := map[string]any{
|
||||||
"User": &UserInfo{
|
"User": &UserInfo{
|
||||||
ID: sessionUserID,
|
ID: sessionUserID,
|
||||||
Username: sessionUsername,
|
Username: sessionUsername,
|
||||||
|
|||||||
159
internal/handlers/profile_test.go
Normal file
159
internal/handlers/profile_test.go
Normal 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
@@ -6,31 +6,36 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
|
// maxWebhookBodySize is the maximum allowed webhook
|
||||||
maxWebhookBodySize = 1 << 20
|
// request body (1 MB).
|
||||||
|
maxWebhookBodySize = 1 << maxBodyShift
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
|
// HandleWebhook handles incoming webhook requests at entrypoint
|
||||||
// Only POST requests are accepted; all other methods return 405 Method Not Allowed.
|
// URLs.
|
||||||
// Events and deliveries are stored in the per-webhook database. The handler
|
|
||||||
// builds self-contained DeliveryTask structs with all target and event data
|
|
||||||
// so the delivery engine can process them without additional DB reads.
|
|
||||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
w.Header().Set("Allow", "POST")
|
w.Header().Set("Allow", "POST")
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
http.Error(
|
||||||
|
w,
|
||||||
|
"Method Not Allowed",
|
||||||
|
http.StatusMethodNotAllowed,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entrypointUUID := chi.URLParam(r, "uuid")
|
entrypointUUID := chi.URLParam(r, "uuid")
|
||||||
if entrypointUUID == "" {
|
if entrypointUUID == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,69 +45,241 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Look up entrypoint by path (from main application DB)
|
entrypoint, ok := h.lookupEntrypoint(
|
||||||
var entrypoint database.Entrypoint
|
w, r, entrypointUUID,
|
||||||
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint)
|
)
|
||||||
if result.Error != nil {
|
if !ok {
|
||||||
h.log.Debug("entrypoint not found", "path", entrypointUUID)
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if active
|
|
||||||
if !entrypoint.Active {
|
if !entrypoint.Active {
|
||||||
http.Error(w, "Gone", http.StatusGone)
|
http.Error(w, "Gone", http.StatusGone)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read body with size limit
|
h.processWebhookRequest(w, r, entrypoint)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize headers as JSON
|
|
||||||
headersJSON, err := json.Marshal(r.Header)
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to serialize headers", "error", err)
|
h.serverError(w, "failed to serialize headers", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all active targets for this webhook (from main application DB)
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to get webhook database",
|
h.serverError(w, "failed to query targets", err)
|
||||||
"webhook_id", entrypoint.WebhookID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
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()
|
tx := webhookDB.Begin()
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
h.log.Error("failed to begin transaction", "error", tx.Error)
|
h.serverError(
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
w, "failed to begin transaction", tx.Error,
|
||||||
return
|
)
|
||||||
|
|
||||||
|
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,
|
WebhookID: entrypoint.WebhookID,
|
||||||
EntrypointID: entrypoint.ID,
|
EntrypointID: entrypoint.ID,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
@@ -110,44 +287,49 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
Body: string(body),
|
Body: string(body),
|
||||||
ContentType: r.Header.Get("Content-Type"),
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Create(event).Error; err != nil {
|
// buildDeliveryTasks creates delivery records in the
|
||||||
tx.Rollback()
|
// transaction and returns tasks for the delivery engine.
|
||||||
h.log.Error("failed to create event", "error", err)
|
// Returns nil if an error occurred.
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
func (h *Handlers) buildDeliveryTasks(
|
||||||
return
|
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))
|
||||||
|
|
||||||
// Prepare body pointer for inline transport (≤16KB bodies are
|
|
||||||
// included in the DeliveryTask 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
|
|
||||||
tasks := make([]delivery.DeliveryTask, 0, len(targets))
|
|
||||||
for i := range targets {
|
for i := range targets {
|
||||||
dlv := &database.Delivery{
|
dlv := &database.Delivery{
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: targets[i].ID,
|
TargetID: targets[i].ID,
|
||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
if err := tx.Create(dlv).Error; err != nil {
|
|
||||||
|
err := tx.Create(dlv).Error
|
||||||
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
h.log.Error("failed to create delivery",
|
h.log.Error(
|
||||||
|
"failed to create delivery",
|
||||||
"target_id", targets[i].ID,
|
"target_id", targets[i].ID,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(
|
||||||
return
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks = append(tasks, delivery.DeliveryTask{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
WebhookID: entrypoint.WebhookID,
|
WebhookID: entrypoint.WebhookID,
|
||||||
|
EntrypointID: entrypoint.ID,
|
||||||
TargetID: targets[i].ID,
|
TargetID: targets[i].ID,
|
||||||
TargetName: targets[i].Name,
|
TargetName: targets[i].Name,
|
||||||
TargetType: targets[i].Type,
|
TargetType: targets[i].Type,
|
||||||
@@ -161,31 +343,5 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
return tasks
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package healthcheck provides application health status reporting.
|
||||||
package healthcheck
|
package healthcheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,55 +13,51 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // HealthcheckParams is a standard fx naming convention
|
//nolint:revive // HealthcheckParams is a standard fx naming convention.
|
||||||
type HealthcheckParams struct {
|
type HealthcheckParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Healthcheck tracks application uptime and reports health status.
|
||||||
type Healthcheck struct {
|
type Healthcheck struct {
|
||||||
StartupTime time.Time
|
StartupTime time.Time
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *HealthcheckParams
|
params *HealthcheckParams
|
||||||
}
|
}
|
||||||
|
|
||||||
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 := new(Healthcheck)
|
||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
|
OnStart: func(_ context.Context) error {
|
||||||
s.StartupTime = time.Now()
|
s.StartupTime = time.Now()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:revive // HealthcheckResponse is a clear, descriptive name
|
// Healthcheck returns the current health status of the
|
||||||
type HealthcheckResponse struct {
|
// application.
|
||||||
Status string `json:"status"`
|
func (s *Healthcheck) Healthcheck() *Response {
|
||||||
Now string `json:"now"`
|
resp := &Response{
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
|
||||||
resp := &HealthcheckResponse{
|
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
UptimeSeconds: int64(s.uptime().Seconds()),
|
UptimeSeconds: int64(s.uptime().Seconds()),
|
||||||
@@ -69,5 +66,21 @@ func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
|||||||
Version: s.params.Globals.Version,
|
Version: s.params.Globals.Version,
|
||||||
Maintenance: s.params.Config.MaintenanceMode,
|
Maintenance: s.params.Config.MaintenanceMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package logger provides structured logging with dynamic level
|
||||||
|
// control.
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,19 +12,25 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // LoggerParams is a standard fx naming convention
|
//nolint:revive // LoggerParams is a standard fx naming convention.
|
||||||
type LoggerParams struct {
|
type LoggerParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logger wraps slog with dynamic level control and structured
|
||||||
|
// output.
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
levelVar *slog.LevelVar
|
levelVar *slog.LevelVar
|
||||||
params LoggerParams
|
params LoggerParams
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:revive // lc parameter is required by fx even if unused
|
// 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) {
|
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||||
l := new(Logger)
|
l := new(Logger)
|
||||||
l.params = params
|
l.params = params
|
||||||
@@ -37,17 +45,22 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
|||||||
tty = true
|
tty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAttr := func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused
|
//nolint:revive // groups param unused but required by slog ReplaceAttr signature.
|
||||||
|
replaceAttr := func(_ []string, a slog.Attr) slog.Attr {
|
||||||
// Always use UTC for timestamps
|
// Always use UTC for timestamps
|
||||||
if a.Key == slog.TimeKey {
|
if a.Key == slog.TimeKey {
|
||||||
if t, ok := a.Value.Any().(time.Time); ok {
|
if t, ok := a.Value.Any().(time.Time); ok {
|
||||||
return slog.Time(slog.TimeKey, t.UTC())
|
return slog.Time(slog.TimeKey, t.UTC())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
var handler slog.Handler
|
var handler slog.Handler
|
||||||
|
|
||||||
opts := &slog.HandlerOptions{
|
opts := &slog.HandlerOptions{
|
||||||
Level: l.levelVar,
|
Level: l.levelVar,
|
||||||
ReplaceAttr: replaceAttr,
|
ReplaceAttr: replaceAttr,
|
||||||
@@ -69,15 +82,18 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
|||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableDebugLogging switches the log level to debug.
|
||||||
func (l *Logger) EnableDebugLogging() {
|
func (l *Logger) EnableDebugLogging() {
|
||||||
l.levelVar.Set(slog.LevelDebug)
|
l.levelVar.Set(slog.LevelDebug)
|
||||||
l.logger.Debug("debug logging enabled", "debug", true)
|
l.logger.Debug("debug logging enabled", "debug", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get returns the underlying slog.Logger.
|
||||||
func (l *Logger) Get() *slog.Logger {
|
func (l *Logger) Get() *slog.Logger {
|
||||||
return l.logger
|
return l.logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identify logs the application name and version at startup.
|
||||||
func (l *Logger) Identify() {
|
func (l *Logger) Identify() {
|
||||||
l.logger.Info("starting",
|
l.logger.Info("starting",
|
||||||
"appname", l.params.Globals.Appname,
|
"appname", l.params.Globals.Appname,
|
||||||
@@ -85,7 +101,8 @@ func (l *Logger) Identify() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods to maintain compatibility with existing code
|
// Writer returns an io.Writer suitable for standard library
|
||||||
|
// loggers.
|
||||||
func (l *Logger) Writer() io.Writer {
|
func (l *Logger) Writer() io.Writer {
|
||||||
return os.Stdout
|
return os.Stdout
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,59 @@
|
|||||||
package logger
|
package logger_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"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) {
|
func TestNew(t *testing.T) {
|
||||||
// Set up globals
|
t.Parallel()
|
||||||
globals.Appname = "test-app"
|
|
||||||
globals.Version = "1.0.0"
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
g, err := globals.New(lc)
|
|
||||||
if err != nil {
|
params := logger.LoggerParams{
|
||||||
t.Fatalf("globals.New() error = %v", err)
|
Globals: testGlobals(),
|
||||||
}
|
}
|
||||||
|
|
||||||
params := LoggerParams{
|
l, err := logger.New(lc, params)
|
||||||
Globals: g,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger, err := New(lc, params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if logger.Get() == nil {
|
if l.Get() == nil {
|
||||||
t.Error("Get() returned nil logger")
|
t.Error("Get() returned nil logger")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that we can log without panic
|
// 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) {
|
func TestEnableDebugLogging(t *testing.T) {
|
||||||
// Set up globals
|
t.Parallel()
|
||||||
globals.Appname = "test-app"
|
|
||||||
globals.Version = "1.0.0"
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
lc := fxtest.NewLifecycle(t)
|
||||||
g, err := globals.New(lc)
|
|
||||||
if err != nil {
|
params := logger.LoggerParams{
|
||||||
t.Fatalf("globals.New() error = %v", err)
|
Globals: testGlobals(),
|
||||||
}
|
}
|
||||||
|
|
||||||
params := LoggerParams{
|
l, err := logger.New(lc, params)
|
||||||
Globals: g,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger, err := New(lc, params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable debug logging should not panic
|
// Enable debug logging should not panic
|
||||||
logger.EnableDebugLogging()
|
l.EnableDebugLogging()
|
||||||
|
|
||||||
// Test debug logging
|
// Test debug logging
|
||||||
logger.Get().Debug("debug message", "test", true)
|
l.Get().Debug("debug message", "test", true)
|
||||||
}
|
}
|
||||||
|
|||||||
84
internal/middleware/csrf.go
Normal file
84
internal/middleware/csrf.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSRFToken retrieves the CSRF token from the request context.
|
||||||
|
// Returns an empty string if the gorilla/csrf middleware has not run.
|
||||||
|
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
|
||||||
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||||
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
|
// token receive a 403 Forbidden response.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m.log.Warn("csrf: token validation failed",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"remote_addr", r.RemoteAddr,
|
||||||
|
"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))...)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
tlsCSRF := tlsProtect(next)
|
||||||
|
httpCSRF := httpProtect(next)
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
494
internal/middleware/csrf_test.go
Normal file
494
internal/middleware/csrf_test.go
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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 = 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRF_POSTWithValidToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
|
getReq := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet, "/form", nil,
|
||||||
|
)
|
||||||
|
token, cookies := csrfGetToken(t, csrfMW, getReq)
|
||||||
|
|
||||||
|
postReq := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost, "/form", nil,
|
||||||
|
)
|
||||||
|
called, _ := csrfPostWithToken(
|
||||||
|
t, csrfMW, postReq, token, cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.True(
|
||||||
|
t, called,
|
||||||
|
"handler should be called with valid CSRF token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
m, _ := testMiddleware(t, env)
|
||||||
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
|
// GET to establish the CSRF cookie
|
||||||
|
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 := 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, 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)
|
||||||
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
|
// GET to establish the CSRF cookie
|
||||||
|
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 := csrfMW(http.HandlerFunc(
|
||||||
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
called = true
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
form := url.Values{"csrf_token": {"invalid-token-value"}}
|
||||||
|
|
||||||
|
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.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
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFToken_NoMiddleware(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
34
internal/middleware/export_test.go
Normal file
34
internal/middleware/export_test.go
Normal 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
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package middleware provides HTTP middleware for logging, auth,
|
||||||
|
// CORS, and metrics.
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,26 +21,42 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // MiddlewareParams is a standard fx naming convention
|
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 {
|
type MiddlewareParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Session *session.Session
|
Session *session.Session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
||||||
|
// metrics.
|
||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *MiddlewareParams
|
params *MiddlewareParams
|
||||||
session *session.Session
|
session *session.Session
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
// 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) {
|
||||||
s := new(Middleware)
|
s := new(Middleware)
|
||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
s.session = params.Session
|
s.session = params.Session
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,19 +68,24 @@ func ipFromHostPort(hp string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(h) > 0 && h[0] == '[' {
|
if len(h) > 0 && h[0] == '[' {
|
||||||
return h[1 : len(h)-1]
|
return h[1 : len(h)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
|
|
||||||
statusCode int
|
statusCode int
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:revive // unexported type is only used internally
|
// newLoggingResponseWriter wraps w and records status codes.
|
||||||
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
|
func newLoggingResponseWriter(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
) *loggingResponseWriter {
|
||||||
return &loggingResponseWriter{w, http.StatusOK}
|
return &loggingResponseWriter{w, http.StatusOK}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,23 +94,30 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
lrw.ResponseWriter.WriteHeader(code)
|
lrw.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// type Middleware func(http.Handler) http.Handler
|
// Logging returns middleware that logs each HTTP request with
|
||||||
// this returns a Middleware that is designed to do every request through the
|
// timing and metadata.
|
||||||
// mux, note the signature:
|
|
||||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||||
return func(next 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()
|
start := time.Now()
|
||||||
lrw := NewLoggingResponseWriter(w)
|
lrw := newLoggingResponseWriter(w)
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
requestID := ""
|
requestID := ""
|
||||||
if reqID := ctx.Value(middleware.RequestIDKey); reqID != nil {
|
|
||||||
|
if reqID := ctx.Value(
|
||||||
|
middleware.RequestIDKey,
|
||||||
|
); reqID != nil {
|
||||||
if id, ok := reqID.(string); ok {
|
if id, ok := reqID.(string); ok {
|
||||||
requestID = id
|
requestID = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("http request",
|
s.log.Info("http request",
|
||||||
"request_start", start,
|
"request_start", start,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
@@ -107,20 +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).
|
||||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
if s.params.Config.IsDev() {
|
if s.params.Config.IsDev() {
|
||||||
// In development, allow any origin for local testing.
|
// In development, allow any origin for local testing.
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||||
|
},
|
||||||
|
AllowedHeaders: []string{
|
||||||
|
"Accept", "Authorization",
|
||||||
|
"Content-Type", "X-CSRF-Token",
|
||||||
|
},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
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 func(next http.Handler) http.Handler {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
@@ -130,20 +169,33 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
// Unauthenticated users are redirected to the login page.
|
// Unauthenticated users are redirected to the login page.
|
||||||
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||||
return func(next 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)
|
sess, err := s.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Debug("auth middleware: failed to get session", "error", err)
|
s.log.Debug(
|
||||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
"auth middleware: failed to get session",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.session.IsAuthenticated(sess) {
|
if !s.session.IsAuthenticated(sess) {
|
||||||
s.log.Debug("auth middleware: unauthenticated request",
|
s.log.Debug(
|
||||||
|
"auth middleware: unauthenticated request",
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
)
|
)
|
||||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,15 +204,19 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metrics returns middleware that records Prometheus HTTP metrics.
|
||||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
mdlw := ghmm.New(ghmm.Config{
|
mdlw := ghmm.New(ghmm.Config{
|
||||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
Recorder: metrics.NewRecorder(metrics.Config{}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return std.Handler("", mdlw, next)
|
return std.Handler("", mdlw, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetricsAuth returns middleware that protects metrics endpoints
|
||||||
|
// with basic auth.
|
||||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
return basicauth.New(
|
return basicauth.New(
|
||||||
"metrics",
|
"metrics",
|
||||||
@@ -172,33 +228,83 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeaders returns middleware that sets production security headers
|
// SecurityHeaders returns middleware that sets production security
|
||||||
// on every response: HSTS, X-Content-Type-Options, X-Frame-Options, CSP,
|
// headers on every response: HSTS, X-Content-Type-Options,
|
||||||
// Referrer-Policy, and Permissions-Policy.
|
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
|
||||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
return func(next 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.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
|
w http.ResponseWriter,
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
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("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(
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
"Content-Security-Policy",
|
||||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
"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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxBodySize returns middleware that limits the request body size for POST
|
// NoCache returns middleware that instructs browsers and
|
||||||
// requests. If the body exceeds the given limit in bytes, the server returns
|
// intermediary proxies not to cache the response. It sets
|
||||||
// 413 Request Entity Too Large. This prevents clients from sending arbitrarily
|
// Cache-Control: no-store and Pragma: no-cache (the latter for
|
||||||
// large form bodies.
|
// older HTTP/1.0 intermediaries). Apply it to authenticated pages
|
||||||
func (s *Middleware) MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
|
// so webhook configuration and captured event data are not stored
|
||||||
|
// by caches.
|
||||||
|
func (s *Middleware) NoCache() func(http.Handler) http.Handler {
|
||||||
return func(next 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(
|
||||||
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch {
|
w http.ResponseWriter,
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
r *http.Request,
|
||||||
}
|
) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,25 +13,37 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMiddleware creates a Middleware with minimal dependencies for testing.
|
const testKeySize = 32
|
||||||
// It uses a real session.Session backed by an in-memory cookie store.
|
|
||||||
func testMiddleware(t *testing.T, env string) (*Middleware, *session.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.Middleware, *session.Session) {
|
||||||
t.Helper()
|
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{
|
cfg := &config.Config{
|
||||||
Environment: env,
|
Environment: env,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a real session manager with a known key
|
// Create a real session manager with a known key
|
||||||
key := make([]byte, 32)
|
key := make([]byte, testKeySize)
|
||||||
|
|
||||||
for i := range key {
|
for i := range key {
|
||||||
key[i] = byte(i)
|
key[i] = byte(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := sessions.NewCookieStore(key)
|
store := sessions.NewCookieStore(key)
|
||||||
store.Options = &sessions.Options{
|
store.Options = &sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
@@ -40,40 +53,33 @@ func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
sessManager := newTestSession(t, store, cfg, log)
|
sessManager := session.NewForTest(store, cfg, log, key)
|
||||||
|
|
||||||
m := &Middleware{
|
m := middleware.NewForTest(log, cfg, sessManager)
|
||||||
log: log,
|
|
||||||
params: &MiddlewareParams{
|
|
||||||
Config: cfg,
|
|
||||||
},
|
|
||||||
session: sessManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, 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) *session.Session {
|
|
||||||
t.Helper()
|
|
||||||
return session.NewForTest(store, cfg, log)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Logging Middleware Tests ---
|
// --- Logging Middleware Tests ---
|
||||||
|
|
||||||
func TestLogging_SetsStatusCode(t *testing.T) {
|
func TestLogging_SetsStatusCode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
if _, err := w.Write([]byte("created")); err != nil {
|
|
||||||
|
_, err := w.Write([]byte("created"))
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}))
|
},
|
||||||
|
))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/test", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
@@ -84,15 +90,20 @@ func TestLogging_SetsStatusCode(t *testing.T) {
|
|||||||
|
|
||||||
func TestLogging_DefaultStatusOK(t *testing.T) {
|
func TestLogging_DefaultStatusOK(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
handler := m.Logging()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
handler := m.Logging()(http.HandlerFunc(
|
||||||
if _, err := w.Write([]byte("ok")); err != nil {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, err := w.Write([]byte("ok"))
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}))
|
},
|
||||||
|
))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
@@ -103,20 +114,31 @@ func TestLogging_DefaultStatusOK(t *testing.T) {
|
|||||||
|
|
||||||
func TestLogging_PassesThroughToNext(t *testing.T) {
|
func TestLogging_PassesThroughToNext(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
var called bool
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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 ---
|
// --- LoggingResponseWriter Tests ---
|
||||||
@@ -125,24 +147,33 @@ func TestLoggingResponseWriter_CapturesStatusCode(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
lrw := NewLoggingResponseWriter(w)
|
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
||||||
|
|
||||||
// Default should be 200
|
// 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
|
// WriteHeader should capture the status code
|
||||||
lrw.WriteHeader(http.StatusNotFound)
|
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
|
// Underlying writer should also get the status code
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(t *testing.T) {
|
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
lrw := NewLoggingResponseWriter(w)
|
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
||||||
|
|
||||||
n, err := lrw.Write([]byte("hello world"))
|
n, err := lrw.Write([]byte("hello world"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -154,79 +185,124 @@ func TestLoggingResponseWriter_WriteDelegatesToUnderlying(t *testing.T) {
|
|||||||
|
|
||||||
func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) {
|
func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
},
|
||||||
|
))
|
||||||
|
|
||||||
// Preflight request
|
// 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("Origin", "http://localhost:3000")
|
||||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
// In dev mode, CORS should allow any origin
|
// 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) {
|
func TestCORS_ProdMode_NoOp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
|
|
||||||
var called bool
|
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")
|
req.Header.Set("Origin", "http://evil.com")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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)
|
// In prod, no CORS headers should be set (no-op middleware)
|
||||||
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"),
|
assert.Empty(
|
||||||
"prod mode should not set CORS headers")
|
t,
|
||||||
|
w.Header().Get("Access-Control-Allow-Origin"),
|
||||||
|
"prod mode should not set CORS headers",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RequireAuth Middleware Tests ---
|
// --- RequireAuth Middleware Tests ---
|
||||||
|
|
||||||
func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
var called bool
|
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()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
var called bool
|
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,
|
handler := m.RequireAuth()(http.HandlerFunc(
|
||||||
// and saving the session cookie
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
setupReq := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
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()
|
setupW := httptest.NewRecorder()
|
||||||
|
|
||||||
sess, err := sessManager.Get(setupReq)
|
sess, err := sessManager.Get(setupReq)
|
||||||
@@ -239,51 +315,117 @@ func TestRequireAuth_AuthenticatedSession_PassesThrough(t *testing.T) {
|
|||||||
require.NotEmpty(t, cookies, "session cookie should be set")
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
||||||
|
|
||||||
// Make the actual request with the session cookie
|
// 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 {
|
for _, c := range cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
var called bool
|
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
|
called = true
|
||||||
}))
|
},
|
||||||
|
))
|
||||||
|
|
||||||
// Create a session but don't authenticate it
|
// 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()
|
setupW := httptest.NewRecorder()
|
||||||
|
|
||||||
sess, err := sessManager.Get(setupReq)
|
sess, err := sessManager.Get(setupReq)
|
||||||
require.NoError(t, err)
|
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))
|
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
||||||
|
|
||||||
cookies := setupW.Result().Cookies()
|
cookies := setupW.Result().Cookies()
|
||||||
require.NotEmpty(t, 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 {
|
for _, c := range cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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, http.StatusSeeOther, w.Code)
|
||||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NoCache Middleware Tests ---
|
||||||
|
|
||||||
|
func TestNoCache_SetsHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
|
||||||
|
handler := m.NoCache()(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
called = true
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet, "/sources", nil,
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.True(
|
||||||
|
t, called,
|
||||||
|
"NoCache middleware should call the next handler",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "no-store",
|
||||||
|
w.Header().Get("Cache-Control"),
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "no-cache",
|
||||||
|
w.Header().Get("Pragma"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helper Tests ---
|
// --- Helper Tests ---
|
||||||
|
|
||||||
func TestIpFromHostPort(t *testing.T) {
|
func TestIpFromHostPort(t *testing.T) {
|
||||||
@@ -304,7 +446,9 @@ func TestIpFromHostPort(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
result := ipFromHostPort(tt.input)
|
|
||||||
|
result := middleware.IPFromHostPort(tt.input)
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, result)
|
assert.Equal(t, tt.expected, result)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -312,122 +456,124 @@ func TestIpFromHostPort(t *testing.T) {
|
|||||||
|
|
||||||
// --- MetricsAuth Tests ---
|
// --- MetricsAuth Tests ---
|
||||||
|
|
||||||
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
// metricsAuthMiddleware creates a Middleware configured for
|
||||||
t.Parallel()
|
// 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{
|
cfg := &config.Config{
|
||||||
Environment: config.EnvironmentDev,
|
Environment: config.EnvironmentDev,
|
||||||
MetricsUsername: "admin",
|
MetricsUsername: "admin",
|
||||||
MetricsPassword: "secret",
|
MetricsPassword: "secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
key := make([]byte, 32)
|
key := make([]byte, testKeySize)
|
||||||
store := sessions.NewCookieStore(key)
|
store := sessions.NewCookieStore(key)
|
||||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
||||||
|
|
||||||
sessManager := session.NewForTest(store, cfg, log)
|
sessManager := session.NewForTest(store, cfg, log, key)
|
||||||
|
|
||||||
m := &Middleware{
|
return middleware.NewForTest(log, cfg, sessManager)
|
||||||
log: log,
|
}
|
||||||
params: &MiddlewareParams{
|
|
||||||
Config: cfg,
|
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
||||||
},
|
t.Parallel()
|
||||||
session: sessManager,
|
|
||||||
}
|
m := metricsAuthMiddleware(t)
|
||||||
|
|
||||||
var called bool
|
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")
|
req.SetBasicAuth("admin", "secret")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
m := metricsAuthMiddleware(t)
|
||||||
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)
|
|
||||||
|
|
||||||
m := &Middleware{
|
|
||||||
log: log,
|
|
||||||
params: &MiddlewareParams{
|
|
||||||
Config: cfg,
|
|
||||||
},
|
|
||||||
session: sessManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
var called bool
|
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")
|
req.SetBasicAuth("admin", "wrong-password")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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)
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsAuth_NoCredentials(t *testing.T) {
|
func TestMetricsAuth_NoCredentials(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
m := metricsAuthMiddleware(t)
|
||||||
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)
|
|
||||||
|
|
||||||
m := &Middleware{
|
|
||||||
log: log,
|
|
||||||
params: &MiddlewareParams{
|
|
||||||
Config: cfg,
|
|
||||||
},
|
|
||||||
session: sessManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
handler := m.MetricsAuth()(http.HandlerFunc(func(w 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
|
// No basic auth header
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
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)
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,16 +581,23 @@ func TestMetricsAuth_NoCredentials(t *testing.T) {
|
|||||||
|
|
||||||
func TestCORS_DevMode_AllowsMethods(t *testing.T) {
|
func TestCORS_DevMode_AllowsMethods(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
},
|
||||||
|
))
|
||||||
|
|
||||||
// Preflight for POST
|
// 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("Origin", "http://localhost:5173")
|
||||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
handler.ServeHTTP(w, req)
|
||||||
@@ -458,14 +611,17 @@ func TestCORS_DevMode_AllowsMethods(t *testing.T) {
|
|||||||
func TestSessionKeyFormat(t *testing.T) {
|
func TestSessionKeyFormat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Verify that the session initialization correctly validates key format.
|
// Verify that the session initialization correctly validates
|
||||||
// A proper 32-byte key encoded as base64 should work.
|
// key format. A proper 32-byte key encoded as base64 should
|
||||||
key := make([]byte, 32)
|
// work.
|
||||||
|
key := make([]byte, testKeySize)
|
||||||
|
|
||||||
for i := range key {
|
for i := range key {
|
||||||
key[i] = byte(i + 1)
|
key[i] = byte(i + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(key)
|
encoded := base64.StdEncoding.EncodeToString(key)
|
||||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, decoded, 32)
|
assert.Len(t, decoded, testKeySize)
|
||||||
}
|
}
|
||||||
|
|||||||
103
internal/middleware/ratelimit.go
Normal file
103
internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// receiverRateInterval is the time window for the webhook
|
||||||
|
// receiver rate limit. The configured limit is expressed in
|
||||||
|
// requests per minute.
|
||||||
|
receiverRateInterval = 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.
|
||||||
|
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) {
|
||||||
|
m.log.Warn("login rate limit exceeded",
|
||||||
|
"path", r.URL.Path,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limited.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiverRateLimit returns middleware that rate-limits the
|
||||||
|
// public webhook receiver endpoint per client IP per request
|
||||||
|
// path (the path contains the entrypoint UUID, so each sender
|
||||||
|
// is limited per entrypoint without affecting other senders or
|
||||||
|
// other entrypoints). The limit is Config.ReceiverRateLimit
|
||||||
|
// requests per minute. Requests over the limit receive a 429;
|
||||||
|
// httprate adds the Retry-After header (RFC 6585). IP
|
||||||
|
// extraction honours X-Forwarded-For, X-Real-IP, and
|
||||||
|
// True-Client-IP headers for reverse-proxy setups.
|
||||||
|
func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
|
||||||
|
return httprate.Limit(
|
||||||
|
m.params.Config.ReceiverRateLimit,
|
||||||
|
receiverRateInterval,
|
||||||
|
httprate.WithKeyFuncs(
|
||||||
|
httprate.KeyByRealIP,
|
||||||
|
httprate.KeyByEndpoint,
|
||||||
|
),
|
||||||
|
httprate.WithLimitHandler(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m.log.Warn(
|
||||||
|
"webhook receiver rate limit exceeded",
|
||||||
|
"path", r.URL.Path,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
"Too many requests. "+
|
||||||
|
"Please slow down.",
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
240
internal/middleware/ratelimit_test.go
Normal file
240
internal/middleware/ratelimit_test.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"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) {
|
||||||
|
callCount++
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
// GET requests should never be rate-limited
|
||||||
|
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, 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) {
|
||||||
|
callCount++
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
// First loginRateLimit POST requests should succeed
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next POST should be rate-limited
|
||||||
|
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, 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) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
// Exhaust limit for IP1
|
||||||
|
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.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.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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiverLimitedHandler builds a ReceiverRateLimit-wrapped
|
||||||
|
// handler with the given per-minute limit.
|
||||||
|
func receiverLimitedHandler(
|
||||||
|
t *testing.T, limit int,
|
||||||
|
) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
))
|
||||||
|
|
||||||
|
m := middleware.NewForTest(
|
||||||
|
log,
|
||||||
|
&config.Config{ReceiverRateLimit: limit},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
return m.ReceiverRateLimit()(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiverPost sends one POST to the handler from the given IP
|
||||||
|
// and path and returns the recorder.
|
||||||
|
func receiverPost(
|
||||||
|
handler http.Handler, ip, path string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost, path, nil,
|
||||||
|
)
|
||||||
|
req.RemoteAddr = ip
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 3
|
||||||
|
|
||||||
|
handler := receiverLimitedHandler(t, limit)
|
||||||
|
|
||||||
|
// The first limit requests from one IP to one entrypoint
|
||||||
|
// pass.
|
||||||
|
for i := range limit {
|
||||||
|
w := receiverPost(
|
||||||
|
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusOK, w.Code,
|
||||||
|
"request %d should pass", i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request over the limit is rejected with a 429
|
||||||
|
// carrying a Retry-After header.
|
||||||
|
w := receiverPost(
|
||||||
|
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||||
|
assert.NotEmpty(
|
||||||
|
t, w.Header().Get("Retry-After"),
|
||||||
|
"429 must carry a Retry-After header",
|
||||||
|
)
|
||||||
|
|
||||||
|
// The same IP is not limited on a different entrypoint.
|
||||||
|
w = receiverPost(
|
||||||
|
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusOK, w.Code,
|
||||||
|
"a different entrypoint must not be affected",
|
||||||
|
)
|
||||||
|
|
||||||
|
// A different IP is not limited on the same entrypoint.
|
||||||
|
w = receiverPost(
|
||||||
|
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, http.StatusOK, w.Code,
|
||||||
|
"a different client IP must not be affected",
|
||||||
|
)
|
||||||
|
}
|
||||||
24
internal/middleware/testing.go
Normal file
24
internal/middleware/testing.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"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() {
|
func (s *Server) serveUntilShutdown() {
|
||||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||||
s.httpServer = &http.Server{
|
s.httpServer = &http.Server{
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: httpReadTimeout,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: httpWriteTimeout,
|
||||||
MaxHeaderBytes: 1 << 20,
|
MaxHeaderBytes: httpMaxHeaderBytes,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,14 +39,21 @@ func (s *Server) serveUntilShutdown() {
|
|||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|
||||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
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)
|
s.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
if s.cancelFunc != nil {
|
||||||
s.cancelFunc()
|
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)
|
s.router.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,24 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/static"
|
"sneak.berlin/go/webhooker/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxFormBodySize is the maximum allowed request body size (in bytes) for
|
// maxFormBodySize is the maximum allowed request body size (in
|
||||||
// form POST endpoints. 1 MB is generous for any form submission while
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB
|
const maxFormBodySize int64 = 1 * 1024 * 1024 // 1 MB
|
||||||
|
|
||||||
|
// 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() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
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.Recoverer)
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
@@ -31,24 +40,28 @@ func (s *Server) SetupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.router.Use(s.mw.CORS())
|
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
|
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||||
// so panics still bubble up to the Recoverer middleware above.
|
// true so panics still bubble up to the Recoverer middleware.
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
Repanic: true,
|
Repanic: true,
|
||||||
})
|
})
|
||||||
s.router.Use(sentryHandler.Handle)
|
s.router.Use(sentryHandler.Handle)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Routes
|
func (s *Server) setupRoutes() {
|
||||||
s.router.Get("/", s.h.HandleIndex())
|
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) {
|
s.router.Route("/api/v1", func(_ chi.Router) {
|
||||||
// TODO: Add API routes here
|
// API routes will be added here.
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Get(
|
s.router.Get(
|
||||||
@@ -60,54 +73,94 @@ func (s *Server) SetupRoutes() {
|
|||||||
if s.params.Config.MetricsUsername != "" {
|
if s.params.Config.MetricsUsername != "" {
|
||||||
s.router.Group(func(r chi.Router) {
|
s.router.Group(func(r chi.Router) {
|
||||||
r.Use(s.mw.MetricsAuth())
|
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
|
s.setupPageRoutes()
|
||||||
|
s.setupUserRoutes()
|
||||||
|
s.setupSourceRoutes()
|
||||||
|
s.setupWebhookRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setupPageRoutes() {
|
||||||
s.router.Route("/pages", func(r chi.Router) {
|
s.router.Route("/pages", func(r chi.Router) {
|
||||||
|
r.Use(s.mw.CSRF())
|
||||||
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
|
|
||||||
// Login page (no auth required)
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.mw.LoginRateLimit())
|
||||||
r.Get("/login", s.h.HandleLoginPage())
|
r.Get("/login", s.h.HandleLoginPage())
|
||||||
r.Post("/login", s.h.HandleLoginSubmit())
|
r.Post("/login", s.h.HandleLoginSubmit())
|
||||||
|
})
|
||||||
|
|
||||||
// Logout (auth required)
|
|
||||||
r.Post("/logout", s.h.HandleLogout())
|
r.Post("/logout", s.h.HandleLogout())
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// User profile routes
|
func (s *Server) setupUserRoutes() {
|
||||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||||
|
r.Use(s.mw.CSRF())
|
||||||
|
r.Use(s.mw.NoCache())
|
||||||
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Webhook management routes (require authentication)
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/sources", func(r chi.Router) {
|
||||||
|
r.Use(s.mw.CSRF())
|
||||||
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Get("/", s.h.HandleSourceList()) // List all webhooks
|
r.Get("/", s.h.HandleSourceList())
|
||||||
r.Get("/new", s.h.HandleSourceCreate()) // Show create form
|
r.Get("/new", s.h.HandleSourceCreate())
|
||||||
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||||
|
r.Use(s.mw.CSRF())
|
||||||
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Get("/", s.h.HandleSourceDetail()) // View webhook details
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
|
r.Get("/edit", s.h.HandleSourceEdit())
|
||||||
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
|
r.Post("/delete", s.h.HandleSourceDelete())
|
||||||
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
|
r.Get("/logs", s.h.HandleSourceLogs())
|
||||||
r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint
|
r.Post(
|
||||||
r.Post("/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete()) // Delete entrypoint
|
"/entrypoints",
|
||||||
r.Post("/entrypoints/{entrypointID}/toggle", s.h.HandleEntrypointToggle()) // Toggle entrypoint active
|
s.h.HandleEntrypointCreate(),
|
||||||
r.Post("/targets", s.h.HandleTargetCreate()) // Add target
|
)
|
||||||
r.Post("/targets/{targetID}/delete", s.h.HandleTargetDelete()) // Delete target
|
r.Post(
|
||||||
r.Post("/targets/{targetID}/toggle", s.h.HandleTargetToggle()) // Toggle target active
|
"/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
|
func (s *Server) setupWebhookRoutes() {
|
||||||
// methods (chi's Method routing returns 405 without Allow header).
|
s.router.With(s.mw.ReceiverRateLimit()).HandleFunc(
|
||||||
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
|
"/webhook/{uuid}",
|
||||||
|
s.h.HandleWebhook(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package server wires up HTTP routes and manages the
|
||||||
|
// application lifecycle.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,9 +23,20 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// nolint:revive // ServerParams is a standard fx naming convention
|
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 {
|
type ServerParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
@@ -31,12 +44,13 @@ type ServerParams struct {
|
|||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server is the main HTTP server that wires up routes and manages
|
||||||
|
// graceful shutdown.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
exitCode int
|
||||||
sentryEnabled bool
|
sentryEnabled bool
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
ctx context.Context
|
|
||||||
cancelFunc context.CancelFunc
|
cancelFunc context.CancelFunc
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
@@ -45,6 +59,8 @@ type Server struct {
|
|||||||
h *handlers.Handlers
|
h *handlers.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
||||||
s := new(Server)
|
s := new(Server)
|
||||||
s.params = params
|
s.params = params
|
||||||
@@ -53,19 +69,23 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
|||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
s.startupTime = time.Now()
|
s.startupTime = time.Now()
|
||||||
go s.Run()
|
go s.Run()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
s.cleanShutdown()
|
s.cleanShutdown(ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run configures Sentry and starts serving HTTP requests.
|
||||||
func (s *Server) Run() {
|
func (s *Server) Run() {
|
||||||
s.configure()
|
s.configure()
|
||||||
|
|
||||||
@@ -75,6 +95,12 @@ func (s *Server) Run() {
|
|||||||
s.serve()
|
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() {
|
func (s *Server) enableSentry() {
|
||||||
s.sentryEnabled = false
|
s.sentryEnabled = false
|
||||||
|
|
||||||
@@ -84,28 +110,36 @@ func (s *Server) enableSentry() {
|
|||||||
|
|
||||||
err := sentry.Init(sentry.ClientOptions{
|
err := sentry.Init(sentry.ClientOptions{
|
||||||
Dsn: s.params.Config.SentryDSN,
|
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 {
|
if err != nil {
|
||||||
s.log.Error("sentry init failure", "error", err)
|
s.log.Error("sentry init failure", "error", err)
|
||||||
// Don't use fatal since we still want the service to run
|
// Don't use fatal since we still want the service to run
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("sentry error reporting activated")
|
s.log.Info("sentry error reporting activated")
|
||||||
s.sentryEnabled = true
|
s.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serve() int {
|
func (s *Server) serve() int {
|
||||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||||
|
s.cancelFunc = cancelFunc
|
||||||
|
|
||||||
// signal watcher
|
// signal watcher
|
||||||
go func() {
|
go func() {
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
|
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
// block and wait for signal
|
// block and wait for signal
|
||||||
sig := <-c
|
sig := <-c
|
||||||
s.log.Info("signal received", "signal", sig.String())
|
s.log.Info("signal received", "signal", sig.String())
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
if s.cancelFunc != nil {
|
||||||
// cancelling the main context will trigger a clean
|
// cancelling the main context will trigger a clean
|
||||||
// shutdown via the fx OnStop hook.
|
// shutdown via the fx OnStop hook.
|
||||||
@@ -115,9 +149,9 @@ func (s *Server) serve() int {
|
|||||||
|
|
||||||
go s.serveUntilShutdown()
|
go s.serveUntilShutdown()
|
||||||
|
|
||||||
<-s.ctx.Done()
|
<-ctx.Done()
|
||||||
// Shutdown is handled by the fx OnStop hook (cleanShutdown).
|
// 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
|
return s.exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,27 +159,29 @@ func (s *Server) cleanupForExit() {
|
|||||||
s.log.Info("cleaning up")
|
s.log.Info("cleaning up")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) cleanShutdown() {
|
func (s *Server) cleanShutdown(ctx context.Context) {
|
||||||
// initiate clean shutdown
|
// initiate clean shutdown
|
||||||
s.exitCode = 0
|
s.exitCode = 0
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
|
ctx, shutdownTimeout,
|
||||||
|
)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
err := s.httpServer.Shutdown(ctxShutdown)
|
||||||
s.log.Error("server clean shutdown failed", "error", err)
|
if err != nil {
|
||||||
|
s.log.Error(
|
||||||
|
"server clean shutdown failed", "error", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.cleanupForExit()
|
s.cleanupForExit()
|
||||||
|
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
sentry.Flush(2 * time.Second)
|
sentry.Flush(sentryFlushTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) MaintenanceMode() bool {
|
|
||||||
return s.params.Config.MaintenanceMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) configure() {
|
func (s *Server) configure() {
|
||||||
// identify ourselves in the logs
|
// identify ourselves in the logs
|
||||||
s.params.Logger.Identify()
|
s.params.Logger.Identify()
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
// Package session manages HTTP session storage and authentication
|
||||||
|
// state.
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
@@ -15,57 +19,89 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// SessionName is the name of the session cookie
|
// SessionName is the name of the session cookie.
|
||||||
SessionName = "webhooker_session"
|
SessionName = "webhooker_session"
|
||||||
|
|
||||||
// UserIDKey is the session key for user ID
|
// UserIDKey is the session key for user ID.
|
||||||
UserIDKey = "user_id"
|
UserIDKey = "user_id"
|
||||||
|
|
||||||
// UsernameKey is the session key for username
|
// UsernameKey is the session key for username.
|
||||||
UsernameKey = "username"
|
UsernameKey = "username"
|
||||||
|
|
||||||
// AuthenticatedKey is the session key for authentication status
|
// AuthenticatedKey is the session key for authentication
|
||||||
|
// status.
|
||||||
AuthenticatedKey = "authenticated"
|
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
|
// ErrSessionKeyLength is returned when the decoded session key
|
||||||
type SessionParams struct {
|
// 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
|
fx.In
|
||||||
|
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session manages encrypted session storage
|
// Session manages encrypted session storage.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
store *sessions.CookieStore
|
store *sessions.CookieStore
|
||||||
|
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
config *config.Config
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new session manager. The cookie store is initialized
|
// New creates a new session manager. The cookie store is
|
||||||
// during the fx OnStart phase after the database is connected, using
|
// initialized during the fx OnStart phase after the database is
|
||||||
// a session key that is auto-generated and stored in the database.
|
// connected, using a session key that is auto-generated and stored
|
||||||
func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
|
// in the database.
|
||||||
|
func New(
|
||||||
|
lc fx.Lifecycle,
|
||||||
|
params Params,
|
||||||
|
) (*Session, error) {
|
||||||
s := &Session{
|
s := &Session{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
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()
|
sessionKey, err := params.Database.GetOrCreateSessionKey()
|
||||||
if err != nil {
|
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 {
|
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 {
|
if len(keyBytes) != sessionKeyLength {
|
||||||
return fmt.Errorf("session key must be 32 bytes (got %d)", len(keyBytes))
|
return fmt.Errorf(
|
||||||
|
"%w: want %d, got %d",
|
||||||
|
ErrSessionKeyLength,
|
||||||
|
sessionKeyLength,
|
||||||
|
len(keyBytes),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := sessions.NewCookieStore(keyBytes)
|
store := sessions.NewCookieStore(keyBytes)
|
||||||
@@ -73,14 +109,16 @@ func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
|
|||||||
// Configure cookie options for security
|
// Configure cookie options for security
|
||||||
store.Options = &sessions.Options{
|
store.Options = &sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: 86400 * 7, // 7 days
|
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: !params.Config.IsDev(), // HTTPS in production
|
Secure: !params.Config.IsDev(),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.key = keyBytes
|
||||||
s.store = store
|
s.store = store
|
||||||
s.log.Info("session manager initialized")
|
s.log.Info("session manager initialized")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -88,93 +126,126 @@ func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a session for the request
|
// Get retrieves a session for the request.
|
||||||
func (s *Session) Get(r *http.Request) (*sessions.Session, error) {
|
func (s *Session) Get(
|
||||||
|
r *http.Request,
|
||||||
|
) (*sessions.Session, error) {
|
||||||
return s.store.Get(r, SessionName)
|
return s.store.Get(r, SessionName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save saves the session
|
// GetKey returns the raw 32-byte authentication key used for
|
||||||
func (s *Session) Save(r *http.Request, w http.ResponseWriter, sess *sessions.Session) error {
|
// 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 {
|
||||||
return sess.Save(r, w)
|
return sess.Save(r, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetUser sets the user information in the session
|
// SetUser sets the user information in the session.
|
||||||
func (s *Session) SetUser(sess *sessions.Session, userID, username string) {
|
func (s *Session) SetUser(
|
||||||
|
sess *sessions.Session,
|
||||||
|
userID, username string,
|
||||||
|
) {
|
||||||
sess.Values[UserIDKey] = userID
|
sess.Values[UserIDKey] = userID
|
||||||
sess.Values[UsernameKey] = username
|
sess.Values[UsernameKey] = username
|
||||||
sess.Values[AuthenticatedKey] = true
|
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) {
|
func (s *Session) ClearUser(sess *sessions.Session) {
|
||||||
delete(sess.Values, UserIDKey)
|
delete(sess.Values, UserIDKey)
|
||||||
delete(sess.Values, UsernameKey)
|
delete(sess.Values, UsernameKey)
|
||||||
delete(sess.Values, AuthenticatedKey)
|
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 {
|
func (s *Session) IsAuthenticated(sess *sessions.Session) bool {
|
||||||
auth, ok := sess.Values[AuthenticatedKey].(bool)
|
auth, ok := sess.Values[AuthenticatedKey].(bool)
|
||||||
|
|
||||||
return ok && auth
|
return ok && auth
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserID retrieves the user ID from the session
|
// GetUserID retrieves the user ID from the session.
|
||||||
func (s *Session) GetUserID(sess *sessions.Session) (string, bool) {
|
func (s *Session) GetUserID(
|
||||||
|
sess *sessions.Session,
|
||||||
|
) (string, bool) {
|
||||||
userID, ok := sess.Values[UserIDKey].(string)
|
userID, ok := sess.Values[UserIDKey].(string)
|
||||||
|
|
||||||
return userID, ok
|
return userID, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUsername retrieves the username from the session
|
// GetUsername retrieves the username from the session.
|
||||||
func (s *Session) GetUsername(sess *sessions.Session) (string, bool) {
|
func (s *Session) GetUsername(
|
||||||
|
sess *sessions.Session,
|
||||||
|
) (string, bool) {
|
||||||
username, ok := sess.Values[UsernameKey].(string)
|
username, ok := sess.Values[UsernameKey].(string)
|
||||||
|
|
||||||
return username, ok
|
return username, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy invalidates the session
|
// Destroy invalidates the session.
|
||||||
func (s *Session) Destroy(sess *sessions.Session) {
|
func (s *Session) Destroy(sess *sessions.Session) {
|
||||||
sess.Options.MaxAge = -1
|
sess.Options.MaxAge = -1
|
||||||
s.ClearUser(sess)
|
s.ClearUser(sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate creates a new session with the same values but a fresh ID.
|
// Regenerate creates a new session with the same values but a
|
||||||
// The old session is destroyed (MaxAge = -1) and saved, then a new session
|
// fresh ID. The old session is destroyed (MaxAge = -1) and saved,
|
||||||
// is created. This prevents session fixation attacks by ensuring the
|
// then a new session is created. This prevents session fixation
|
||||||
// session ID changes after privilege escalation (e.g. login).
|
// attacks by ensuring the session ID changes after privilege
|
||||||
func (s *Session) Regenerate(r *http.Request, w http.ResponseWriter, oldSess *sessions.Session) (*sessions.Session, error) {
|
// 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
|
// Copy the values from the old session
|
||||||
oldValues := make(map[interface{}]interface{})
|
oldValues := make(map[any]any)
|
||||||
for k, v := range oldSess.Values {
|
maps.Copy(oldValues, oldSess.Values)
|
||||||
oldValues[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy the old session
|
// Destroy the old session
|
||||||
oldSess.Options.MaxAge = -1
|
oldSess.Options.MaxAge = -1
|
||||||
s.ClearUser(oldSess)
|
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)
|
// Create a new session (gorilla/sessions generates a new ID)
|
||||||
newSess, err := s.store.New(r, SessionName)
|
newSess, err := s.store.New(r, SessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// store.New may return an error alongside a new empty session
|
// store.New may return an error alongside a new empty
|
||||||
// if the old cookie is now invalid. That is expected after we
|
// session if the old cookie is now invalid. That is
|
||||||
// destroyed it above. Only fail on a nil session.
|
// expected after we destroyed it above. Only fail on a
|
||||||
|
// nil session.
|
||||||
if newSess == nil {
|
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
|
// Restore the copied values into the new session
|
||||||
for k, v := range oldValues {
|
maps.Copy(newSess.Values, oldValues)
|
||||||
newSess.Values[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the standard session options (the destroyed old session had
|
// Apply the standard session options (the destroyed old
|
||||||
// MaxAge = -1, which store.New might inherit from the cookie).
|
// session had MaxAge = -1, which store.New might inherit
|
||||||
|
// from the cookie).
|
||||||
newSess.Options = &sessions.Options{
|
newSess.Options = &sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: 86400 * 7,
|
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: !s.config.IsDev(),
|
Secure: !s.config.IsDev(),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package session
|
package session_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -11,15 +12,22 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testSession creates a Session with a real cookie store for testing.
|
const testKeySize = 32
|
||||||
func testSession(t *testing.T) *Session {
|
|
||||||
|
// testSession creates a Session with a real cookie store for
|
||||||
|
// testing.
|
||||||
|
func testSession(t *testing.T) *session.Session {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
key := make([]byte, 32)
|
|
||||||
|
key := make([]byte, testKeySize)
|
||||||
|
|
||||||
for i := range key {
|
for i := range key {
|
||||||
key[i] = byte(i + 42)
|
key[i] = byte(i + 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := sessions.NewCookieStore(key)
|
store := sessions.NewCookieStore(key)
|
||||||
store.Options = &sessions.Options{
|
store.Options = &sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
@@ -32,34 +40,47 @@ func testSession(t *testing.T) *Session {
|
|||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Environment: config.EnvironmentDev,
|
Environment: config.EnvironmentDev,
|
||||||
}
|
}
|
||||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
||||||
|
|
||||||
return NewForTest(store, cfg, log)
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
))
|
||||||
|
|
||||||
|
return session.NewForTest(store, cfg, log, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Get and Save Tests ---
|
// --- Get and Save Tests ---
|
||||||
|
|
||||||
func TestGet_NewSession(t *testing.T) {
|
func TestGet_NewSession(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, sess)
|
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) {
|
func TestGet_ExistingSession(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
// Create and save a session
|
// Create and save a session
|
||||||
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
req1 := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
w1 := httptest.NewRecorder()
|
w1 := httptest.NewRecorder()
|
||||||
|
|
||||||
sess1, err := s.Get(req1)
|
sess1, err := s.Get(req1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sess1.Values["test_key"] = "test_value"
|
sess1.Values["test_key"] = "test_value"
|
||||||
require.NoError(t, s.Save(req1, w1, sess1))
|
require.NoError(t, s.Save(req1, w1, sess1))
|
||||||
|
|
||||||
@@ -68,26 +89,34 @@ func TestGet_ExistingSession(t *testing.T) {
|
|||||||
require.NotEmpty(t, cookies)
|
require.NotEmpty(t, cookies)
|
||||||
|
|
||||||
// Make a new request with the session cookie
|
// 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 {
|
for _, c := range cookies {
|
||||||
req2.AddCookie(c)
|
req2.AddCookie(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess2, err := s.Get(req2)
|
sess2, err := s.Get(req2)
|
||||||
require.NoError(t, err)
|
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"])
|
assert.Equal(t, "test_value", sess2.Values["test_key"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSave_SetsCookie(t *testing.T) {
|
func TestSave_SetsCookie(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
sess.Values["key"] = "value"
|
sess.Values["key"] = "value"
|
||||||
|
|
||||||
err = s.Save(req, w, sess)
|
err = s.Save(req, w, sess)
|
||||||
@@ -98,48 +127,73 @@ func TestSave_SetsCookie(t *testing.T) {
|
|||||||
|
|
||||||
// Verify the cookie has the expected name
|
// Verify the cookie has the expected name
|
||||||
var found bool
|
var found bool
|
||||||
|
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
if c.Name == SessionName {
|
if c.Name == session.SessionName {
|
||||||
found = true
|
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
|
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 ---
|
// --- SetUser and User Retrieval Tests ---
|
||||||
|
|
||||||
func TestSetUser_SetsAllFields(t *testing.T) {
|
func TestSetUser_SetsAllFields(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
s.SetUser(sess, "user-abc-123", "alice")
|
s.SetUser(sess, "user-abc-123", "alice")
|
||||||
|
|
||||||
assert.Equal(t, "user-abc-123", sess.Values[UserIDKey])
|
assert.Equal(
|
||||||
assert.Equal(t, "alice", sess.Values[UsernameKey])
|
t, "user-abc-123", sess.Values[session.UserIDKey],
|
||||||
assert.Equal(t, true, sess.Values[AuthenticatedKey])
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, "alice", sess.Values[session.UsernameKey],
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
t, true, sess.Values[session.AuthenticatedKey],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUserID(t *testing.T) {
|
func TestGetUserID(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Before setting user
|
// Before setting user
|
||||||
userID, ok := s.GetUserID(sess)
|
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)
|
assert.Empty(t, userID)
|
||||||
|
|
||||||
// After setting user
|
// After setting user
|
||||||
s.SetUser(sess, "user-xyz", "bob")
|
s.SetUser(sess, "user-xyz", "bob")
|
||||||
|
|
||||||
userID, ok = s.GetUserID(sess)
|
userID, ok = s.GetUserID(sess)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "user-xyz", userID)
|
assert.Equal(t, "user-xyz", userID)
|
||||||
@@ -147,19 +201,25 @@ func TestGetUserID(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetUsername(t *testing.T) {
|
func TestGetUsername(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Before setting user
|
// Before setting user
|
||||||
username, ok := s.GetUsername(sess)
|
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)
|
assert.Empty(t, username)
|
||||||
|
|
||||||
// After setting user
|
// After setting user
|
||||||
s.SetUser(sess, "user-xyz", "bob")
|
s.SetUser(sess, "user-xyz", "bob")
|
||||||
|
|
||||||
username, ok = s.GetUsername(sess)
|
username, ok = s.GetUsername(sess)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "bob", username)
|
assert.Equal(t, "bob", username)
|
||||||
@@ -169,20 +229,29 @@ func TestGetUsername(t *testing.T) {
|
|||||||
|
|
||||||
func TestIsAuthenticated_NoSession(t *testing.T) {
|
func TestIsAuthenticated_NoSession(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
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) {
|
func TestIsAuthenticated_AfterSetUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -192,9 +261,12 @@ func TestIsAuthenticated_AfterSetUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -202,52 +274,71 @@ func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
|||||||
require.True(t, s.IsAuthenticated(sess))
|
require.True(t, s.IsAuthenticated(sess))
|
||||||
|
|
||||||
s.ClearUser(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) {
|
func TestIsAuthenticated_WrongType(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set authenticated to a non-bool value
|
// Set authenticated to a non-bool value
|
||||||
sess.Values[AuthenticatedKey] = "yes"
|
sess.Values[session.AuthenticatedKey] = "yes"
|
||||||
assert.False(t, s.IsAuthenticated(sess), "should return false for non-bool authenticated value")
|
|
||||||
|
assert.False(
|
||||||
|
t, s.IsAuthenticated(sess),
|
||||||
|
"should return false for non-bool authenticated value",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ClearUser Tests ---
|
// --- ClearUser Tests ---
|
||||||
|
|
||||||
func TestClearUser_RemovesAllKeys(t *testing.T) {
|
func TestClearUser_RemovesAllKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
s.SetUser(sess, "user-123", "alice")
|
s.SetUser(sess, "user-123", "alice")
|
||||||
s.ClearUser(sess)
|
s.ClearUser(sess)
|
||||||
|
|
||||||
_, hasUserID := sess.Values[UserIDKey]
|
_, hasUserID := sess.Values[session.UserIDKey]
|
||||||
assert.False(t, hasUserID, "UserIDKey should be removed")
|
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")
|
assert.False(t, hasUsername, "UsernameKey should be removed")
|
||||||
|
|
||||||
_, hasAuth := sess.Values[AuthenticatedKey]
|
_, hasAuth := sess.Values[session.AuthenticatedKey]
|
||||||
assert.False(t, hasAuth, "AuthenticatedKey should be removed")
|
assert.False(
|
||||||
|
t, hasAuth, "AuthenticatedKey should be removed",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Destroy Tests ---
|
// --- Destroy Tests ---
|
||||||
|
|
||||||
func TestDestroy_InvalidatesSession(t *testing.T) {
|
func TestDestroy_InvalidatesSession(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -255,11 +346,18 @@ func TestDestroy_InvalidatesSession(t *testing.T) {
|
|||||||
|
|
||||||
s.Destroy(sess)
|
s.Destroy(sess)
|
||||||
|
|
||||||
// After Destroy: MaxAge should be -1 (delete cookie) and user data cleared
|
// After Destroy: MaxAge should be -1 (delete cookie) and
|
||||||
assert.Equal(t, -1, sess.Options.MaxAge, "Destroy should set MaxAge to -1")
|
// user data cleared
|
||||||
assert.False(t, s.IsAuthenticated(sess), "should not be authenticated after Destroy")
|
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")
|
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) {
|
func TestSessionPersistence_RoundTrip(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
// Step 1: Create session, set user, save
|
// Step 1: Create session, set user, save
|
||||||
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
req1 := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
w1 := httptest.NewRecorder()
|
w1 := httptest.NewRecorder()
|
||||||
|
|
||||||
sess1, err := s.Get(req1)
|
sess1, err := s.Get(req1)
|
||||||
@@ -281,8 +381,13 @@ func TestSessionPersistence_RoundTrip(t *testing.T) {
|
|||||||
cookies := w1.Result().Cookies()
|
cookies := w1.Result().Cookies()
|
||||||
require.NotEmpty(t, cookies)
|
require.NotEmpty(t, cookies)
|
||||||
|
|
||||||
// Step 2: New request with cookies — session data should persist
|
// Step 2: New request with cookies -- session data should
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
// persist
|
||||||
|
req2 := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet, "/profile", nil,
|
||||||
|
)
|
||||||
|
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
req2.AddCookie(c)
|
req2.AddCookie(c)
|
||||||
}
|
}
|
||||||
@@ -290,7 +395,10 @@ func TestSessionPersistence_RoundTrip(t *testing.T) {
|
|||||||
sess2, err := s.Get(req2)
|
sess2, err := s.Get(req2)
|
||||||
require.NoError(t, err)
|
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)
|
userID, ok := s.GetUserID(sess2)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
@@ -305,19 +413,23 @@ func TestSessionPersistence_RoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionConstants(t *testing.T) {
|
func TestSessionConstants(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
assert.Equal(t, "webhooker_session", SessionName)
|
|
||||||
assert.Equal(t, "user_id", UserIDKey)
|
assert.Equal(t, "webhooker_session", session.SessionName)
|
||||||
assert.Equal(t, "username", UsernameKey)
|
assert.Equal(t, "user_id", session.UserIDKey)
|
||||||
assert.Equal(t, "authenticated", AuthenticatedKey)
|
assert.Equal(t, "username", session.UsernameKey)
|
||||||
|
assert.Equal(t, "authenticated", session.AuthenticatedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Edge Cases ---
|
// --- Edge Cases ---
|
||||||
|
|
||||||
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
|
|
||||||
sess, err := s.Get(req)
|
sess, err := s.Get(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -338,10 +450,12 @@ func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := testSession(t)
|
s := testSession(t)
|
||||||
|
|
||||||
// Create a session
|
// Create a session
|
||||||
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
req1 := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil)
|
||||||
w1 := httptest.NewRecorder()
|
w1 := httptest.NewRecorder()
|
||||||
|
|
||||||
sess, err := s.Get(req1)
|
sess, err := s.Get(req1)
|
||||||
@@ -353,10 +467,15 @@ func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
|||||||
require.NotEmpty(t, cookies)
|
require.NotEmpty(t, cookies)
|
||||||
|
|
||||||
// Destroy and save
|
// Destroy and save
|
||||||
req2 := httptest.NewRequest(http.MethodGet, "/logout", nil)
|
req2 := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet, "/logout", nil,
|
||||||
|
)
|
||||||
|
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
req2.AddCookie(c)
|
req2.AddCookie(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
w2 := httptest.NewRecorder()
|
w2 := httptest.NewRecorder()
|
||||||
|
|
||||||
sess2, err := s.Get(req2)
|
sess2, err := s.Get(req2)
|
||||||
@@ -364,15 +483,25 @@ func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
|||||||
s.Destroy(sess2)
|
s.Destroy(sess2)
|
||||||
require.NoError(t, s.Save(req2, w2, 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()
|
responseCookies := w2.Result().Cookies()
|
||||||
|
|
||||||
var sessionCookie *http.Cookie
|
var sessionCookie *http.Cookie
|
||||||
|
|
||||||
for _, c := range responseCookies {
|
for _, c := range responseCookies {
|
||||||
if c.Name == SessionName {
|
if c.Name == session.SessionName {
|
||||||
sessionCookie = c
|
sessionCookie = c
|
||||||
|
|
||||||
break
|
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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import (
|
|||||||
|
|
||||||
// NewForTest creates a Session with a pre-configured cookie store for use
|
// NewForTest creates a Session with a pre-configured cookie store for use
|
||||||
// in tests. This bypasses the fx lifecycle and database dependency, allowing
|
// in tests. This bypasses the fx lifecycle and database dependency, allowing
|
||||||
// middleware and handler tests to use real session functionality.
|
// middleware and handler tests to use real session functionality. The key
|
||||||
func NewForTest(store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *Session {
|
// parameter is the raw 32-byte authentication key used for session encryption
|
||||||
|
// and CSRF cookie signing.
|
||||||
|
func NewForTest(store *sessions.CookieStore, cfg *config.Config, log *slog.Logger, key []byte) *Session {
|
||||||
return &Session{
|
return &Session{
|
||||||
store: store,
|
store: store,
|
||||||
|
key: key,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
|
|||||||
121
script/bootstrap
Executable file
121
script/bootstrap
Executable 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
15
script/check
Executable 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
15
script/cibuild
Executable 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
15
script/docker
Executable 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
15
script/fmt
Executable 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
17
script/fmt-check
Executable 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
16
script/install-precommit
Executable 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
12
script/lint
Executable 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
21
script/precommit
Executable 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
12
script/projectname
Executable 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
14
script/setup
Executable 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
12
script/test
Executable 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 "$@"
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Package static embeds static assets (CSS, JS) served by the web UI.
|
||||||
package static
|
package static
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Static holds the embedded CSS and JavaScript files for the web UI.
|
||||||
|
//
|
||||||
//go:embed css js
|
//go:embed css js
|
||||||
var Static embed.FS
|
var Static embed.FS
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/pages/login" class="space-y-6">
|
<form method="POST" action="/pages/login" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username" class="label">Username</label>
|
<label for="username" class="label">Username</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
{{.User.Username}}
|
{{.User.Username}}
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<a href="/sources" class="btn-text w-full text-left">Sources</a>
|
<a href="/sources" class="btn-text w-full text-left">Sources</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
||||||
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-danger">Delete</button>
|
<button type="submit" class="btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
<!-- Add entrypoint form -->
|
<!-- Add entrypoint form -->
|
||||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||||
<button type="submit" class="btn-primary text-sm">Add</button>
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -56,11 +58,13 @@
|
|||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,21 +92,27 @@
|
|||||||
<!-- Add target form -->
|
<!-- Add target form -->
|
||||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||||
<select name="type" x-model="targetType" class="input text-sm w-32">
|
<select name="type" x-model="targetType" class="input text-sm w-32">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Database</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'">
|
<div x-show="targetType === 'http'">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" class="input text-sm">
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
|
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
|
<div x-show="targetType === 'slack'">
|
||||||
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,11 +130,13 @@
|
|||||||
<span class="badge-error">Inactive</span>
|
<span class="badge-error">Inactive</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
||||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
|
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/sources/new" class="space-y-6">
|
<form method="POST" action="/sources/new" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name" class="label">Name</label>
|
<label for="name" class="label">Name</label>
|
||||||
<input type="text" id="name" name="name" required autofocus placeholder="My Webhook" class="input">
|
<input type="text" id="name" name="name" required autofocus placeholder="My Webhook" class="input">
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Package templates embeds HTML templates used by the web UI.
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Templates holds the embedded HTML template files.
|
||||||
|
//
|
||||||
//go:embed *.html
|
//go:embed *.html
|
||||||
var Templates embed.FS
|
var Templates embed.FS
|
||||||
|
|||||||
Reference in New Issue
Block a user