Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cded182e98 |
@@ -1,15 +0,0 @@
|
||||
.git/
|
||||
bin/
|
||||
*.md
|
||||
LICENSE
|
||||
.editorconfig
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
tmp/
|
||||
temp/
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -1,15 +0,0 @@
|
||||
name: check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||
- name: Build Docker image (runs make check)
|
||||
run: script/cibuild
|
||||
+3
-4
@@ -4,7 +4,7 @@
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
/webhooker
|
||||
webhooker
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -26,12 +26,11 @@ vendor/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment and config files
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Data directory (SQLite databases)
|
||||
data/
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
+38
-24
@@ -1,32 +1,46 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
enable:
|
||||
- gofmt
|
||||
- revive
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosimple
|
||||
- ineffassign
|
||||
- typecheck
|
||||
- gosec
|
||||
- misspell
|
||||
- unparam
|
||||
- prealloc
|
||||
- copyloopvar
|
||||
- gocritic
|
||||
- gochecknoinits
|
||||
- gochecknoglobals
|
||||
|
||||
linters-settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
lines: 80
|
||||
statements: 50
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
gofmt:
|
||||
simplify: true
|
||||
revive:
|
||||
confidence: 0.8
|
||||
govet:
|
||||
enable:
|
||||
- shadow
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-rules:
|
||||
# Exclude globals check for version variables in main
|
||||
- path: cmd/webhooker/main.go
|
||||
linters:
|
||||
- gochecknoglobals
|
||||
# Exclude globals check for version variables in globals package
|
||||
- path: internal/globals/globals.go
|
||||
linters:
|
||||
- gochecknoglobals
|
||||
+28
-51
@@ -1,81 +1,58 @@
|
||||
# Lint stage
|
||||
# golangci/golangci-lint:v2.11.3 (Debian-based), 2026-03-17
|
||||
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||
# compile on Alpine musl (off64_t is a glibc type).
|
||||
FROM golangci/golangci-lint:v2.11.3@sha256:e838e8ab68aaefe83e2408691510867ade9329c0e0b895a3fb35eb93d1c2a4ba AS lint
|
||||
## lint image
|
||||
FROM golangci/golangci-lint:latest
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /build
|
||||
WORKDIR /build
|
||||
COPY ./ ./
|
||||
RUN golangci-lint run
|
||||
|
||||
WORKDIR /src
|
||||
## build image:
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
# Copy go mod files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run formatting check and linter
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Build stage
|
||||
# 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/*
|
||||
# Install build dependencies including gcc for CGO
|
||||
RUN apk add --no-cache git make gcc musl-dev sqlite-dev
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
|
||||
# Copy go mod files first for better layer caching
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run tests and build
|
||||
RUN make test
|
||||
RUN make build
|
||||
# Build the application with CGO enabled for SQLite
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o webhooker cmd/webhooker/main.go
|
||||
|
||||
# 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
|
||||
## output image:
|
||||
FROM alpine:latest
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-03-17
|
||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
# Install ca-certificates for HTTPS and sqlite libs
|
||||
RUN apk --no-cache add ca-certificates sqlite-libs
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 -S webhooker && \
|
||||
adduser -u 1000 -S webhooker -G webhooker
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /build/bin/webhooker /app/webhooker
|
||||
COPY --from=builder /build/webhooker .
|
||||
|
||||
# Create data directory for all SQLite databases (main app DB +
|
||||
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
|
||||
RUN mkdir -p /var/lib/webhooker
|
||||
|
||||
RUN chown -R webhooker:webhooker /app /var/lib/webhooker
|
||||
# Change ownership
|
||||
RUN chown -R webhooker:webhooker /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER webhooker
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
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.json || exit 1
|
||||
|
||||
CMD ["/app/webhooker"]
|
||||
# Run the application
|
||||
CMD ["./webhooker"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jeffrey Paul <sneak@sneak.berlin>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,50 +1,39 @@
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker clean hooks css
|
||||
.PHONY: test fmt lint build run clean
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
test:
|
||||
@script/test
|
||||
|
||||
lint:
|
||||
@script/lint
|
||||
test: lint
|
||||
go test -v ./...
|
||||
|
||||
fmt:
|
||||
@script/fmt
|
||||
go fmt ./...
|
||||
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
check:
|
||||
@script/check
|
||||
|
||||
build:
|
||||
go build -o bin/webhooker ./cmd/webhooker
|
||||
build: test
|
||||
go build -o bin/webhooker cmd/webhooker/main.go
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
# Development helpers
|
||||
.PHONY: dev deps
|
||||
|
||||
dev:
|
||||
go run ./cmd/webhooker
|
||||
go run cmd/webhooker/main.go
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
# Docker targets
|
||||
.PHONY: docker-build docker-run
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
docker-build:
|
||||
docker build -t webhooker:latest .
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
css:
|
||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
docker-run: docker-build
|
||||
docker run --rm -p 8080:8080 webhooker:latest
|
||||
@@ -1,408 +0,0 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-07-06
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
style conventions are in separate documents:
|
||||
|
||||
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||
(general, bash, Docker)
|
||||
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||
|
||||
---
|
||||
|
||||
- Cross-project documentation (such as this file) must include
|
||||
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||
with the authoritative source as policies evolve.
|
||||
|
||||
- **ALL external references must be pinned by cryptographic hash.** This
|
||||
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||
install scripts are arbitrary remote code execution. This is the single most
|
||||
important rule in this document. Double-check every external reference in
|
||||
every file before committing. There are zero exceptions to this rule.
|
||||
|
||||
- Every repo with software must have a root `Makefile` with these targets:
|
||||
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||
|
||||
- Repos follow the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
pattern: the implementation of each Makefile target lives in an executable
|
||||
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||
for development after a fresh clone: runs `bootstrap`, then
|
||||
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||
(detected in that order; apt runs noninteractive). For node it uses the
|
||||
installed node if present; otherwise it installs a PINNED node version via
|
||||
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||
release archive (never `curl | sh`), with bash installed as an explicit
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||
scripts are our own extensions to the standard: `script/check` runs
|
||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||
what the git pre-commit hook runs, and it calls `script/check`;
|
||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||
target shims to it); and `script/projectname` (literally that filename) simply
|
||||
outputs the project's name. Scripts that need the name call
|
||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||
`script/precommit`, not in the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
|
||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||
instead of invoking the underlying tools directly. The Makefile is the single
|
||||
source of truth for how these operations are run.
|
||||
|
||||
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||
the required targets above, it should have targets for every common operation:
|
||||
running a local development server (`make run`, `make dev`), re-initializing
|
||||
or migrating the database (`make db-reset`, `make migrate`), building
|
||||
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||
developer would do regularly. If someone checks out the repo and types
|
||||
`make<tab>`, they should see every meaningful operation available. A new
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled. Dockerfiles install development
|
||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||
layer stays cached until dependencies change.
|
||||
|
||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||
repos use a multistage build where linting runs in an independent stage based
|
||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||
then declares an explicit dependency on the lint stage via
|
||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||
linting before proceeding to compilation and tests. This ensures lint failures
|
||||
surface in seconds rather than minutes, without blocking on dependency
|
||||
download or compilation in the build stage.
|
||||
|
||||
The standard pattern for a Go repo Dockerfile is:
|
||||
|
||||
```dockerfile
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
|
||||
# Build stage
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
WORKDIR /src
|
||||
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make test
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine@sha256:...
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||
includes both Go and the linter), so there is no need to install the
|
||||
linter separately.
|
||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||
this line, the build stage would not wait for lint to finish and a lint
|
||||
failure might not fail the overall build.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
The lint stage should not depend on the actual build output — it exists to
|
||||
fail fast.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint stage with `apk add`.
|
||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||
build stage, not the lint stage, because they may require compiled
|
||||
artifacts or heavier dependencies.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||
Dockerfile already runs `make check`, a successful build implies all checks
|
||||
pass.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||
|
||||
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||
that shims to it.
|
||||
|
||||
- All repos with software must have tests that run via the platform-standard
|
||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
|
||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||
show full output. This keeps CI logs and `docker build` output clean on
|
||||
success (just package/suite summaries) while providing full diagnostic detail
|
||||
on failure (every test case, every assertion). The general shell pattern:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@<test-command> || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
<test-command-with-v>; exit 1; }
|
||||
```
|
||||
|
||||
Go example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@go test -timeout 30s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 30s -race -v ./...; exit 1; }
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@python -m pytest || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
python -m pytest -v; exit 1; }
|
||||
```
|
||||
|
||||
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||
already proved the tests are broken, so the build must not pass even if a
|
||||
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||
for diagnostic output.
|
||||
|
||||
- Docker builds must complete in under 5 minutes.
|
||||
|
||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||
directories.
|
||||
|
||||
- `main` must always pass `make check`, no exceptions.
|
||||
|
||||
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
|
||||
- **No build artifacts in version control.** Code-derived data (compiled
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||
should generate these at build time. Notable exception: Go protobuf generated
|
||||
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||
downloads code but does not execute code generation.
|
||||
|
||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||
|
||||
- Never force-push to `main`.
|
||||
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
- Use `yarn`, not `npm`.
|
||||
|
||||
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||
|
||||
- Simple projects should be configured with environment variables.
|
||||
|
||||
- Dockerized web services listen on port 8080 by default, overridable with
|
||||
`PORT`.
|
||||
|
||||
- **HTTP/web services must be hardened for production internet exposure before
|
||||
tagging 1.0.** This means full compliance with security best practices
|
||||
including, without limitation, all of the following:
|
||||
- **Security headers** on every response:
|
||||
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||
and `includeSubDomains`.
|
||||
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||
unavoidable, and document the reason.
|
||||
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||
- `X-Content-Type-Options: nosniff`.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||
- `Permissions-Policy` restricting access to browser features the
|
||||
application does not use (camera, microphone, geolocation, etc.).
|
||||
- **Request and response limits:**
|
||||
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||
unbounded input.
|
||||
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||
against slowloris attacks.
|
||||
- `WriteTimeout` on the `http.Server`.
|
||||
- `IdleTimeout` on the `http.Server`.
|
||||
- Per-handler execution time limits via `context.WithTimeout` or
|
||||
chi/stdlib `middleware.Timeout`.
|
||||
- **Authentication and session security:**
|
||||
- Rate limiting on password-based authentication endpoints. API keys are
|
||||
high-entropy and not susceptible to brute force, so they are exempt.
|
||||
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||
authenticated via `Authorization` header (Bearer token, API key) are
|
||||
exempt because the browser does not attach these automatically.
|
||||
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||
MD5, or SHA.
|
||||
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||
`Strict`) attributes.
|
||||
- **Reverse proxy awareness:**
|
||||
- True client IP detection when behind a reverse proxy
|
||||
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||
forwarded headers only from a configured set of trusted proxy
|
||||
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||
- **CORS:**
|
||||
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||
only for public, unauthenticated read-only APIs.
|
||||
- **Error handling:**
|
||||
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||
or other implementation details to the client. Return generic error
|
||||
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||
- **TLS:**
|
||||
- Services never terminate TLS directly. They are always deployed behind
|
||||
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||
set by the application so that the browser enforces HTTPS end-to-end.
|
||||
|
||||
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||
hardening measure exists for HTTP services and is not listed here, it is
|
||||
still expected. When in doubt, harden.
|
||||
|
||||
- `README.md` is the primary documentation. Required sections:
|
||||
- **Description**: First line must include the project name, purpose,
|
||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard (with that link), then documents each provided `script/`
|
||||
entrypoint and its purpose.
|
||||
- **Rationale**: Why does this exist?
|
||||
- **Design**: How is the program structured?
|
||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||
the todo list in the README so a new agent can pick up where the last one
|
||||
left off.
|
||||
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||
`LICENSE` file in the repo root and a License section in the README.
|
||||
- **Author**: [@sneak](https://sneak.berlin).
|
||||
|
||||
- First commit of a new repo should contain only `README.md`.
|
||||
|
||||
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||
committing.
|
||||
|
||||
- Use SemVer.
|
||||
|
||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||
the binary.
|
||||
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||
tracking table itself. Nothing else.
|
||||
- `001_schema.sql` — the full application schema.
|
||||
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||
Never edit existing migrations after release.
|
||||
|
||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
- `internal/` — Go internal packages
|
||||
- `internal/db/migrations/` — database migrations
|
||||
- `pkg/` — Go library packages
|
||||
- `share/` — systemd units, data files
|
||||
- `static/` — static assets (images, fonts, etc.)
|
||||
- `web/` — web frontend source
|
||||
|
||||
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||
templates. Fetch them from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||
|
||||
- New repos must contain at minimum:
|
||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||
- `Makefile`
|
||||
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||
`install-precommit`)
|
||||
- `Dockerfile`, `.dockerignore`
|
||||
- `.gitea/workflows/check.yml`
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
@@ -1,77 +1,70 @@
|
||||
# Workflow
|
||||
|
||||
* branch (from `main`)
|
||||
* do the work in Next Step
|
||||
* move Next Step to the top of Completed Steps
|
||||
* move the top item of Future Steps into Next Step
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||
* push
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. No git tags exist. main (afe88c6) is a working webhook proxy
|
||||
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
|
||||
policy compliance (#6), and pinned lint tooling (#55). Note: TODO.md was
|
||||
deliberately deleted from this repo in f9a9569 (2026-03-01, #6); its
|
||||
content was folded into the README TODO section, which this draft
|
||||
reconstructs as of 2026-07-06.
|
||||
pre-1.0. Single initial commit on main (2026-03-01), no tags. Early
|
||||
stage; most of the feature plan is unimplemented and the policy scaffold
|
||||
is incomplete.
|
||||
|
||||
# Next Step
|
||||
|
||||
Implement automatic event retention cleanup based on retention_days: a
|
||||
periodic maintenance job that deletes Events, Deliveries, and
|
||||
DeliveryResults older than the parent webhook's retention_days from each
|
||||
per-webhook event database. The field exists on the Webhook model and
|
||||
the README promises the behavior, but nothing enforces it, so event
|
||||
databases currently grow without bound.
|
||||
Full policy scaffold in one commit:
|
||||
|
||||
- Add LICENSE, REPO_POLICIES.md, .editorconfig, .dockerignore.
|
||||
- Add .gitea/workflows/check.yml running make check.
|
||||
- Extend the Makefile with fmt-check, check, docker, and hooks targets
|
||||
(test/lint/fmt exist; docker-build/docker-run exist but not the
|
||||
required docker target).
|
||||
- Make the Dockerfile run make check and pin base images by sha256.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||
Makefile shims, README Entrypoints section
|
||||
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
|
||||
plain HTTP and behind reverse proxies (#54)
|
||||
- 2026-03-17 root path redirects based on auth state (#52)
|
||||
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
|
||||
with DNS rebinding defense, and per-IP login rate limiting (#42)
|
||||
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
|
||||
absolute dev DATA_DIR default and clarified env docs (#46)
|
||||
- 2026-03-05 security headers middleware, session regeneration on
|
||||
login, request body size limits (#41)
|
||||
- 2026-03-04 tests for delivery, middleware, and session packages
|
||||
(#32); removed globals.Buildarch (#31)
|
||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
||||
delivery engine with bounded worker pool and circuit breaker,
|
||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
||||
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
|
||||
into README (#6)
|
||||
- 2026-03-01: Initial commit: Go module (git.eeqj.de/sneak/webhooker),
|
||||
cmd/internal/pkg layout, config.yaml, Dockerfile, Makefile with
|
||||
test/lint/fmt, templates and static assets, README describing the
|
||||
processor/target delivery design, .gitignore and .golangci.yml.
|
||||
|
||||
# Future Steps
|
||||
|
||||
- Manual event redelivery from the web UI (replay is a core promised
|
||||
capability in the README rationale)
|
||||
- Delivery status and retry management UI
|
||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||
plus handler enforcement; global limits must not apply to receiver
|
||||
endpoints)
|
||||
- Webhook signature verification for GitHub and Stripe HMAC formats
|
||||
- API key authentication for programmatic access (APIKey model exists;
|
||||
Bearer token middleware does not)
|
||||
- REST API v1
|
||||
- CRUD for webhooks, entrypoints, targets
|
||||
- event viewing and filtering endpoints
|
||||
- event redelivery endpoint
|
||||
- OpenAPI specification
|
||||
- Analytics dashboard: success rates, response times, volume
|
||||
- Session expiration tuning and a remember-me option
|
||||
- Password change and reset flow
|
||||
- Later, nice to have
|
||||
- email delivery target type
|
||||
- SNS and S3 delivery targets
|
||||
- data transformations (e.g. webhook to Slack message formatting)
|
||||
- JSONL file delivery with periodic S3 upload
|
||||
- webhook event search and filtering
|
||||
- multi-user with role-based access control
|
||||
- Compliance (after Next Step):
|
||||
- Rework README.md into the required sections: Description, Getting
|
||||
Started, Rationale, Design, TODO, License, Author.
|
||||
- Keep main green under the new workflow.
|
||||
- Phase 1, security and infrastructure hardening (also required for 1.0
|
||||
as an HTTP service): security headers (HSTS, CSP, X-Frame-Options),
|
||||
request timeouts and context handling, max request/response body
|
||||
sizes, rate limiting middleware, CSRF protection on forms, restricted
|
||||
CORS, request ID tracking through the request lifecycle, panic
|
||||
recovery with proper error reporting.
|
||||
- Phase 2, authentication and authorization: session-checking auth
|
||||
middleware, session expiration, remember-me, password reset, optional
|
||||
registration, authorization middleware for protected routes, API key
|
||||
auth for programmatic access. Use bcrypt or argon2 for passwords and
|
||||
secure cookies per policy.
|
||||
- Phase 3, database models and migrations: webhook source model,
|
||||
request log model, retry model for failed deliveries, indexes,
|
||||
migration system.
|
||||
- Phase 4, source management UI: source list, create form, detail page,
|
||||
edit, delete with confirmation, webhook URL generation, secret key
|
||||
management, webhook testing.
|
||||
- Phase 5, processing engine: reception at /webhook/{uuid}, request
|
||||
validation, forwarding to targets, request/response logging,
|
||||
signature verification (GitHub, Stripe formats), transformation of
|
||||
headers and body, outbound timeouts, retry with exponential backoff.
|
||||
- Phase 6, logs and analytics: log viewer per source, filtering and
|
||||
search, body viewer, analytics dashboard (success rates, response
|
||||
times), health monitoring, alerting on failures, retention policies.
|
||||
- Phase 7, advanced features: request replay, batching, queuing,
|
||||
multiple targets per source, conditional forwarding, transformation
|
||||
templates, debugging tools, scheduled or delayed delivery.
|
||||
- Phase 8, API: RESTful source management API, API auth and rate
|
||||
limiting, OpenAPI docs, client libraries, webhooks-as-a-service API.
|
||||
- Phase 9, performance and scalability: Redis caching, background job
|
||||
processing, horizontal scaling, delivery parallelization, Prometheus
|
||||
metrics, performance dashboard.
|
||||
- Phase 10, operations: comprehensive logging, admin dashboard for user
|
||||
management, backup and restore, health checks and monitoring,
|
||||
deployment automation (Docker, K8s), zero-downtime deploys, feature
|
||||
flags.
|
||||
- Nice-to-have: webhook marketplace/templates, team collaboration,
|
||||
webhook versioning, A/B testing, analytics export, mobile monitoring
|
||||
app, Slack/Discord/email notifications, documentation generator,
|
||||
GraphQL subscriptions, WebSocket real-time updates.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Package main is the entry point for the webhooker application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// Build-time variables set via -ldflags.
|
||||
//
|
||||
//nolint:gochecknoglobals // Build-time variables injected by the linker.
|
||||
var (
|
||||
version = "dev"
|
||||
appname = "webhooker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.Appname = appname
|
||||
globals.Version = version
|
||||
|
||||
fx.New(
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
handlers.New,
|
||||
middleware.New,
|
||||
delivery.New,
|
||||
// Wire *delivery.Engine as delivery.Notifier so the
|
||||
// webhook handler can notify the engine of new deliveries.
|
||||
func(e *delivery.Engine) delivery.Notifier { return e },
|
||||
server.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server, *delivery.Engine) {}),
|
||||
).Run()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
port: 8080
|
||||
debug: true
|
||||
maintenanceMode: false
|
||||
developmentMode: true
|
||||
environment: dev
|
||||
# Database URL for local development
|
||||
dburl: postgres://webhooker:webhooker@localhost:5432/webhooker_dev?sslmode=disable
|
||||
# Basic auth for metrics endpoint in dev
|
||||
metricsUsername: admin
|
||||
metricsPassword: admin
|
||||
# Dev admin credentials for testing
|
||||
devAdminUsername: devadmin
|
||||
devAdminPassword: devpassword
|
||||
secrets:
|
||||
# Use default insecure session key for development
|
||||
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=
|
||||
# Sentry DSN - usually not needed in dev
|
||||
sentryDSN: ""
|
||||
|
||||
prod:
|
||||
config:
|
||||
port: $ENV:PORT
|
||||
debug: $ENV:DEBUG
|
||||
maintenanceMode: $ENV:MAINTENANCE_MODE
|
||||
developmentMode: false
|
||||
environment: prod
|
||||
dburl: $ENV:DBURL
|
||||
metricsUsername: $ENV:METRICS_USERNAME
|
||||
metricsPassword: $ENV:METRICS_PASSWORD
|
||||
# Dev admin credentials should not be set in production
|
||||
devAdminUsername: ""
|
||||
devAdminPassword: ""
|
||||
secrets:
|
||||
sessionKey: $ENV:SESSION_KEY
|
||||
sentryDSN: $ENV:SENTRY_DSN
|
||||
|
||||
configDefaults:
|
||||
# These defaults apply to all environments unless overridden
|
||||
port: 8080
|
||||
debug: false
|
||||
maintenanceMode: false
|
||||
developmentMode: false
|
||||
environment: dev
|
||||
metricsUsername: ""
|
||||
metricsPassword: ""
|
||||
devAdminUsername: ""
|
||||
devAdminPassword: ""
|
||||
@@ -1,19 +1,21 @@
|
||||
module sneak.berlin/go/webhooker
|
||||
module git.eeqj.de/sneak/webhooker
|
||||
|
||||
go 1.26.1
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
git.eeqj.de/sneak/webhooker/pkg/config v0.0.0-00010101000000-000000000000
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
github.com/getsentry/sentry-go v0.25.0
|
||||
github.com/go-chi/chi v1.5.5
|
||||
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/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/prometheus/client_golang v1.18.0
|
||||
github.com/slok/go-http-metrics v0.11.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
go.uber.org/fx v1.20.1
|
||||
golang.org/x/crypto v0.38.0
|
||||
@@ -23,35 +25,67 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute v1.23.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v1.1.5 // indirect
|
||||
cloud.google.com/go/secretmanager v1.11.4 // indirect
|
||||
github.com/aws/aws-sdk-go v1.50.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // 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/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.45.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/dig v1.17.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
go.uber.org/zap v1.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/oauth2 v0.15.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/api v0.153.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
|
||||
google.golang.org/grpc v1.59.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
@@ -63,3 +97,5 @@ require (
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // indirect
|
||||
)
|
||||
|
||||
replace git.eeqj.de/sneak/webhooker/pkg/config => ./pkg/config
|
||||
|
||||
@@ -1,29 +1,74 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
|
||||
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
|
||||
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
|
||||
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
|
||||
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
|
||||
cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k=
|
||||
cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w=
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI=
|
||||
github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
|
||||
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI=
|
||||
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
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/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/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/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -31,34 +76,49 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
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/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
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/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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -68,6 +128,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
|
||||
@@ -78,18 +139,37 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
|
||||
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
|
||||
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/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=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
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/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
|
||||
@@ -102,32 +182,109 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
|
||||
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
|
||||
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.153.0 h1:N1AwGhielyKFaUqH07/ZSIQR3uNPcV7NVw0vj+j4iR4=
|
||||
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
|
||||
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
|
||||
+62
-84
@@ -1,147 +1,125 @@
|
||||
// Package config loads application configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
pkgconfig "git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
|
||||
// Populates the environment from a ./.env file automatically for
|
||||
// development configuration. Kept in one place only (here).
|
||||
// spooky action at a distance!
|
||||
// this populates the environment
|
||||
// from a ./.env file automatically
|
||||
// for development configuration.
|
||||
// .env contents should be things like
|
||||
// `DBURL=postgres://user:pass@.../`
|
||||
// (without the backticks, of course)
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
const (
|
||||
// EnvironmentDev represents development environment.
|
||||
// EnvironmentDev represents development environment
|
||||
EnvironmentDev = "dev"
|
||||
// EnvironmentProd represents production environment.
|
||||
// EnvironmentProd represents production environment
|
||||
EnvironmentProd = "prod"
|
||||
|
||||
// defaultPort is the default HTTP listen port.
|
||||
defaultPort = 8080
|
||||
// DevSessionKey is an insecure default session key for development
|
||||
// This is "webhooker-dev-session-key-insecure!" base64 encoded
|
||||
DevSessionKey = "d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE="
|
||||
)
|
||||
|
||||
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||
// contains an unrecognised value.
|
||||
var ErrInvalidEnvironment = errors.New("invalid environment")
|
||||
|
||||
//nolint:revive // ConfigParams is a standard fx naming convention.
|
||||
// nolint:revive // ConfigParams is a standard fx naming convention
|
||||
type ConfigParams struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Config holds all application configuration loaded from
|
||||
// environment variables.
|
||||
type Config struct {
|
||||
DataDir string
|
||||
DBURL string
|
||||
Debug bool
|
||||
MaintenanceMode bool
|
||||
DevelopmentMode bool
|
||||
DevAdminUsername string
|
||||
DevAdminPassword string
|
||||
Environment string
|
||||
MetricsPassword string
|
||||
MetricsUsername string
|
||||
Port int
|
||||
SentryDSN string
|
||||
SessionKey string
|
||||
params *ConfigParams
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// IsDev returns true if running in development environment.
|
||||
// IsDev returns true if running in development environment
|
||||
func (c *Config) IsDev() bool {
|
||||
return c.Environment == EnvironmentDev
|
||||
}
|
||||
|
||||
// IsProd returns true if running in production environment.
|
||||
// IsProd returns true if running in production environment
|
||||
func (c *Config) IsProd() bool {
|
||||
return c.Environment == EnvironmentProd
|
||||
}
|
||||
|
||||
// envString returns the value of the named environment variable,
|
||||
// or an empty string if not set.
|
||||
func envString(key string) string {
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
// envBool returns the value of the named environment variable
|
||||
// parsed as a boolean. Returns defaultValue if not set.
|
||||
func envBool(key string, defaultValue bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return strings.EqualFold(v, "true") || v == "1"
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// envInt returns the value of the named environment variable
|
||||
// parsed as an integer. Returns defaultValue if not set or
|
||||
// unparseable.
|
||||
func envInt(key string, defaultValue int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
i, err := strconv.Atoi(v)
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// New creates a Config by reading environment variables.
|
||||
//
|
||||
//nolint:revive // lc parameter is required by fx even if unused.
|
||||
// nolint:revive // lc parameter is required by fx even if unused
|
||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
log := params.Logger.Get()
|
||||
|
||||
// Determine environment from WEBHOOKER_ENVIRONMENT env var,
|
||||
// default to dev
|
||||
// Determine environment from WEBHOOKER_ENVIRONMENT env var, default to dev
|
||||
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
||||
if environment == "" {
|
||||
environment = EnvironmentDev
|
||||
}
|
||||
|
||||
// Validate environment
|
||||
if environment != EnvironmentDev &&
|
||||
environment != EnvironmentProd {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
|
||||
ErrInvalidEnvironment,
|
||||
EnvironmentDev, EnvironmentProd, environment,
|
||||
)
|
||||
if environment != EnvironmentDev && environment != EnvironmentProd {
|
||||
return nil, fmt.Errorf("WEBHOOKER_ENVIRONMENT must be either '%s' or '%s', got '%s'",
|
||||
EnvironmentDev, EnvironmentProd, environment)
|
||||
}
|
||||
|
||||
// Load configuration values from environment variables
|
||||
// Set the environment in the config package
|
||||
pkgconfig.SetEnvironment(environment)
|
||||
|
||||
// Load configuration values
|
||||
s := &Config{
|
||||
DataDir: envString("DATA_DIR"),
|
||||
Debug: envBool("DEBUG", false),
|
||||
MaintenanceMode: envBool("MAINTENANCE_MODE", false),
|
||||
Environment: environment,
|
||||
MetricsUsername: envString("METRICS_USERNAME"),
|
||||
MetricsPassword: envString("METRICS_PASSWORD"),
|
||||
Port: envInt("PORT", defaultPort),
|
||||
SentryDSN: envString("SENTRY_DSN"),
|
||||
DBURL: pkgconfig.GetString("dburl"),
|
||||
Debug: pkgconfig.GetBool("debug"),
|
||||
MaintenanceMode: pkgconfig.GetBool("maintenanceMode"),
|
||||
DevelopmentMode: pkgconfig.GetBool("developmentMode"),
|
||||
DevAdminUsername: pkgconfig.GetString("devAdminUsername"),
|
||||
DevAdminPassword: pkgconfig.GetString("devAdminPassword"),
|
||||
Environment: pkgconfig.GetString("environment", environment),
|
||||
MetricsUsername: pkgconfig.GetString("metricsUsername"),
|
||||
MetricsPassword: pkgconfig.GetString("metricsPassword"),
|
||||
Port: pkgconfig.GetInt("port", 8080),
|
||||
SentryDSN: pkgconfig.GetSecretString("sentryDSN"),
|
||||
SessionKey: pkgconfig.GetSecretString("sessionKey"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
// Set default DataDir. All SQLite databases (main application
|
||||
// DB and per-webhook event DBs) live here. The same default is
|
||||
// used regardless of environment; override with DATA_DIR if
|
||||
// needed.
|
||||
if s.DataDir == "" {
|
||||
s.DataDir = "/var/lib/webhooker"
|
||||
// Validate database URL
|
||||
if s.DBURL == "" {
|
||||
return nil, fmt.Errorf("database URL (dburl) is required")
|
||||
}
|
||||
|
||||
// In production, require session key
|
||||
if s.IsProd() && s.SessionKey == "" {
|
||||
return nil, fmt.Errorf("SESSION_KEY is required in production environment")
|
||||
}
|
||||
|
||||
// In development mode, warn if using default session key
|
||||
if s.IsDev() && s.SessionKey == DevSessionKey {
|
||||
log.Warn("Using insecure default session key for development mode")
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
s.log = params.Logger.Get()
|
||||
log.Debug("Debug mode enabled")
|
||||
}
|
||||
|
||||
// Log configuration summary (without secrets)
|
||||
@@ -150,10 +128,10 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
"port", s.Port,
|
||||
"debug", s.Debug,
|
||||
"maintenanceMode", s.MaintenanceMode,
|
||||
"dataDir", s.DataDir,
|
||||
"developmentMode", s.DevelopmentMode,
|
||||
"hasSessionKey", s.SessionKey != "",
|
||||
"hasSentryDSN", s.SentryDSN != "",
|
||||
"hasMetricsAuth",
|
||||
s.MetricsUsername != "" && s.MetricsPassword != "",
|
||||
"hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "",
|
||||
)
|
||||
|
||||
return s, nil
|
||||
|
||||
+223
-88
@@ -1,18 +1,69 @@
|
||||
package config_test
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
pkgconfig "git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
// createTestConfig creates a test configuration file in memory
|
||||
func createTestConfig(fs afero.Fs) error {
|
||||
configYAML := `
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
port: 8080
|
||||
debug: true
|
||||
maintenanceMode: false
|
||||
developmentMode: true
|
||||
environment: dev
|
||||
dburl: postgres://test:test@localhost:5432/test_dev?sslmode=disable
|
||||
metricsUsername: testuser
|
||||
metricsPassword: testpass
|
||||
devAdminUsername: devadmin
|
||||
devAdminPassword: devpass
|
||||
secrets:
|
||||
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=
|
||||
sentryDSN: ""
|
||||
|
||||
prod:
|
||||
config:
|
||||
port: $ENV:PORT
|
||||
debug: $ENV:DEBUG
|
||||
maintenanceMode: $ENV:MAINTENANCE_MODE
|
||||
developmentMode: false
|
||||
environment: prod
|
||||
dburl: $ENV:DBURL
|
||||
metricsUsername: $ENV:METRICS_USERNAME
|
||||
metricsPassword: $ENV:METRICS_PASSWORD
|
||||
devAdminUsername: ""
|
||||
devAdminPassword: ""
|
||||
secrets:
|
||||
sessionKey: $ENV:SESSION_KEY
|
||||
sentryDSN: $ENV:SENTRY_DSN
|
||||
|
||||
configDefaults:
|
||||
port: 8080
|
||||
debug: false
|
||||
maintenanceMode: false
|
||||
developmentMode: false
|
||||
environment: dev
|
||||
metricsUsername: ""
|
||||
metricsPassword: ""
|
||||
devAdminUsername: ""
|
||||
devAdminPassword: ""
|
||||
`
|
||||
return afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644)
|
||||
}
|
||||
|
||||
func TestEnvironmentConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -24,18 +75,26 @@ func TestEnvironmentConfig(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "default is dev",
|
||||
envValue: "",
|
||||
expectError: false,
|
||||
isDev: true,
|
||||
isProd: false,
|
||||
},
|
||||
{
|
||||
name: "explicit dev",
|
||||
envValue: "dev",
|
||||
expectError: false,
|
||||
isDev: true,
|
||||
isProd: false,
|
||||
},
|
||||
{
|
||||
name: "explicit prod",
|
||||
name: "explicit prod with session key",
|
||||
envValue: "prod",
|
||||
envVars: map[string]string{
|
||||
"SESSION_KEY": "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
|
||||
"DBURL": "postgres://prod:prod@localhost:5432/prod?sslmode=require",
|
||||
},
|
||||
expectError: false,
|
||||
isDev: false,
|
||||
isProd: true,
|
||||
},
|
||||
@@ -48,118 +107,194 @@ func TestEnvironmentConfig(t *testing.T) {
|
||||
|
||||
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.
|
||||
// Create in-memory filesystem with test config
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, createTestConfig(fs))
|
||||
pkgconfig.SetFs(fs)
|
||||
|
||||
// Set environment variable if specified
|
||||
if tt.envValue != "" {
|
||||
t.Setenv(
|
||||
"WEBHOOKER_ENVIRONMENT", tt.envValue,
|
||||
)
|
||||
} else {
|
||||
require.NoError(t, os.Unsetenv(
|
||||
"WEBHOOKER_ENVIRONMENT",
|
||||
))
|
||||
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue)
|
||||
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
|
||||
}
|
||||
|
||||
// Set additional environment variables
|
||||
for k, v := range tt.envVars {
|
||||
t.Setenv(k, v)
|
||||
os.Setenv(k, v)
|
||||
defer os.Unsetenv(k)
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
testEnvironmentConfigError(t)
|
||||
} else {
|
||||
testEnvironmentConfigSuccess(
|
||||
t, tt.isDev, tt.isProd,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testEnvironmentConfigError(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
// Use regular fx.New for error cases since fxtest doesn't expose errors the same way
|
||||
var cfg *Config
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.NopLogger, // Suppress fx logs in tests
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
|
||||
assert.Error(t, app.Err())
|
||||
}
|
||||
|
||||
func testEnvironmentConfigSuccess(
|
||||
t *testing.T,
|
||||
isDev, isProd bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
require.NoError(t, app.Err())
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
defer app.RequireStop()
|
||||
|
||||
assert.Equal(t, isDev, cfg.IsDev())
|
||||
assert.Equal(t, isProd, cfg.IsProd())
|
||||
}
|
||||
|
||||
func TestDefaultDataDir(t *testing.T) {
|
||||
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
|
||||
|
||||
// Use fxtest for success cases
|
||||
var cfg *Config
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
require.NoError(t, app.Err())
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
defer app.RequireStop()
|
||||
|
||||
assert.Equal(
|
||||
t, "/var/lib/webhooker", cfg.DataDir,
|
||||
assert.Equal(t, tt.isDev, cfg.IsDev())
|
||||
assert.Equal(t, tt.isProd, cfg.IsProd())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKeyDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
environment string
|
||||
sessionKey string
|
||||
dburl string
|
||||
expectError bool
|
||||
expectedKey string
|
||||
}{
|
||||
{
|
||||
name: "dev mode with default session key",
|
||||
environment: "dev",
|
||||
sessionKey: "",
|
||||
expectError: false,
|
||||
expectedKey: DevSessionKey,
|
||||
},
|
||||
{
|
||||
name: "dev mode with custom session key",
|
||||
environment: "dev",
|
||||
sessionKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==",
|
||||
expectError: false,
|
||||
expectedKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==",
|
||||
},
|
||||
{
|
||||
name: "prod mode with no session key fails",
|
||||
environment: "prod",
|
||||
sessionKey: "",
|
||||
dburl: "postgres://prod:prod@localhost:5432/prod",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "prod mode with session key succeeds",
|
||||
environment: "prod",
|
||||
sessionKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
|
||||
dburl: "postgres://prod:prod@localhost:5432/prod",
|
||||
expectError: false,
|
||||
expectedKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create in-memory filesystem with test config
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create custom config for session key tests
|
||||
configYAML := `
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
environment: dev
|
||||
developmentMode: true
|
||||
dburl: postgres://test:test@localhost:5432/test_dev
|
||||
secrets:`
|
||||
|
||||
// Only add sessionKey line if it's not empty
|
||||
if tt.sessionKey != "" {
|
||||
configYAML += `
|
||||
sessionKey: ` + tt.sessionKey
|
||||
} else if tt.environment == "dev" {
|
||||
// For dev mode with no session key, use the default
|
||||
configYAML += `
|
||||
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=`
|
||||
}
|
||||
|
||||
// Add prod config if testing prod
|
||||
if tt.environment == "prod" {
|
||||
configYAML += `
|
||||
prod:
|
||||
config:
|
||||
environment: prod
|
||||
developmentMode: false
|
||||
dburl: $ENV:DBURL
|
||||
secrets:
|
||||
sessionKey: $ENV:SESSION_KEY`
|
||||
}
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644))
|
||||
pkgconfig.SetFs(fs)
|
||||
|
||||
// Clean up any existing env vars
|
||||
os.Unsetenv("WEBHOOKER_ENVIRONMENT")
|
||||
os.Unsetenv("SESSION_KEY")
|
||||
os.Unsetenv("DBURL")
|
||||
|
||||
// Set environment variables
|
||||
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
|
||||
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
|
||||
|
||||
if tt.sessionKey != "" && tt.environment == "prod" {
|
||||
os.Setenv("SESSION_KEY", tt.sessionKey)
|
||||
defer os.Unsetenv("SESSION_KEY")
|
||||
}
|
||||
|
||||
if tt.dburl != "" {
|
||||
os.Setenv("DBURL", tt.dburl)
|
||||
defer os.Unsetenv("DBURL")
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
// Use regular fx.New for error cases
|
||||
var cfg *Config
|
||||
app := fx.New(
|
||||
fx.NopLogger, // Suppress fx logs in tests
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
assert.Error(t, app.Err())
|
||||
} else {
|
||||
// Use fxtest for success cases
|
||||
var cfg *Config
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
require.NoError(t, app.Err())
|
||||
app.RequireStart()
|
||||
defer app.RequireStop()
|
||||
|
||||
if tt.environment == "dev" && tt.sessionKey == "" {
|
||||
// Dev mode with no session key uses default
|
||||
assert.Equal(t, DevSessionKey, cfg.SessionKey)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedKey, cfg.SessionKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,15 @@ import (
|
||||
// This replaces gorm.Model but uses UUID instead of uint for ID
|
||||
type BaseModel struct {
|
||||
ID string `gorm:"type:uuid;primary_key" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt,omitzero"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to set UUID before creating a record.
|
||||
func (b *BaseModel) BeforeCreate(_ *gorm.DB) error {
|
||||
// BeforeCreate hook to set UUID before creating a record
|
||||
func (b *BaseModel) BeforeCreate(tx *gorm.DB) error {
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+31
-160
@@ -1,61 +1,42 @@
|
||||
// Package database provides SQLite persistence for webhooks, events, and users.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
dataDirPerm = 0750
|
||||
randomPasswordLen = 16
|
||||
sessionKeyLen = 32
|
||||
)
|
||||
|
||||
//nolint:revive // DatabaseParams is a standard fx naming convention.
|
||||
// nolint:revive // DatabaseParams is a standard fx naming convention
|
||||
type DatabaseParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Database manages the main SQLite connection and schema migrations.
|
||||
type Database struct {
|
||||
db *gorm.DB
|
||||
log *slog.Logger
|
||||
params *DatabaseParams
|
||||
}
|
||||
|
||||
// New creates a Database that connects on fx start and disconnects on stop.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params DatabaseParams,
|
||||
) (*Database, error) {
|
||||
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
|
||||
d := &Database{
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
|
||||
return d.connect()
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
OnStop: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
|
||||
return d.close()
|
||||
},
|
||||
})
|
||||
@@ -63,92 +44,17 @@ func New(
|
||||
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 {
|
||||
// Ensure the data directory exists before opening the database.
|
||||
dataDir := d.params.Config.DataDir
|
||||
|
||||
err := os.MkdirAll(dataDir, dataDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"creating data directory %s: %w",
|
||||
dataDir,
|
||||
err,
|
||||
)
|
||||
dbURL := d.params.Config.DBURL
|
||||
if dbURL == "" {
|
||||
// Default to SQLite for development
|
||||
dbURL = "file:webhooker.db?cache=shared&mode=rwc"
|
||||
}
|
||||
|
||||
// Construct the main application database path inside DATA_DIR.
|
||||
dbPath := filepath.Join(dataDir, "webhooker.db")
|
||||
dbURL := fmt.Sprintf(
|
||||
"file:%s?cache=shared&mode=rwc",
|
||||
dbPath,
|
||||
)
|
||||
|
||||
// Open the database with the pure Go SQLite driver
|
||||
// First, open the database with the pure Go driver
|
||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
d.log.Error(
|
||||
"failed to open database",
|
||||
"error", err,
|
||||
)
|
||||
|
||||
d.log.Error("failed to open database", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -157,16 +63,12 @@ func (d *Database) connect() error {
|
||||
Conn: sqlDB,
|
||||
}, &gorm.Config{})
|
||||
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
|
||||
}
|
||||
|
||||
d.db = db
|
||||
d.log.Info("connected to database", "path", dbPath)
|
||||
d.log.Info("connected to database", "database", dbURL)
|
||||
|
||||
// Run migrations
|
||||
return d.migrate()
|
||||
@@ -174,62 +76,34 @@ func (d *Database) connect() error {
|
||||
|
||||
func (d *Database) migrate() error {
|
||||
// Run GORM auto-migrations
|
||||
err := d.Migrate()
|
||||
if err != nil {
|
||||
d.log.Error(
|
||||
"failed to run database migrations",
|
||||
"error", err,
|
||||
)
|
||||
|
||||
if err := d.Migrate(); err != nil {
|
||||
d.log.Error("failed to run database migrations", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
d.log.Info("database migrations completed")
|
||||
|
||||
// Check if admin user exists
|
||||
var userCount int64
|
||||
|
||||
err = d.db.Model(&User{}).Count(&userCount).Error
|
||||
if err != nil {
|
||||
d.log.Error(
|
||||
"failed to count users",
|
||||
"error", err,
|
||||
)
|
||||
|
||||
if err := d.db.Model(&User{}).Count(&userCount).Error; err != nil {
|
||||
d.log.Error("failed to count users", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if userCount == 0 {
|
||||
return d.createAdminUser()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) createAdminUser() error {
|
||||
// Create admin user
|
||||
d.log.Info("no users found, creating admin user")
|
||||
|
||||
// Generate random password
|
||||
password, err := GenerateRandomPassword(
|
||||
randomPasswordLen,
|
||||
)
|
||||
password, err := GenerateRandomPassword(16)
|
||||
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
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := HashPassword(password)
|
||||
if err != nil {
|
||||
d.log.Error(
|
||||
"failed to hash password",
|
||||
"error", err,
|
||||
)
|
||||
|
||||
d.log.Error("failed to hash password", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -239,22 +113,17 @@ func (d *Database) createAdminUser() error {
|
||||
Password: hashedPassword,
|
||||
}
|
||||
|
||||
err = d.db.Create(adminUser).Error
|
||||
if err != nil {
|
||||
d.log.Error(
|
||||
"failed to create admin user",
|
||||
"error", err,
|
||||
)
|
||||
|
||||
if err := d.db.Create(adminUser).Error; err != nil {
|
||||
d.log.Error("failed to create admin user", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Log the password - this will only happen once on first startup
|
||||
d.log.Info("admin user created",
|
||||
"username", "admin",
|
||||
"password", password,
|
||||
"message",
|
||||
"SAVE THIS PASSWORD - it will not be shown again!",
|
||||
)
|
||||
"message", "SAVE THIS PASSWORD - it will not be shown again!")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -265,9 +134,11 @@ func (d *Database) close() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DB() *gorm.DB {
|
||||
return d.db
|
||||
}
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
package database_test
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
func setupTestDB(
|
||||
t *testing.T,
|
||||
) (*database.Database, *fxtest.Lifecycle) {
|
||||
t.Helper()
|
||||
|
||||
func TestDatabaseConnection(t *testing.T) {
|
||||
// Set up test dependencies
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
g := &globals.Globals{
|
||||
Appname: "webhooker-test",
|
||||
Version: "test",
|
||||
// Create globals
|
||||
globals.Appname = "webhooker-test"
|
||||
globals.Version = "test"
|
||||
globals.Buildarch = "test"
|
||||
|
||||
g, err := globals.New(lc)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create globals: %v", err)
|
||||
}
|
||||
|
||||
l, err := logger.New(
|
||||
lc,
|
||||
logger.LoggerParams{Globals: g},
|
||||
)
|
||||
// Create logger
|
||||
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create logger: %v", err)
|
||||
}
|
||||
|
||||
c := &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
Environment: "dev",
|
||||
// Create config
|
||||
c, err := config.New(lc, config.ConfigParams{
|
||||
Globals: g,
|
||||
Logger: l,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.New(lc, database.DatabaseParams{
|
||||
// Set test database URL
|
||||
c.DBURL = "file:test.db?cache=shared&mode=rwc"
|
||||
|
||||
// Create database
|
||||
db, err := New(lc, DatabaseParams{
|
||||
Config: c,
|
||||
Logger: l,
|
||||
})
|
||||
@@ -44,45 +51,31 @@ func setupTestDB(
|
||||
t.Fatalf("Failed to create database: %v", err)
|
||||
}
|
||||
|
||||
return db, lc
|
||||
}
|
||||
|
||||
func TestDatabaseConnection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, lc := setupTestDB(t)
|
||||
// Start lifecycle (this will trigger the connection)
|
||||
ctx := context.Background()
|
||||
|
||||
err := lc.Start(ctx)
|
||||
err = lc.Start(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
stopErr := lc.Stop(ctx)
|
||||
if stopErr != nil {
|
||||
t.Errorf(
|
||||
"Failed to stop lifecycle: %v",
|
||||
stopErr,
|
||||
)
|
||||
if stopErr := lc.Stop(ctx); stopErr != nil {
|
||||
t.Errorf("Failed to stop lifecycle: %v", stopErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Verify we can get the DB instance
|
||||
if db.DB() == nil {
|
||||
t.Error("Expected non-nil database connection")
|
||||
}
|
||||
|
||||
// Test that we can perform a simple query
|
||||
var result int
|
||||
|
||||
err = db.DB().Raw("SELECT 1").Scan(&result).Error
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute test query: %v", err)
|
||||
}
|
||||
|
||||
if result != 1 {
|
||||
t.Errorf(
|
||||
"Expected query result to be 1, got %d",
|
||||
result,
|
||||
)
|
||||
t.Errorf("Expected query result to be 1, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import "time"
|
||||
type APIKey struct {
|
||||
BaseModel
|
||||
|
||||
UserID string `gorm:"type:uuid;not null" json:"userId"`
|
||||
UserID string `gorm:"type:uuid;not null" json:"user_id"`
|
||||
Key string `gorm:"uniqueIndex;not null" json:"key"`
|
||||
Description string `json:"description"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
|
||||
// Relations
|
||||
User User `json:"user,omitzero"`
|
||||
User User `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package database
|
||||
// DeliveryStatus represents the status of a delivery
|
||||
type DeliveryStatus string
|
||||
|
||||
// Delivery status values.
|
||||
const (
|
||||
DeliveryStatusPending DeliveryStatus = "pending"
|
||||
DeliveryStatusDelivered DeliveryStatus = "delivered"
|
||||
@@ -15,12 +14,12 @@ const (
|
||||
type Delivery struct {
|
||||
BaseModel
|
||||
|
||||
EventID string `gorm:"type:uuid;not null" json:"eventId"`
|
||||
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||
EventID string `gorm:"type:uuid;not null" json:"event_id"`
|
||||
TargetID string `gorm:"type:uuid;not null" json:"target_id"`
|
||||
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
|
||||
|
||||
// Relations
|
||||
Event Event `json:"event,omitzero"`
|
||||
Target Target `json:"target,omitzero"`
|
||||
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
|
||||
Event Event `json:"event,omitempty"`
|
||||
Target Target `json:"target,omitempty"`
|
||||
DeliveryResults []DeliveryResult `json:"delivery_results,omitempty"`
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ package database
|
||||
type DeliveryResult struct {
|
||||
BaseModel
|
||||
|
||||
DeliveryID string `gorm:"type:uuid;not null" json:"deliveryId"`
|
||||
AttemptNum int `gorm:"not null" json:"attemptNum"`
|
||||
DeliveryID string `gorm:"type:uuid;not null" json:"delivery_id"`
|
||||
AttemptNum int `gorm:"not null" json:"attempt_num"`
|
||||
Success bool `json:"success"`
|
||||
StatusCode int `json:"statusCode,omitempty"`
|
||||
ResponseBody string `gorm:"type:text" json:"responseBody,omitempty"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
ResponseBody string `gorm:"type:text" json:"response_body,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"durationMs"` // Duration in milliseconds
|
||||
Duration int64 `json:"duration_ms"` // Duration in milliseconds
|
||||
|
||||
// Relations
|
||||
Delivery Delivery `json:"delivery,omitzero"`
|
||||
Delivery Delivery `json:"delivery,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package database
|
||||
|
||||
// Entrypoint represents an inbound URL endpoint that feeds into a webhook
|
||||
type Entrypoint struct {
|
||||
BaseModel
|
||||
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint
|
||||
Description string `json:"description"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
package database
|
||||
|
||||
// Event represents a captured webhook event
|
||||
// Event represents a webhook event
|
||||
type Event struct {
|
||||
BaseModel
|
||||
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
||||
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
|
||||
|
||||
// Request data
|
||||
Method string `gorm:"not null" json:"method"`
|
||||
Headers string `gorm:"type:text" json:"headers"` // JSON
|
||||
Body string `gorm:"type:text" json:"body"`
|
||||
ContentType string `json:"contentType"`
|
||||
ContentType string `json:"content_type"`
|
||||
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
|
||||
Processor Processor `json:"processor,omitempty"`
|
||||
Webhook Webhook `json:"webhook,omitempty"`
|
||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package database
|
||||
|
||||
// Processor represents an event processor
|
||||
type Processor struct {
|
||||
BaseModel
|
||||
|
||||
UserID string `gorm:"type:uuid;not null" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events
|
||||
|
||||
// Relations
|
||||
User User `json:"user,omitempty"`
|
||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package database
|
||||
|
||||
// Setting stores application-level key-value configuration.
|
||||
// Used for auto-generated values like the session encryption key.
|
||||
type Setting struct {
|
||||
Key string `gorm:"primaryKey" json:"key"`
|
||||
Value string `gorm:"type:text;not null" json:"value"`
|
||||
}
|
||||
@@ -3,19 +3,18 @@ package database
|
||||
// TargetType represents the type of delivery target
|
||||
type TargetType string
|
||||
|
||||
// Target type values.
|
||||
const (
|
||||
TargetTypeHTTP TargetType = "http"
|
||||
TargetTypeRetry TargetType = "retry"
|
||||
TargetTypeDatabase TargetType = "database"
|
||||
TargetTypeLog TargetType = "log"
|
||||
TargetTypeSlack TargetType = "slack"
|
||||
)
|
||||
|
||||
// Target represents a delivery target for a webhook
|
||||
// Target represents a delivery target for a processor
|
||||
type Target struct {
|
||||
BaseModel
|
||||
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type TargetType `gorm:"not null" json:"type"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
@@ -23,11 +22,11 @@ type Target struct {
|
||||
// Configuration fields (JSON stored based on type)
|
||||
Config string `gorm:"type:text" json:"config"` // JSON configuration
|
||||
|
||||
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff)
|
||||
MaxRetries int `json:"maxRetries,omitempty"`
|
||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
||||
// For retry targets
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
MaxQueueSize int `json:"max_queue_size,omitempty"`
|
||||
|
||||
// Relations
|
||||
Webhook Webhook `json:"webhook,omitzero"`
|
||||
Processor Processor `json:"processor,omitempty"`
|
||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ type User struct {
|
||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||
|
||||
// Relations
|
||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
||||
Processors []Processor `json:"processors,omitempty"`
|
||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
package database
|
||||
|
||||
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
||||
// Webhook represents a webhook endpoint that feeds into a processor
|
||||
type Webhook struct {
|
||||
BaseModel
|
||||
|
||||
UserID string `gorm:"type:uuid;not null" json:"userId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
|
||||
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this webhook
|
||||
Description string `json:"description"`
|
||||
RetentionDays int `gorm:"default:30" json:"retentionDays"` // Days to retain events
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
|
||||
// Relations
|
||||
User User `json:"user,omitzero"`
|
||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
Processor Processor `json:"processor,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package database
|
||||
|
||||
// Migrate runs database migrations for the main application database.
|
||||
// Only configuration-tier models are stored in the main database.
|
||||
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||
// Migrate runs database migrations for all models
|
||||
func (d *Database) Migrate() error {
|
||||
return d.db.AutoMigrate(
|
||||
&Setting{},
|
||||
&User{},
|
||||
&APIKey{},
|
||||
&Processor{},
|
||||
&Webhook{},
|
||||
&Entrypoint{},
|
||||
&Target{},
|
||||
&Event{},
|
||||
&Delivery{},
|
||||
&DeliveryResult{},
|
||||
)
|
||||
}
|
||||
|
||||
+45
-141
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
@@ -21,23 +20,6 @@ const (
|
||||
argon2SaltLen = 16
|
||||
)
|
||||
|
||||
// hashParts is the expected number of $-separated segments
|
||||
// in an encoded Argon2id hash string.
|
||||
const hashParts = 6
|
||||
|
||||
// minPasswordComplexityLen is the minimum password length that
|
||||
// triggers per-character-class complexity enforcement.
|
||||
const minPasswordComplexityLen = 4
|
||||
|
||||
// Sentinel errors returned by decodeHash.
|
||||
var (
|
||||
errInvalidHashFormat = errors.New("invalid hash format")
|
||||
errInvalidAlgorithm = errors.New("invalid algorithm")
|
||||
errIncompatibleVersion = errors.New("incompatible argon2 version")
|
||||
errSaltLengthOutOfRange = errors.New("salt length out of range")
|
||||
errHashLengthOutOfRange = errors.New("hash length out of range")
|
||||
)
|
||||
|
||||
// PasswordConfig holds Argon2 configuration
|
||||
type PasswordConfig struct {
|
||||
Time uint32
|
||||
@@ -64,44 +46,26 @@ func HashPassword(password string) (string, error) {
|
||||
|
||||
// Generate a salt
|
||||
salt := make([]byte, config.SaltLen)
|
||||
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate the hash
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
config.Time,
|
||||
config.Memory,
|
||||
config.Threads,
|
||||
config.KeyLen,
|
||||
)
|
||||
hash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
|
||||
|
||||
// Encode the hash and parameters
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
// Format: $argon2id$v=19$m=65536,t=1,p=4$salt$hash
|
||||
encoded := fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
config.Memory,
|
||||
config.Time,
|
||||
config.Threads,
|
||||
b64Salt,
|
||||
b64Hash,
|
||||
)
|
||||
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, config.Memory, config.Time, config.Threads, b64Salt, b64Hash)
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if the provided password matches the hash
|
||||
func VerifyPassword(
|
||||
password, encodedHash string,
|
||||
) (bool, error) {
|
||||
func VerifyPassword(password, encodedHash string) (bool, error) {
|
||||
// Extract parameters and hash from encoded string
|
||||
config, salt, hash, err := decodeHash(encodedHash)
|
||||
if err != nil {
|
||||
@@ -109,119 +73,60 @@ func VerifyPassword(
|
||||
}
|
||||
|
||||
// Generate hash of the provided password
|
||||
otherHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
config.Time,
|
||||
config.Memory,
|
||||
config.Threads,
|
||||
config.KeyLen,
|
||||
)
|
||||
otherHash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
|
||||
|
||||
// Compare hashes using constant time comparison
|
||||
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
|
||||
}
|
||||
|
||||
// decodeHash extracts parameters, salt, and hash from an
|
||||
// encoded hash string.
|
||||
func decodeHash(
|
||||
encodedHash string,
|
||||
) (*PasswordConfig, []byte, []byte, error) {
|
||||
// decodeHash extracts parameters, salt, and hash from an encoded hash string
|
||||
func decodeHash(encodedHash string) (*PasswordConfig, []byte, []byte, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != hashParts {
|
||||
return nil, nil, nil, errInvalidHashFormat
|
||||
if len(parts) != 6 {
|
||||
return nil, nil, nil, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
|
||||
if parts[1] != "argon2id" {
|
||||
return nil, nil, nil, errInvalidAlgorithm
|
||||
return nil, nil, nil, fmt.Errorf("invalid algorithm")
|
||||
}
|
||||
|
||||
version, err := parseVersion(parts[2])
|
||||
if err != nil {
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if version != argon2.Version {
|
||||
return nil, nil, nil, errIncompatibleVersion
|
||||
return nil, nil, nil, fmt.Errorf("incompatible argon2 version")
|
||||
}
|
||||
|
||||
config, err := parseParams(parts[3])
|
||||
if err != nil {
|
||||
config := &PasswordConfig{}
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &config.Memory, &config.Time, &config.Threads); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
salt, err := decodeSalt(parts[4])
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
saltLen := len(salt)
|
||||
if saltLen < 0 || saltLen > int(^uint32(0)) {
|
||||
return nil, nil, nil, fmt.Errorf("salt length out of range")
|
||||
}
|
||||
config.SaltLen = uint32(saltLen) // nolint:gosec // checked above
|
||||
|
||||
config.SaltLen = uint32(len(salt)) //nolint:gosec // validated in decodeSalt
|
||||
|
||||
hash, err := decodeHashBytes(parts[5])
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
config.KeyLen = uint32(len(hash)) //nolint:gosec // validated in decodeHashBytes
|
||||
hashLen := len(hash)
|
||||
if hashLen < 0 || hashLen > int(^uint32(0)) {
|
||||
return nil, nil, nil, fmt.Errorf("hash length out of range")
|
||||
}
|
||||
config.KeyLen = uint32(hashLen) // nolint:gosec // checked above
|
||||
|
||||
return config, salt, hash, nil
|
||||
}
|
||||
|
||||
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.
|
||||
// GenerateRandomPassword generates a cryptographically secure random password
|
||||
func GenerateRandomPassword(length int) (string, error) {
|
||||
const (
|
||||
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
@@ -236,27 +141,27 @@ func GenerateRandomPassword(length int) (string, error) {
|
||||
// Create password slice
|
||||
password := make([]byte, length)
|
||||
|
||||
// Ensure at least one character from each set
|
||||
if length >= minPasswordComplexityLen {
|
||||
// Ensure at least one character from each set for password complexity
|
||||
if length >= 4 {
|
||||
// Get one character from each set
|
||||
password[0] = uppercase[cryptoRandInt(len(uppercase))]
|
||||
password[1] = lowercase[cryptoRandInt(len(lowercase))]
|
||||
password[2] = digits[cryptoRandInt(len(digits))]
|
||||
password[3] = special[cryptoRandInt(len(special))]
|
||||
|
||||
// Fill the rest randomly from all characters
|
||||
for i := minPasswordComplexityLen; i < length; i++ {
|
||||
for i := 4; i < length; i++ {
|
||||
password[i] = allChars[cryptoRandInt(len(allChars))]
|
||||
}
|
||||
|
||||
// Shuffle the password to avoid predictable pattern
|
||||
for i := range len(password) - 1 {
|
||||
j := cryptoRandInt(len(password) - i)
|
||||
idx := len(password) - 1 - i
|
||||
password[idx], password[j] = password[j], password[idx]
|
||||
for i := len(password) - 1; i > 0; i-- {
|
||||
j := cryptoRandInt(i + 1)
|
||||
password[i], password[j] = password[j], password[i]
|
||||
}
|
||||
} else {
|
||||
// For very short passwords, just use all characters
|
||||
for i := range length {
|
||||
for i := 0; i < length; i++ {
|
||||
password[i] = allChars[cryptoRandInt(len(allChars))]
|
||||
}
|
||||
}
|
||||
@@ -264,17 +169,16 @@ func GenerateRandomPassword(length int) (string, error) {
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
// cryptoRandInt generates a cryptographically secure random
|
||||
// integer in [0, upperBound).
|
||||
func cryptoRandInt(upperBound int) int {
|
||||
if upperBound <= 0 {
|
||||
panic("upperBound must be positive")
|
||||
// cryptoRandInt generates a cryptographically secure random integer in [0, max)
|
||||
func cryptoRandInt(max int) int {
|
||||
if max <= 0 {
|
||||
panic("max must be positive")
|
||||
}
|
||||
|
||||
nBig, err := rand.Int(
|
||||
rand.Reader,
|
||||
big.NewInt(int64(upperBound)),
|
||||
)
|
||||
// Calculate the maximum valid value to avoid modulo bias
|
||||
// For example, if max=200 and we have 256 possible values,
|
||||
// we only accept values 0-199 (reject 200-255)
|
||||
nBig, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("crypto/rand error: %v", err))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
package database_test
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
func TestGenerateRandomPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
@@ -22,172 +18,109 @@ func TestGenerateRandomPassword(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password, err := database.GenerateRandomPassword(
|
||||
tt.length,
|
||||
)
|
||||
password, err := GenerateRandomPassword(tt.length)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"GenerateRandomPassword() error = %v",
|
||||
err,
|
||||
)
|
||||
t.Fatalf("GenerateRandomPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if len(password) != tt.length {
|
||||
t.Errorf(
|
||||
"Password length = %v, want %v",
|
||||
len(password), tt.length,
|
||||
)
|
||||
t.Errorf("Password length = %v, want %v", len(password), tt.length)
|
||||
}
|
||||
|
||||
checkPasswordComplexity(
|
||||
t, password, tt.length,
|
||||
)
|
||||
// For passwords >= 4 chars, check complexity
|
||||
if tt.length >= 4 {
|
||||
hasUpper := false
|
||||
hasLower := false
|
||||
hasDigit := false
|
||||
hasSpecial := false
|
||||
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case char >= 'A' && char <= 'Z':
|
||||
hasUpper = true
|
||||
case char >= 'a' && char <= 'z':
|
||||
hasLower = true
|
||||
case char >= '0' && char <= '9':
|
||||
hasDigit = true
|
||||
case strings.ContainsRune("!@#$%^&*()_+-=[]{}|;:,.<>?", char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
|
||||
t.Errorf("Password lacks required complexity: upper=%v, lower=%v, digit=%v, special=%v",
|
||||
hasUpper, hasLower, hasDigit, hasSpecial)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkPasswordComplexity(
|
||||
t *testing.T,
|
||||
password string,
|
||||
length int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// For passwords >= 4 chars, check complexity
|
||||
if length < 4 {
|
||||
return
|
||||
}
|
||||
|
||||
flags := classifyChars(password)
|
||||
|
||||
if !flags[0] || !flags[1] || !flags[2] || !flags[3] {
|
||||
t.Errorf(
|
||||
"Password lacks required complexity: "+
|
||||
"upper=%v, lower=%v, digit=%v, special=%v",
|
||||
flags[0], flags[1], flags[2], flags[3],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyChars(s string) [4]bool {
|
||||
var flags [4]bool // upper, lower, digit, special
|
||||
|
||||
for _, char := range s {
|
||||
switch {
|
||||
case char >= 'A' && char <= 'Z':
|
||||
flags[0] = true
|
||||
case char >= 'a' && char <= 'z':
|
||||
flags[1] = true
|
||||
case char >= '0' && char <= '9':
|
||||
flags[2] = true
|
||||
case strings.ContainsRune(
|
||||
"!@#$%^&*()_+-=[]{}|;:,.<>?",
|
||||
char,
|
||||
):
|
||||
flags[3] = true
|
||||
}
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
func TestGenerateRandomPasswordUniqueness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate multiple passwords and ensure they're different
|
||||
passwords := make(map[string]bool)
|
||||
|
||||
const numPasswords = 100
|
||||
|
||||
for range numPasswords {
|
||||
password, err := database.GenerateRandomPassword(16)
|
||||
for i := 0; i < numPasswords; i++ {
|
||||
password, err := GenerateRandomPassword(16)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"GenerateRandomPassword() error = %v",
|
||||
err,
|
||||
)
|
||||
t.Fatalf("GenerateRandomPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if passwords[password] {
|
||||
t.Errorf(
|
||||
"Duplicate password generated: %s",
|
||||
password,
|
||||
)
|
||||
t.Errorf("Duplicate password generated: %s", password)
|
||||
}
|
||||
|
||||
passwords[password] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password := "testPassword123!"
|
||||
|
||||
hash, err := database.HashPassword(password)
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that hash has correct format
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf(
|
||||
"Hash doesn't have correct prefix: %s",
|
||||
hash,
|
||||
)
|
||||
t.Errorf("Hash doesn't have correct prefix: %s", hash)
|
||||
}
|
||||
|
||||
// Verify password
|
||||
valid, err := database.VerifyPassword(password, hash)
|
||||
valid, err := VerifyPassword(password, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
t.Error(
|
||||
"VerifyPassword() returned false " +
|
||||
"for correct password",
|
||||
)
|
||||
t.Error("VerifyPassword() returned false for correct password")
|
||||
}
|
||||
|
||||
// Verify wrong password fails
|
||||
valid, err = database.VerifyPassword(
|
||||
"wrongPassword", hash,
|
||||
)
|
||||
valid, err = VerifyPassword("wrongPassword", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if valid {
|
||||
t.Error(
|
||||
"VerifyPassword() returned true " +
|
||||
"for wrong password",
|
||||
)
|
||||
t.Error("VerifyPassword() returned true for wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordUniqueness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password := "testPassword123!"
|
||||
|
||||
// Same password should produce different hashes
|
||||
hash1, err := database.HashPassword(password)
|
||||
// Same password should produce different hashes due to salt
|
||||
hash1, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
hash2, err := database.HashPassword(password)
|
||||
hash2, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Error(
|
||||
"Same password produced identical hashes " +
|
||||
"(salt not working)",
|
||||
)
|
||||
t.Error("Same password produced identical hashes (salt not working)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB.
|
||||
// Intended for use in tests that need a *database.Database without the
|
||||
// full fx lifecycle. The caller is responsible for closing the underlying
|
||||
// sql.DB connection.
|
||||
func NewTestDatabase(db *gorm.DB) *Database {
|
||||
return &Database{
|
||||
db: db,
|
||||
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestWebhookDBManager creates a WebhookDBManager backed by the given
|
||||
// data directory. Intended for use in tests without the fx lifecycle.
|
||||
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
|
||||
return &WebhookDBManager{
|
||||
dataDir: dataDir,
|
||||
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
// WebhookDBManagerParams holds the fx dependencies for
|
||||
// WebhookDBManager.
|
||||
type WebhookDBManagerParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// errInvalidCachedDBType indicates a type assertion failure
|
||||
// when retrieving a cached database connection.
|
||||
var errInvalidCachedDBType = errors.New(
|
||||
"invalid cached database type",
|
||||
)
|
||||
|
||||
// WebhookDBManager manages per-webhook SQLite database files
|
||||
// for event storage. Each webhook gets its own dedicated
|
||||
// database containing Events, Deliveries, and DeliveryResults.
|
||||
// Database connections are opened lazily and cached.
|
||||
type WebhookDBManager struct {
|
||||
dataDir string
|
||||
dbs sync.Map // map[webhookID]*gorm.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebhookDBManager creates a new WebhookDBManager and
|
||||
// registers lifecycle hooks.
|
||||
func NewWebhookDBManager(
|
||||
lc fx.Lifecycle,
|
||||
params WebhookDBManagerParams,
|
||||
) (*WebhookDBManager, error) {
|
||||
m := &WebhookDBManager{
|
||||
dataDir: params.Config.DataDir,
|
||||
log: params.Logger.Get(),
|
||||
}
|
||||
|
||||
// Create data directory if it doesn't exist
|
||||
err := os.MkdirAll(m.dataDir, dataDirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"creating data directory %s: %w",
|
||||
m.dataDir,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStop: func(_ context.Context) error {
|
||||
return m.CloseAll()
|
||||
},
|
||||
})
|
||||
|
||||
m.log.Info(
|
||||
"webhook database manager initialized",
|
||||
"data_dir", m.dataDir,
|
||||
)
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetDB returns the database connection for a webhook,
|
||||
// creating the database file lazily if it doesn't exist.
|
||||
func (m *WebhookDBManager) GetDB(
|
||||
webhookID string,
|
||||
) (*gorm.DB, error) {
|
||||
// Fast path: already open
|
||||
if val, ok := m.dbs.Load(webhookID); ok {
|
||||
cachedDB, castOK := val.(*gorm.DB)
|
||||
if !castOK {
|
||||
return nil, fmt.Errorf(
|
||||
"%w for webhook %s",
|
||||
errInvalidCachedDBType,
|
||||
webhookID,
|
||||
)
|
||||
}
|
||||
|
||||
return cachedDB, nil
|
||||
}
|
||||
|
||||
// Slow path: open/create the database
|
||||
db, err := m.openDB(webhookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store it; if another goroutine beat us, close ours
|
||||
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
|
||||
if loaded {
|
||||
// Another goroutine created it first; close our duplicate
|
||||
sqlDB, closeErr := db.DB()
|
||||
if closeErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
|
||||
existingDB, castOK := actual.(*gorm.DB)
|
||||
if !castOK {
|
||||
return nil, fmt.Errorf(
|
||||
"%w for webhook %s",
|
||||
errInvalidCachedDBType,
|
||||
webhookID,
|
||||
)
|
||||
}
|
||||
|
||||
return existingDB, nil
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// CreateDB explicitly creates a new per-webhook database file
|
||||
// and runs migrations.
|
||||
func (m *WebhookDBManager) CreateDB(
|
||||
webhookID string,
|
||||
) error {
|
||||
_, err := m.GetDB(webhookID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DBExists checks if a per-webhook database file exists on
|
||||
// disk.
|
||||
func (m *WebhookDBManager) DBExists(
|
||||
webhookID string,
|
||||
) bool {
|
||||
_, err := os.Stat(m.dbPath(webhookID))
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DeleteDB closes the connection and deletes the database file
|
||||
// for a webhook. The file is permanently removed.
|
||||
func (m *WebhookDBManager) DeleteDB(
|
||||
webhookID string,
|
||||
) error {
|
||||
// Close and remove from cache
|
||||
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
|
||||
if gormDB, castOK := val.(*gorm.DB); castOK {
|
||||
sqlDB, err := gormDB.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the main DB file and WAL/SHM files
|
||||
path := m.dbPath(webhookID)
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
err := os.Remove(path + suffix)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf(
|
||||
"deleting webhook database file %s%s: %w",
|
||||
path, suffix, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
m.log.Info(
|
||||
"deleted per-webhook database",
|
||||
"webhook_id", webhookID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseAll closes all open per-webhook database connections.
|
||||
// Called during application shutdown.
|
||||
func (m *WebhookDBManager) CloseAll() error {
|
||||
var lastErr error
|
||||
|
||||
m.dbs.Range(func(key, value any) bool {
|
||||
if gormDB, castOK := value.(*gorm.DB); castOK {
|
||||
sqlDB, err := gormDB.DB()
|
||||
if err == nil {
|
||||
closeErr := sqlDB.Close()
|
||||
if closeErr != nil {
|
||||
lastErr = closeErr
|
||||
m.log.Error(
|
||||
"failed to close webhook database",
|
||||
"webhook_id", key,
|
||||
"error", closeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.dbs.Delete(key)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// DBPath returns the filesystem path for a webhook's database
|
||||
// file.
|
||||
func (m *WebhookDBManager) DBPath(
|
||||
webhookID string,
|
||||
) string {
|
||||
return m.dbPath(webhookID)
|
||||
}
|
||||
|
||||
func (m *WebhookDBManager) dbPath(
|
||||
webhookID string,
|
||||
) string {
|
||||
return filepath.Join(
|
||||
m.dataDir,
|
||||
fmt.Sprintf("events-%s.db", webhookID),
|
||||
)
|
||||
}
|
||||
|
||||
// openDB opens (or creates) a per-webhook SQLite database and
|
||||
// runs migrations.
|
||||
func (m *WebhookDBManager) openDB(
|
||||
webhookID string,
|
||||
) (*gorm.DB, error) {
|
||||
path := m.dbPath(webhookID)
|
||||
dbURL := fmt.Sprintf(
|
||||
"file:%s?cache=shared&mode=rwc",
|
||||
path,
|
||||
)
|
||||
|
||||
sqlDB, err := sql.Open("sqlite", dbURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"opening webhook database %s: %w",
|
||||
webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Dialector{
|
||||
Conn: sqlDB,
|
||||
}, &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"connecting to webhook database %s: %w",
|
||||
webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Run migrations for event-tier models only
|
||||
err = db.AutoMigrate(
|
||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"migrating webhook database %s: %w",
|
||||
webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
m.log.Info(
|
||||
"opened per-webhook database",
|
||||
"webhook_id", webhookID,
|
||||
"path", path,
|
||||
)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
func setupTestWebhookDBManager(
|
||||
t *testing.T,
|
||||
) (*database.WebhookDBManager, *fxtest.Lifecycle) {
|
||||
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)
|
||||
|
||||
dataDir := filepath.Join(t.TempDir(), "events")
|
||||
|
||||
cfg := &config.Config{
|
||||
DataDir: dataDir,
|
||||
}
|
||||
|
||||
mgr, err := database.NewWebhookDBManager(
|
||||
lc,
|
||||
database.WebhookDBManagerParams{
|
||||
Config: cfg,
|
||||
Logger: l,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return mgr, lc
|
||||
}
|
||||
|
||||
func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
// DB should not exist yet
|
||||
assert.False(t, mgr.DBExists(webhookID))
|
||||
|
||||
// Create the DB
|
||||
err := mgr.CreateDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// DB file should now exist
|
||||
assert.True(t, mgr.DBExists(webhookID))
|
||||
|
||||
// Get the DB again (should use cached connection)
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Verify we can write an event
|
||||
event := &database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: "POST",
|
||||
Headers: `{"Content-Type":["application/json"]}`,
|
||||
Body: `{"test": true}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
require.NoError(t, db.Create(event).Error)
|
||||
assert.NotEmpty(t, event.ID)
|
||||
|
||||
// Verify we can read it back
|
||||
var readEvent database.Event
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.First(&readEvent, "id = ?", event.ID).Error,
|
||||
)
|
||||
assert.Equal(t, webhookID, readEvent.WebhookID)
|
||||
assert.Equal(t, "POST", readEvent.Method)
|
||||
assert.Equal(t, `{"test": true}`, readEvent.Body)
|
||||
}
|
||||
|
||||
func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
// Create the DB and write some data
|
||||
require.NoError(t, mgr.CreateDB(webhookID))
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: "POST",
|
||||
Body: `{"test": true}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
require.NoError(t, db.Create(event).Error)
|
||||
|
||||
// Delete the DB
|
||||
require.NoError(t, mgr.DeleteDB(webhookID))
|
||||
|
||||
// File should no longer exist
|
||||
assert.False(t, mgr.DBExists(webhookID))
|
||||
|
||||
// Verify the file is actually gone from disk
|
||||
dbPath := mgr.DBPath(webhookID)
|
||||
|
||||
_, err = os.Stat(dbPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
// GetDB should lazily create the database
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// File should now exist
|
||||
assert.True(t, mgr.DBExists(webhookID))
|
||||
}
|
||||
|
||||
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
webhookID := uuid.New().String()
|
||||
targetID := uuid.New().String()
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
event, delivery := seedDeliveryWorkflow(
|
||||
t, db, webhookID, targetID,
|
||||
)
|
||||
|
||||
verifyPendingDeliveries(t, db, event)
|
||||
completeDelivery(t, db, delivery)
|
||||
verifyNoPending(t, db)
|
||||
}
|
||||
|
||||
func seedDeliveryWorkflow(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
webhookID, targetID string,
|
||||
) (*database.Event, *database.Delivery) {
|
||||
t.Helper()
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: "POST",
|
||||
Headers: `{"Content-Type":["application/json"]}`,
|
||||
Body: `{"payload": "test"}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
require.NoError(t, db.Create(event).Error)
|
||||
|
||||
delivery := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(delivery).Error)
|
||||
|
||||
return event, delivery
|
||||
}
|
||||
|
||||
func verifyPendingDeliveries(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
event *database.Event,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var pending []database.Delivery
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Where(
|
||||
"status = ?",
|
||||
database.DeliveryStatusPending,
|
||||
).Preload("Event").Find(&pending).Error,
|
||||
)
|
||||
require.Len(t, pending, 1)
|
||||
assert.Equal(t, event.ID, pending[0].EventID)
|
||||
assert.Equal(t, "POST", pending[0].Event.Method)
|
||||
}
|
||||
|
||||
func completeDelivery(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
delivery *database.Delivery,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
result := &database.DeliveryResult{
|
||||
DeliveryID: delivery.ID,
|
||||
AttemptNum: 1,
|
||||
Success: true,
|
||||
StatusCode: 200,
|
||||
Duration: 42,
|
||||
}
|
||||
require.NoError(t, db.Create(result).Error)
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Model(delivery).Update(
|
||||
"status",
|
||||
database.DeliveryStatusDelivered,
|
||||
).Error,
|
||||
)
|
||||
}
|
||||
|
||||
func verifyNoPending(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var stillPending []database.Delivery
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
db.Where(
|
||||
"status = ?",
|
||||
database.DeliveryStatusPending,
|
||||
).Find(&stillPending).Error,
|
||||
)
|
||||
assert.Empty(t, stillPending)
|
||||
}
|
||||
|
||||
func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||
|
||||
webhook1 := uuid.New().String()
|
||||
webhook2 := uuid.New().String()
|
||||
|
||||
// Create DBs for two webhooks
|
||||
require.NoError(t, mgr.CreateDB(webhook1))
|
||||
require.NoError(t, mgr.CreateDB(webhook2))
|
||||
|
||||
db1, err := mgr.GetDB(webhook1)
|
||||
require.NoError(t, err)
|
||||
|
||||
db2, err := mgr.GetDB(webhook2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write events to each webhook's DB
|
||||
event1 := &database.Event{
|
||||
WebhookID: webhook1,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: "POST",
|
||||
Body: `{"webhook": 1}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
event2 := &database.Event{
|
||||
WebhookID: webhook2,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: "PUT",
|
||||
Body: `{"webhook": 2}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
|
||||
require.NoError(t, db1.Create(event1).Error)
|
||||
require.NoError(t, db2.Create(event2).Error)
|
||||
|
||||
// Verify isolation: each DB only has its own events
|
||||
var count1 int64
|
||||
|
||||
db1.Model(&database.Event{}).Count(&count1)
|
||||
assert.Equal(t, int64(1), count1)
|
||||
|
||||
var count2 int64
|
||||
|
||||
db2.Model(&database.Event{}).Count(&count2)
|
||||
assert.Equal(t, int64(1), count2)
|
||||
|
||||
// Delete webhook1's DB, webhook2 should be unaffected
|
||||
require.NoError(t, mgr.DeleteDB(webhook1))
|
||||
assert.False(t, mgr.DBExists(webhook1))
|
||||
assert.True(t, mgr.DBExists(webhook2))
|
||||
|
||||
// webhook2's data should still be accessible
|
||||
var events []database.Event
|
||||
|
||||
require.NoError(t, db2.Find(&events).Error)
|
||||
assert.Len(t, events, 1)
|
||||
assert.Equal(t, "PUT", events[0].Method)
|
||||
}
|
||||
|
||||
func TestWebhookDBManager_CloseAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr, lc := setupTestWebhookDBManager(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, lc.Start(ctx))
|
||||
|
||||
// Create a few DBs
|
||||
for range 3 {
|
||||
require.NoError(
|
||||
t,
|
||||
mgr.CreateDB(uuid.New().String()),
|
||||
)
|
||||
}
|
||||
|
||||
// CloseAll should close all connections without error
|
||||
require.NoError(t, mgr.CloseAll())
|
||||
|
||||
// Stop lifecycle (CloseAll already called)
|
||||
require.NoError(t, lc.Stop(ctx))
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CircuitState represents the current state of a circuit
|
||||
// breaker.
|
||||
type CircuitState int
|
||||
|
||||
const (
|
||||
// CircuitClosed is the normal operating state.
|
||||
CircuitClosed CircuitState = iota
|
||||
// CircuitOpen means the circuit has tripped.
|
||||
CircuitOpen
|
||||
// CircuitHalfOpen allows a single probe delivery to
|
||||
// test whether the target has recovered.
|
||||
CircuitHalfOpen
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFailureThreshold is the number of consecutive
|
||||
// failures before a circuit breaker trips open.
|
||||
defaultFailureThreshold = 5
|
||||
|
||||
// defaultCooldown is how long a circuit stays open
|
||||
// before transitioning to half-open.
|
||||
defaultCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
// CircuitBreaker implements the circuit breaker pattern
|
||||
// for a single delivery target.
|
||||
type CircuitBreaker struct {
|
||||
mu sync.Mutex
|
||||
state CircuitState
|
||||
failures int
|
||||
threshold int
|
||||
cooldown time.Duration
|
||||
lastFailure time.Time
|
||||
}
|
||||
|
||||
// NewCircuitBreaker creates a circuit breaker with default
|
||||
// settings.
|
||||
func NewCircuitBreaker() *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
state: CircuitClosed,
|
||||
threshold: defaultFailureThreshold,
|
||||
cooldown: defaultCooldown,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks whether a delivery attempt should proceed.
|
||||
func (cb *CircuitBreaker) Allow() bool {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitClosed:
|
||||
return true
|
||||
|
||||
case CircuitOpen:
|
||||
if time.Since(cb.lastFailure) >= cb.cooldown {
|
||||
cb.state = CircuitHalfOpen
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
case CircuitHalfOpen:
|
||||
return false
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// CooldownRemaining returns how much time is left before
|
||||
// an open circuit transitions to half-open.
|
||||
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
if cb.state != CircuitOpen {
|
||||
return 0
|
||||
}
|
||||
|
||||
remaining := cb.cooldown - time.Since(cb.lastFailure)
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return remaining
|
||||
}
|
||||
|
||||
// RecordSuccess records a successful delivery and resets
|
||||
// the circuit breaker to closed state.
|
||||
func (cb *CircuitBreaker) RecordSuccess() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.failures = 0
|
||||
cb.state = CircuitClosed
|
||||
}
|
||||
|
||||
// RecordFailure records a failed delivery. If the failure
|
||||
// count reaches the threshold, the circuit trips open.
|
||||
func (cb *CircuitBreaker) RecordFailure() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.failures++
|
||||
cb.lastFailure = time.Now()
|
||||
|
||||
switch cb.state {
|
||||
case CircuitClosed:
|
||||
if cb.failures >= cb.threshold {
|
||||
cb.state = CircuitOpen
|
||||
}
|
||||
|
||||
case CircuitOpen:
|
||||
// Already open; no state change needed.
|
||||
|
||||
case CircuitHalfOpen:
|
||||
// Probe failed -- reopen immediately.
|
||||
cb.state = CircuitOpen
|
||||
}
|
||||
}
|
||||
|
||||
// State returns the current circuit state.
|
||||
func (cb *CircuitBreaker) State() CircuitState {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
return cb.state
|
||||
}
|
||||
|
||||
// String returns the human-readable name of a circuit
|
||||
// state.
|
||||
func (s CircuitState) String() string {
|
||||
switch s {
|
||||
case CircuitClosed:
|
||||
return "closed"
|
||||
case CircuitOpen:
|
||||
return "open"
|
||||
case CircuitHalfOpen:
|
||||
return "half-open"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
func TestCircuitBreaker_ClosedState_AllowsDeliveries(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
assert.Equal(t, delivery.CircuitClosed, cb.State())
|
||||
assert.True(t, cb.Allow(),
|
||||
"closed circuit should allow deliveries",
|
||||
)
|
||||
|
||||
for range 10 {
|
||||
assert.True(t, cb.Allow())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_FailureCounting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
for i := range delivery.ExportDefaultFailureThreshold - 1 {
|
||||
cb.RecordFailure()
|
||||
|
||||
assert.Equal(t,
|
||||
delivery.CircuitClosed, cb.State(),
|
||||
"circuit should remain closed after %d failures",
|
||||
i+1,
|
||||
)
|
||||
|
||||
assert.True(t, cb.Allow(),
|
||||
"should still allow after %d failures",
|
||||
i+1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_OpenTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
assert.Equal(t, delivery.CircuitOpen, cb.State(),
|
||||
"circuit should be open after threshold failures",
|
||||
)
|
||||
|
||||
assert.False(t, cb.Allow(),
|
||||
"open circuit should reject deliveries",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_Cooldown_StaysOpen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
require.Equal(t, delivery.CircuitOpen, cb.State())
|
||||
|
||||
assert.False(t, cb.Allow(),
|
||||
"should be blocked during cooldown",
|
||||
)
|
||||
|
||||
remaining := cb.CooldownRemaining()
|
||||
|
||||
assert.Greater(t, remaining, time.Duration(0),
|
||||
"cooldown should have remaining time",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpen_AfterCooldown(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := newShortCooldownCB(t)
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
require.Equal(t, delivery.CircuitOpen, cb.State())
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, time.Duration(0),
|
||||
cb.CooldownRemaining(),
|
||||
)
|
||||
|
||||
assert.True(t, cb.Allow(),
|
||||
"should allow one probe after cooldown",
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
delivery.CircuitHalfOpen, cb.State(),
|
||||
"should be half-open after probe allowed",
|
||||
)
|
||||
|
||||
assert.False(t, cb.Allow(),
|
||||
"should reject additional probes while half-open",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ProbeSuccess_ClosesCircuit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := newShortCooldownCB(t)
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
require.True(t, cb.Allow())
|
||||
|
||||
cb.RecordSuccess()
|
||||
|
||||
assert.Equal(t, delivery.CircuitClosed, cb.State(),
|
||||
"successful probe should close circuit",
|
||||
)
|
||||
|
||||
assert.True(t, cb.Allow(),
|
||||
"closed circuit should allow deliveries",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ProbeFailure_ReopensCircuit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := newShortCooldownCB(t)
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
require.True(t, cb.Allow())
|
||||
|
||||
cb.RecordFailure()
|
||||
|
||||
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,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold - 1 {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
require.Equal(t, delivery.CircuitClosed, cb.State())
|
||||
|
||||
cb.RecordSuccess()
|
||||
|
||||
assert.Equal(t, delivery.CircuitClosed, cb.State())
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold - 1 {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
assert.Equal(t, delivery.CircuitClosed, cb.State(),
|
||||
"circuit should still be closed -- "+
|
||||
"success reset the counter",
|
||||
)
|
||||
|
||||
cb.RecordFailure()
|
||||
|
||||
assert.Equal(t, delivery.CircuitOpen, cb.State())
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
const goroutines = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(goroutines * 3)
|
||||
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
cb.Allow()
|
||||
}()
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
cb.RecordFailure()
|
||||
}()
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
cb.RecordSuccess()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
state := cb.State()
|
||||
|
||||
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,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := delivery.NewCircuitBreaker()
|
||||
|
||||
assert.Equal(t, time.Duration(0),
|
||||
cb.CooldownRemaining(),
|
||||
"closed circuit should have zero cooldown remaining",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
cb := newShortCooldownCB(t)
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
require.True(t, cb.Allow())
|
||||
|
||||
assert.Equal(t, time.Duration(0),
|
||||
cb.CooldownRemaining(),
|
||||
"half-open circuit should have zero cooldown remaining",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCircuitState_String(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "closed", delivery.CircuitClosed.String())
|
||||
assert.Equal(t, "open", delivery.CircuitOpen.String())
|
||||
assert.Equal(t, "half-open", delivery.CircuitHalfOpen.String())
|
||||
assert.Equal(t, "unknown", delivery.CircuitState(99).String())
|
||||
}
|
||||
|
||||
// newShortCooldownCB creates a CircuitBreaker with a short
|
||||
// cooldown for testing. We use NewCircuitBreaker and
|
||||
// manipulate through the public API.
|
||||
func newShortCooldownCB(t *testing.T) *delivery.CircuitBreaker {
|
||||
t.Helper()
|
||||
|
||||
return delivery.NewTestCircuitBreaker(
|
||||
delivery.ExportDefaultFailureThreshold,
|
||||
50*time.Millisecond,
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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
@@ -1,252 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// Exported constants for test access.
|
||||
const (
|
||||
ExportDeliveryChannelSize = deliveryChannelSize
|
||||
ExportRetryChannelSize = retryChannelSize
|
||||
ExportDefaultFailureThreshold = defaultFailureThreshold
|
||||
ExportDefaultCooldown = defaultCooldown
|
||||
)
|
||||
|
||||
// ExportIsBlockedIP exposes isBlockedIP for testing.
|
||||
func ExportIsBlockedIP(ip net.IP) bool {
|
||||
return isBlockedIP(ip)
|
||||
}
|
||||
|
||||
// ExportBlockedNetworks exposes blockedNetworks.
|
||||
func ExportBlockedNetworks() []*net.IPNet {
|
||||
return blockedNetworks
|
||||
}
|
||||
|
||||
// ExportIsForwardableHeader exposes isForwardableHeader.
|
||||
func ExportIsForwardableHeader(name string) bool {
|
||||
return isForwardableHeader(name)
|
||||
}
|
||||
|
||||
// ExportTruncate exposes truncate for testing.
|
||||
func ExportTruncate(s string, maxLen int) string {
|
||||
return truncate(s, maxLen)
|
||||
}
|
||||
|
||||
// ExportDeliverHTTP exposes deliverHTTP for testing.
|
||||
func (e *Engine) ExportDeliverHTTP(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
) {
|
||||
e.deliverHTTP(ctx, webhookDB, d, task)
|
||||
}
|
||||
|
||||
// ExportDeliverDatabase exposes deliverDatabase.
|
||||
func (e *Engine) ExportDeliverDatabase(
|
||||
webhookDB *gorm.DB, d *database.Delivery,
|
||||
) {
|
||||
e.deliverDatabase(webhookDB, d)
|
||||
}
|
||||
|
||||
// ExportDeliverLog exposes deliverLog for testing.
|
||||
func (e *Engine) ExportDeliverLog(
|
||||
webhookDB *gorm.DB, d *database.Delivery,
|
||||
) {
|
||||
e.deliverLog(webhookDB, d)
|
||||
}
|
||||
|
||||
// ExportDeliverSlack exposes deliverSlack for testing.
|
||||
func (e *Engine) ExportDeliverSlack(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
) {
|
||||
e.deliverSlack(ctx, webhookDB, d)
|
||||
}
|
||||
|
||||
// ExportProcessNewTask exposes processNewTask.
|
||||
func (e *Engine) ExportProcessNewTask(
|
||||
ctx context.Context, task *Task,
|
||||
) {
|
||||
e.processNewTask(ctx, task)
|
||||
}
|
||||
|
||||
// ExportProcessRetryTask exposes processRetryTask.
|
||||
func (e *Engine) ExportProcessRetryTask(
|
||||
ctx context.Context, task *Task,
|
||||
) {
|
||||
e.processRetryTask(ctx, task)
|
||||
}
|
||||
|
||||
// ExportProcessDelivery exposes processDelivery.
|
||||
func (e *Engine) ExportProcessDelivery(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
) {
|
||||
e.processDelivery(ctx, webhookDB, d, task)
|
||||
}
|
||||
|
||||
// ExportGetCircuitBreaker exposes getCircuitBreaker.
|
||||
func (e *Engine) ExportGetCircuitBreaker(
|
||||
targetID string,
|
||||
) *CircuitBreaker {
|
||||
return e.getCircuitBreaker(targetID)
|
||||
}
|
||||
|
||||
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||
func (e *Engine) ExportParseHTTPConfig(
|
||||
configJSON string,
|
||||
) (*HTTPTargetConfig, error) {
|
||||
return e.parseHTTPConfig(configJSON)
|
||||
}
|
||||
|
||||
// ExportParseSlackConfig exposes parseSlackConfig.
|
||||
func (e *Engine) ExportParseSlackConfig(
|
||||
configJSON string,
|
||||
) (*SlackTargetConfig, error) {
|
||||
return e.parseSlackConfig(configJSON)
|
||||
}
|
||||
|
||||
// ExportDoHTTPRequest exposes doHTTPRequest.
|
||||
func (e *Engine) ExportDoHTTPRequest(
|
||||
ctx context.Context,
|
||||
cfg *HTTPTargetConfig,
|
||||
event *database.Event,
|
||||
) (int, string, int64, error) {
|
||||
return e.doHTTPRequest(ctx, cfg, event)
|
||||
}
|
||||
|
||||
// ExportClientForConfig exposes clientForConfig.
|
||||
func (e *Engine) ExportClientForConfig(
|
||||
cfg *HTTPTargetConfig,
|
||||
) *http.Client {
|
||||
return e.clientForConfig(cfg)
|
||||
}
|
||||
|
||||
// ExportClient returns the engine's shared HTTP client.
|
||||
func (e *Engine) ExportClient() *http.Client {
|
||||
return e.client
|
||||
}
|
||||
|
||||
// ExportScheduleRetry exposes scheduleRetry.
|
||||
func (e *Engine) ExportScheduleRetry(
|
||||
task Task, delay time.Duration,
|
||||
) {
|
||||
e.scheduleRetry(task, delay)
|
||||
}
|
||||
|
||||
// ExportRecoverPendingDeliveries exposes
|
||||
// recoverPendingDeliveries.
|
||||
func (e *Engine) ExportRecoverPendingDeliveries(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
webhookID string,
|
||||
) {
|
||||
e.recoverPendingDeliveries(
|
||||
ctx, webhookDB, webhookID,
|
||||
)
|
||||
}
|
||||
|
||||
// ExportRecoverWebhookDeliveries exposes
|
||||
// recoverWebhookDeliveries.
|
||||
func (e *Engine) ExportRecoverWebhookDeliveries(
|
||||
ctx context.Context, webhookID string,
|
||||
) {
|
||||
e.recoverWebhookDeliveries(ctx, webhookID)
|
||||
}
|
||||
|
||||
// ExportRecoverInFlight exposes recoverInFlight.
|
||||
func (e *Engine) ExportRecoverInFlight(
|
||||
ctx context.Context,
|
||||
) {
|
||||
e.recoverInFlight(ctx)
|
||||
}
|
||||
|
||||
// ExportStart exposes start for testing.
|
||||
func (e *Engine) ExportStart(ctx context.Context) {
|
||||
e.start(ctx)
|
||||
}
|
||||
|
||||
// ExportStop exposes stop for testing.
|
||||
func (e *Engine) ExportStop() {
|
||||
e.stop()
|
||||
}
|
||||
|
||||
// ExportDeliveryCh returns the delivery channel.
|
||||
func (e *Engine) ExportDeliveryCh() chan Task {
|
||||
return e.deliveryCh
|
||||
}
|
||||
|
||||
// ExportRetryCh returns the retry channel.
|
||||
func (e *Engine) ExportRetryCh() chan Task {
|
||||
return e.retryCh
|
||||
}
|
||||
|
||||
// NewTestEngine creates an Engine for unit tests without
|
||||
// database dependencies.
|
||||
func NewTestEngine(
|
||||
log *slog.Logger,
|
||||
client *http.Client,
|
||||
workers int,
|
||||
) *Engine {
|
||||
return &Engine{
|
||||
log: log,
|
||||
client: client,
|
||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: workers,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestEngineSmallRetry creates an Engine with a tiny
|
||||
// retry channel buffer for overflow testing.
|
||||
func NewTestEngineSmallRetry(
|
||||
log *slog.Logger,
|
||||
) *Engine {
|
||||
return &Engine{
|
||||
log: log,
|
||||
retryCh: make(chan Task, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestEngineWithDB creates an Engine with a real
|
||||
// database and dbManager for integration tests.
|
||||
func NewTestEngineWithDB(
|
||||
db *database.Database,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
log *slog.Logger,
|
||||
client *http.Client,
|
||||
workers int,
|
||||
) *Engine {
|
||||
return &Engine{
|
||||
database: db,
|
||||
dbManager: dbMgr,
|
||||
log: log,
|
||||
client: client,
|
||||
deliveryCh: make(chan Task, deliveryChannelSize),
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: workers,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestCircuitBreaker creates a CircuitBreaker with
|
||||
// custom settings for testing.
|
||||
func NewTestCircuitBreaker(
|
||||
threshold int, cooldown time.Duration,
|
||||
) *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
state: CircuitClosed,
|
||||
threshold: threshold,
|
||||
cooldown: cooldown,
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,28 @@
|
||||
// Package globals provides build-time variables injected via ldflags.
|
||||
package globals
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Build-time variables populated from main() and copied into the
|
||||
// Globals object.
|
||||
//
|
||||
//nolint:gochecknoglobals // Build-time variables set by main().
|
||||
// these get populated from main() and copied into the Globals object.
|
||||
var (
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
)
|
||||
|
||||
// Globals holds build-time metadata about the application.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
}
|
||||
|
||||
// New creates a Globals instance from the package-level
|
||||
// build-time variables.
|
||||
//
|
||||
//nolint:revive // lc parameter is required by fx even if unused.
|
||||
// nolint:revive // lc parameter is required by fx even if unused
|
||||
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Buildarch: Buildarch,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
package globals_test
|
||||
package globals
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"go.uber.org/fx/fxtest"
|
||||
)
|
||||
|
||||
func TestGlobalsFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
func TestNew(t *testing.T) {
|
||||
// Set test values
|
||||
Appname = "test-app"
|
||||
Version = "1.0.0"
|
||||
Buildarch = "test-arch"
|
||||
|
||||
g := &globals.Globals{
|
||||
Appname: "test-app",
|
||||
Version: "1.0.0",
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
globals, err := New(lc)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
if g.Appname != "test-app" {
|
||||
t.Errorf(
|
||||
"Appname = %v, want %v",
|
||||
g.Appname, "test-app",
|
||||
)
|
||||
if globals.Appname != "test-app" {
|
||||
t.Errorf("Appname = %v, want %v", globals.Appname, "test-app")
|
||||
}
|
||||
|
||||
if g.Version != "1.0.0" {
|
||||
t.Errorf(
|
||||
"Version = %v, want %v",
|
||||
g.Version, "1.0.0",
|
||||
)
|
||||
if globals.Version != "1.0.0" {
|
||||
t.Errorf("Version = %v, want %v", globals.Version, "1.0.0")
|
||||
}
|
||||
if globals.Buildarch != "test-arch" {
|
||||
t.Errorf("Buildarch = %v, want %v", globals.Buildarch, "test-arch")
|
||||
}
|
||||
}
|
||||
|
||||
+43
-136
@@ -3,7 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"git.eeqj.de/sneak/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// HandleLoginPage returns a handler for the login page (GET)
|
||||
@@ -13,31 +13,25 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||
sess, err := h.session.Get(r)
|
||||
if err == nil && h.session.IsAuthenticated(sess) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Render login page
|
||||
data := map[string]any{
|
||||
data := map[string]interface{}{
|
||||
"Error": "",
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLoginSubmit handles the login form submission (POST)
|
||||
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Limit request body to prevent memory exhaustion
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
|
||||
|
||||
// Parse form data
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.log.Error("failed to parse form", "error", err)
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,147 +40,67 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
|
||||
// Validate input
|
||||
if username == "" || password == "" {
|
||||
h.renderLoginError(
|
||||
w, r,
|
||||
"Username and password are required",
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Error": "Username and password are required",
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authenticateUser(
|
||||
w, r, username, password,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = h.createAuthenticatedSession(w, r, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.log.Info(
|
||||
"user logged in",
|
||||
"username", username,
|
||||
"user_id", user.ID,
|
||||
)
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// renderLoginError renders the login page with an error message.
|
||||
func (h *Handlers) renderLoginError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
msg string,
|
||||
status int,
|
||||
) {
|
||||
data := map[string]any{
|
||||
"Error": msg,
|
||||
}
|
||||
|
||||
w.WriteHeader(status)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
}
|
||||
|
||||
// authenticateUser looks up and verifies a user's credentials.
|
||||
// On failure it writes an HTTP response and returns an error.
|
||||
func (h *Handlers) authenticateUser(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
username, password string,
|
||||
) (database.User, error) {
|
||||
// Find user in database
|
||||
var user database.User
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"username = ?", username,
|
||||
).First(&user).Error
|
||||
if err != nil {
|
||||
if err := h.db.DB().Where("username = ?", username).First(&user).Error; err != nil {
|
||||
h.log.Debug("user not found", "username", username)
|
||||
h.renderLoginError(
|
||||
w, r,
|
||||
"Invalid username or password",
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return user, err
|
||||
data := map[string]interface{}{
|
||||
"Error": "Invalid username or password",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
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
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
h.log.Debug("invalid password", "username", username)
|
||||
h.renderLoginError(
|
||||
w, r,
|
||||
"Invalid username or password",
|
||||
http.StatusUnauthorized,
|
||||
)
|
||||
|
||||
return user, errInvalidPassword
|
||||
data := map[string]interface{}{
|
||||
"Error": "Invalid username or password",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
// Create session
|
||||
sess, 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
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set user in session
|
||||
h.session.SetUser(sess, user.ID, user.Username)
|
||||
|
||||
err = h.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
// 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 err
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
h.log.Info("user logged in", "username", username, "user_id", user.ID)
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogout handles user logout
|
||||
@@ -195,10 +109,7 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
sess, err := h.session.Get(r)
|
||||
if err != nil {
|
||||
h.log.Error("failed to get session", "error", err)
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,12 +117,8 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
h.session.Destroy(sess)
|
||||
|
||||
// Save the destroyed session
|
||||
err = h.session.Save(r, w, sess)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to save destroyed session",
|
||||
"error", err,
|
||||
)
|
||||
if err := h.session.Save(r, w, sess); err != nil {
|
||||
h.log.Error("failed to save destroyed session", "error", err)
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+63
-163
@@ -1,127 +1,58 @@
|
||||
// Package handlers provides HTTP request handlers for the
|
||||
// webhooker web UI and API.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/database"
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"git.eeqj.de/sneak/webhooker/internal/session"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
"sneak.berlin/go/webhooker/templates"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||
maxBodyShift = 20
|
||||
// recentEventLimit is the number of recent events to show.
|
||||
recentEventLimit = 20
|
||||
// defaultRetentionDays is the default event retention period.
|
||||
defaultRetentionDays = 30
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
)
|
||||
|
||||
// errInvalidPassword is returned when a password does not match.
|
||||
var errInvalidPassword = errors.New("invalid password")
|
||||
|
||||
//nolint:revive // HandlersParams is a standard fx naming convention.
|
||||
// nolint:revive // HandlersParams is a standard fx naming convention
|
||||
type HandlersParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Database *database.Database
|
||||
WebhookDBMgr *database.WebhookDBManager
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Session *session.Session
|
||||
Notifier delivery.Notifier
|
||||
}
|
||||
|
||||
// Handlers provides HTTP handler methods for all application
|
||||
// routes.
|
||||
type Handlers struct {
|
||||
params *HandlersParams
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
session *session.Session
|
||||
notifier delivery.Notifier
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
// parsePageTemplate parses a page-specific template set from the
|
||||
// embedded FS. Each page template is combined with the shared
|
||||
// base, htmlheader, and navbar templates. The page file must be
|
||||
// listed first so that its root action ({{template "base" .}})
|
||||
// becomes the template set's entry point.
|
||||
func parsePageTemplate(pageFile string) *template.Template {
|
||||
return template.Must(
|
||||
template.ParseFS(
|
||||
templates.Templates,
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// New creates a Handlers instance, parsing all page templates at
|
||||
// startup.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params HandlersParams,
|
||||
) (*Handlers, error) {
|
||||
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||
s := new(Handlers)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.hc = params.Healthcheck
|
||||
s.db = params.Database
|
||||
s.dbMgr = params.WebhookDBMgr
|
||||
s.session = params.Session
|
||||
s.notifier = params.Notifier
|
||||
|
||||
// Parse all page templates once at startup
|
||||
s.templates = map[string]*template.Template{
|
||||
"login.html": parsePageTemplate("login.html"),
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(ctx context.Context) error {
|
||||
// FIXME compile some templates here or something
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(
|
||||
w http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
//nolint:unparam // r parameter will be used in the future for request context
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if data != nil {
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
@@ -130,15 +61,17 @@ func (s *Handlers) respondJSON(
|
||||
}
|
||||
}
|
||||
|
||||
// serverError logs an error and sends a 500 response.
|
||||
func (s *Handlers) serverError(
|
||||
w http.ResponseWriter, msg string, err error,
|
||||
) {
|
||||
s.log.Error(msg, "error", err)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
//nolint:unparam,unused // will be used for handling JSON requests
|
||||
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// TemplateData represents the common data passed to templates
|
||||
type TemplateData struct {
|
||||
User *UserInfo
|
||||
Version string
|
||||
UserCount int64
|
||||
Uptime string
|
||||
}
|
||||
|
||||
// UserInfo represents user information for templates
|
||||
@@ -147,91 +80,58 @@ type UserInfo struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
// templateDataWrapper wraps non-map data with common fields.
|
||||
type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
CSRFToken string
|
||||
Data any
|
||||
}
|
||||
// renderTemplate renders a template with common data
|
||||
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) {
|
||||
// Always include the common templates
|
||||
allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"}
|
||||
allTemplates = append(allTemplates, templateFiles...)
|
||||
|
||||
// getUserInfo extracts user info from the session.
|
||||
func (s *Handlers) getUserInfo(
|
||||
r *http.Request,
|
||||
) *UserInfo {
|
||||
// Parse templates
|
||||
tmpl, err := template.ParseFiles(allTemplates...)
|
||||
if err != nil {
|
||||
s.log.Error("failed to parse template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from session if available
|
||||
var userInfo *UserInfo
|
||||
sess, err := s.session.Get(r)
|
||||
if err != nil || !s.session.IsAuthenticated(sess) {
|
||||
return nil
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
username, ok := s.session.GetUsername(sess)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, ok := s.session.GetUserID(sess)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &UserInfo{ID: userID, Username: username}
|
||||
}
|
||||
|
||||
// renderTemplate renders a pre-parsed template with common
|
||||
// data
|
||||
func (s *Handlers) renderTemplate(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
pageTemplate string,
|
||||
data any,
|
||||
) {
|
||||
tmpl, ok := s.templates[pageTemplate]
|
||||
if !ok {
|
||||
s.log.Error(
|
||||
"template not found",
|
||||
"template", pageTemplate,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := s.getUserInfo(r)
|
||||
csrfToken := middleware.CSRFToken(r)
|
||||
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
m["User"] = userInfo
|
||||
m["CSRFToken"] = csrfToken
|
||||
s.executeTemplate(w, tmpl, m)
|
||||
|
||||
return
|
||||
// Wrap data with base template data
|
||||
type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
wrapper := templateDataWrapper{
|
||||
User: userInfo,
|
||||
CSRFToken: csrfToken,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
s.executeTemplate(w, tmpl, wrapper)
|
||||
}
|
||||
// If data is a map, merge user info into it
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
m["User"] = userInfo
|
||||
if err := tmpl.Execute(w, m); err != nil {
|
||||
s.log.Error("failed to execute template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
// Otherwise use wrapper
|
||||
if err := tmpl.Execute(w, wrapper); err != nil {
|
||||
s.log.Error("failed to execute template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,188 +1,130 @@
|
||||
package handlers_test
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/database"
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"git.eeqj.de/sneak/webhooker/internal/session"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
type noopNotifier struct{}
|
||||
func TestHandleIndex(t *testing.T) {
|
||||
var h *Handlers
|
||||
|
||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
||||
|
||||
func newTestApp(
|
||||
t *testing.T,
|
||||
targets ...any,
|
||||
) *fxtest.App {
|
||||
t.Helper()
|
||||
|
||||
return fxtest.New(
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config {
|
||||
return &config.Config{
|
||||
DataDir: t.TempDir(),
|
||||
// This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!"
|
||||
SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
|
||||
}
|
||||
},
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
func() *database.Database {
|
||||
// Mock database with a mock DB method
|
||||
db := &database.Database{}
|
||||
return db
|
||||
},
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
func() delivery.Notifier {
|
||||
return &noopNotifier{}
|
||||
},
|
||||
handlers.New,
|
||||
New,
|
||||
),
|
||||
fx.Populate(targets...),
|
||||
fx.Populate(&h),
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleIndex_Unauthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
defer app.RequireStop()
|
||||
|
||||
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.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)
|
||||
assert.NotNil(t, handler)
|
||||
}
|
||||
|
||||
func TestRenderTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
var h *Handlers
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
func() *config.Config {
|
||||
return &config.Config{
|
||||
// This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!"
|
||||
SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
|
||||
}
|
||||
},
|
||||
func() *database.Database {
|
||||
// Mock database
|
||||
return &database.Database{}
|
||||
},
|
||||
healthcheck.New,
|
||||
session.New,
|
||||
New,
|
||||
),
|
||||
fx.Populate(&h),
|
||||
)
|
||||
app.RequireStart()
|
||||
defer app.RequireStop()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
t.Run("handles missing templates gracefully", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
data := map[string]any{"Version": "1.0.0"}
|
||||
data := map[string]interface{}{
|
||||
"Version": "1.0.0",
|
||||
}
|
||||
|
||||
h.RenderTemplateForTest(
|
||||
w, req, "nonexistent.html", data,
|
||||
)
|
||||
// When templates don't exist, renderTemplate should return an error
|
||||
h.renderTemplate(w, req, []string{"nonexistent.html"}, data)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusInternalServerError, w.Code,
|
||||
)
|
||||
// Should return internal server error when template parsing fails
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatUptime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
duration string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "minutes only",
|
||||
duration: "45m",
|
||||
expected: "45m",
|
||||
},
|
||||
{
|
||||
name: "hours and minutes",
|
||||
duration: "2h30m",
|
||||
expected: "2h 30m",
|
||||
},
|
||||
{
|
||||
name: "days, hours and minutes",
|
||||
duration: "25h45m",
|
||||
expected: "1d 1h 45m",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d, err := time.ParseDuration(tt.duration)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := formatUptime(d)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,9 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const httpStatusOK = 200
|
||||
|
||||
// HandleHealthCheck returns an HTTP handler that reports
|
||||
// application health.
|
||||
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
resp := s.hc.Healthcheck()
|
||||
s.respondJSON(w, req, resp, httpStatusOK)
|
||||
s.respondJSON(w, req, resp, 200)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-10
@@ -1,21 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.session.Get(r)
|
||||
if err == nil && s.session.IsAuthenticated(sess) {
|
||||
http.Redirect(w, r, "/sources", http.StatusSeeOther)
|
||||
type IndexResponse struct {
|
||||
Message string `json:"message"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
return
|
||||
func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||
// Calculate server start time
|
||||
startTime := time.Now()
|
||||
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
// Calculate uptime
|
||||
uptime := time.Since(startTime)
|
||||
uptimeStr := formatUptime(uptime)
|
||||
|
||||
// Get user count from database
|
||||
var userCount int64
|
||||
s.db.DB().Model(&database.User{}).Count(&userCount)
|
||||
|
||||
// Prepare template data
|
||||
data := map[string]interface{}{
|
||||
"Version": s.params.Globals.Version,
|
||||
"Uptime": uptimeStr,
|
||||
"UserCount": userCount,
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
// Render the template
|
||||
s.renderTemplate(w, req, []string{"templates/base.html", "templates/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,18 +13,14 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
requestedUsername := chi.URLParam(r, "username")
|
||||
if requestedUsername == "" {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get session. RequireAuth middleware guarantees an
|
||||
// authenticated session before this handler runs, so we
|
||||
// only need to guard against an unexpected retrieval error.
|
||||
// Get session
|
||||
sess, 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)
|
||||
|
||||
if err != nil || !h.session.IsAuthenticated(sess) {
|
||||
// Redirect to login if not authenticated
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,7 +29,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing username")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,19 +36,17 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
if !ok {
|
||||
h.log.Error("authenticated session missing user ID")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For now, only allow users to view their own profile
|
||||
if requestedUsername != sessionUsername {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare data for template
|
||||
data := map[string]any{
|
||||
data := map[string]interface{}{
|
||||
"User": &UserInfo{
|
||||
ID: sessionUserID,
|
||||
Username: sessionUsername,
|
||||
@@ -61,6 +54,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Render the profile page
|
||||
h.renderTemplate(w, r, "profile.html", data)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
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
+16
-320
@@ -1,346 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxWebhookBodySize is the maximum allowed webhook
|
||||
// request body (1 MB).
|
||||
maxWebhookBodySize = 1 << maxBodyShift
|
||||
)
|
||||
|
||||
// HandleWebhook handles incoming webhook requests at entrypoint
|
||||
// URLs.
|
||||
// HandleWebhook handles incoming webhook requests
|
||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(
|
||||
w,
|
||||
"Method Not Allowed",
|
||||
http.StatusMethodNotAllowed,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
entrypointUUID := chi.URLParam(r, "uuid")
|
||||
if entrypointUUID == "" {
|
||||
// Get webhook UUID from URL
|
||||
webhookUUID := chi.URLParam(r, "uuid")
|
||||
if webhookUUID == "" {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Log the incoming webhook request
|
||||
h.log.Info("webhook request received",
|
||||
"entrypoint_uuid", entrypointUUID,
|
||||
"uuid", webhookUUID,
|
||||
"method", r.Method,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
|
||||
entrypoint, ok := h.lookupEntrypoint(
|
||||
w, r, entrypointUUID,
|
||||
)
|
||||
if !ok {
|
||||
// Only POST methods are allowed for webhooks
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !entrypoint.Active {
|
||||
http.Error(w, "Gone", http.StatusGone)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.processWebhookRequest(w, r, entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
headersJSON, err := json.Marshal(r.Header)
|
||||
// TODO: Implement webhook handling logic
|
||||
// For now, return "unimplemented" for all webhook POST requests
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, err := w.Write([]byte("unimplemented"))
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to serialize headers", err)
|
||||
|
||||
return
|
||||
h.log.Error("failed to write response", "error", err)
|
||||
}
|
||||
|
||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to query targets", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.createAndDeliverEvent(
|
||||
w, r, entrypoint, body, headersJSON, targets,
|
||||
)
|
||||
}
|
||||
|
||||
// loadActiveTargets returns all active targets for a webhook.
|
||||
func (h *Handlers) loadActiveTargets(
|
||||
webhookID string,
|
||||
) ([]database.Target, error) {
|
||||
var targets []database.Target
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"webhook_id = ? AND active = ?",
|
||||
webhookID, true,
|
||||
).Find(&targets).Error
|
||||
|
||||
return targets, err
|
||||
}
|
||||
|
||||
// lookupEntrypoint finds an entrypoint by UUID path.
|
||||
func (h *Handlers) lookupEntrypoint(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypointUUID string,
|
||||
) (database.Entrypoint, bool) {
|
||||
var entrypoint database.Entrypoint
|
||||
|
||||
result := h.db.DB().Where(
|
||||
"path = ?", entrypointUUID,
|
||||
).First(&entrypoint)
|
||||
if result.Error != nil {
|
||||
h.log.Debug(
|
||||
"entrypoint not found",
|
||||
"path", entrypointUUID,
|
||||
)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return entrypoint, false
|
||||
}
|
||||
|
||||
return entrypoint, true
|
||||
}
|
||||
|
||||
// readWebhookBody reads and validates the request body size.
|
||||
func (h *Handlers) readWebhookBody(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) ([]byte, bool) {
|
||||
body, err := io.ReadAll(
|
||||
io.LimitReader(r.Body, maxWebhookBodySize+1),
|
||||
)
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to read request body", "error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if len(body) > maxWebhookBodySize {
|
||||
http.Error(
|
||||
w,
|
||||
"Request body too large",
|
||||
http.StatusRequestEntityTooLarge,
|
||||
)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return body, true
|
||||
}
|
||||
|
||||
// createAndDeliverEvent creates the event and delivery records
|
||||
// then notifies the delivery engine.
|
||||
func (h *Handlers) createAndDeliverEvent(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
entrypoint database.Entrypoint,
|
||||
body, headersJSON []byte,
|
||||
targets []database.Target,
|
||||
) {
|
||||
tx, err := h.beginWebhookTx(w, entrypoint.WebhookID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := h.buildEvent(r, entrypoint, headersJSON, body)
|
||||
|
||||
err = tx.Create(event).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
h.serverError(w, "failed to create event", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bodyPtr := inlineBody(body)
|
||||
|
||||
tasks := h.buildDeliveryTasks(
|
||||
w, tx, event, entrypoint, targets, bodyPtr,
|
||||
)
|
||||
if tasks == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to commit transaction", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||
}
|
||||
|
||||
// beginWebhookTx opens a transaction on the per-webhook DB.
|
||||
func (h *Handlers) beginWebhookTx(
|
||||
w http.ResponseWriter,
|
||||
webhookID string,
|
||||
) (*gorm.DB, error) {
|
||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to get webhook database", err,
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx := webhookDB.Begin()
|
||||
if tx.Error != nil {
|
||||
h.serverError(
|
||||
w, "failed to begin transaction", tx.Error,
|
||||
)
|
||||
|
||||
return nil, tx.Error
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// inlineBody returns a pointer to body as a string if it fits
|
||||
// within the inline size limit, or nil otherwise.
|
||||
func inlineBody(body []byte) *string {
|
||||
if len(body) < delivery.MaxInlineBodySize {
|
||||
s := string(body)
|
||||
|
||||
return &s
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishWebhookResponse notifies the delivery engine, logs the
|
||||
// event, and writes the HTTP response.
|
||||
func (h *Handlers) finishWebhookResponse(
|
||||
w http.ResponseWriter,
|
||||
event *database.Event,
|
||||
entrypoint database.Entrypoint,
|
||||
tasks []delivery.Task,
|
||||
) {
|
||||
if len(tasks) > 0 {
|
||||
h.notifier.Notify(tasks)
|
||||
}
|
||||
|
||||
h.log.Info("webhook event created",
|
||||
"event_id", event.ID,
|
||||
"webhook_id", entrypoint.WebhookID,
|
||||
"entrypoint_id", entrypoint.ID,
|
||||
"target_count", len(tasks),
|
||||
)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := w.Write([]byte(`{"status":"ok"}`))
|
||||
if err != nil {
|
||||
h.log.Error(
|
||||
"failed to write response", "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// buildEvent creates a new Event struct from request data.
|
||||
func (h *Handlers) buildEvent(
|
||||
r *http.Request,
|
||||
entrypoint database.Entrypoint,
|
||||
headersJSON, body []byte,
|
||||
) *database.Event {
|
||||
return &database.Event{
|
||||
WebhookID: entrypoint.WebhookID,
|
||||
EntrypointID: entrypoint.ID,
|
||||
Method: r.Method,
|
||||
Headers: string(headersJSON),
|
||||
Body: string(body),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeliveryTasks creates delivery records in the
|
||||
// transaction and returns tasks for the delivery engine.
|
||||
// Returns nil if an error occurred.
|
||||
func (h *Handlers) buildDeliveryTasks(
|
||||
w http.ResponseWriter,
|
||||
tx *gorm.DB,
|
||||
event *database.Event,
|
||||
entrypoint database.Entrypoint,
|
||||
targets []database.Target,
|
||||
bodyPtr *string,
|
||||
) []delivery.Task {
|
||||
tasks := make([]delivery.Task, 0, len(targets))
|
||||
|
||||
for i := range targets {
|
||||
dlv := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: targets[i].ID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
}
|
||||
|
||||
err := tx.Create(dlv).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
h.log.Error(
|
||||
"failed to create delivery",
|
||||
"target_id", targets[i].ID,
|
||||
"error", err,
|
||||
)
|
||||
http.Error(
|
||||
w, "Internal server error",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks = append(tasks, delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: entrypoint.WebhookID,
|
||||
TargetID: targets[i].ID,
|
||||
TargetName: targets[i].Name,
|
||||
TargetType: targets[i].Type,
|
||||
TargetConfig: targets[i].Config,
|
||||
MaxRetries: targets[i].MaxRetries,
|
||||
Method: event.Method,
|
||||
Headers: event.Headers,
|
||||
ContentType: event.ContentType,
|
||||
Body: bodyPtr,
|
||||
AttemptNum: 1,
|
||||
})
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package healthcheck provides application health status reporting.
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
@@ -6,58 +5,62 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/database"
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"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 {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Database *database.Database
|
||||
}
|
||||
|
||||
// Healthcheck tracks application uptime and reports health status.
|
||||
type Healthcheck struct {
|
||||
StartupTime time.Time
|
||||
log *slog.Logger
|
||||
params *HealthcheckParams
|
||||
}
|
||||
|
||||
// New creates a Healthcheck that records the startup time on fx
|
||||
// start.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params HealthcheckParams,
|
||||
) (*Healthcheck, error) {
|
||||
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
|
||||
s := new(Healthcheck)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
|
||||
s.StartupTime = time.Now()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Healthcheck returns the current health status of the
|
||||
// application.
|
||||
func (s *Healthcheck) Healthcheck() *Response {
|
||||
resp := &Response{
|
||||
// nolint:revive // HealthcheckResponse is a clear, descriptive name
|
||||
type HealthcheckResponse struct {
|
||||
Status string `json:"status"`
|
||||
Now string `json:"now"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
UptimeHuman string `json:"uptime_human"`
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenance_mode"`
|
||||
}
|
||||
|
||||
func (s *Healthcheck) uptime() time.Duration {
|
||||
return time.Since(s.StartupTime)
|
||||
}
|
||||
|
||||
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
|
||||
resp := &HealthcheckResponse{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(s.uptime().Seconds()),
|
||||
@@ -66,21 +69,5 @@ func (s *Healthcheck) Healthcheck() *Response {
|
||||
Version: s.params.Globals.Version,
|
||||
Maintenance: s.params.Config.MaintenanceMode,
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// Response contains the JSON-serialised health status.
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Now string `json:"now"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
UptimeHuman string `json:"uptimeHuman"`
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenanceMode"`
|
||||
}
|
||||
|
||||
func (s *Healthcheck) uptime() time.Duration {
|
||||
return time.Since(s.StartupTime)
|
||||
}
|
||||
|
||||
+38
-34
@@ -1,5 +1,3 @@
|
||||
// Package logger provides structured logging with dynamic level
|
||||
// control.
|
||||
package logger
|
||||
|
||||
import (
|
||||
@@ -8,62 +6,44 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"go.uber.org/fx"
|
||||
"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 {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// Logger wraps slog with dynamic level control and structured
|
||||
// output.
|
||||
type Logger struct {
|
||||
logger *slog.Logger
|
||||
levelVar *slog.LevelVar
|
||||
params LoggerParams
|
||||
}
|
||||
|
||||
// New creates a Logger that outputs text (TTY) or JSON (non-TTY)
|
||||
// to stdout.
|
||||
//
|
||||
//nolint:revive // lc parameter is required by fx even if unused.
|
||||
// nolint:revive // lc parameter is required by fx even if unused
|
||||
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||
l := new(Logger)
|
||||
l.params = params
|
||||
|
||||
// Use slog.LevelVar for dynamic log level changes
|
||||
l.levelVar = new(slog.LevelVar)
|
||||
l.levelVar.Set(slog.LevelInfo)
|
||||
|
||||
// Determine if we're running in a terminal
|
||||
tty := false
|
||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||
tty = true
|
||||
}
|
||||
|
||||
//nolint:revive // groups param unused but required by slog ReplaceAttr signature.
|
||||
replaceAttr := func(_ []string, a slog.Attr) slog.Attr {
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused
|
||||
// Always use UTC for timestamps
|
||||
if a.Key == slog.TimeKey {
|
||||
if t, ok := a.Value.Any().(time.Time); ok {
|
||||
return slog.Time(slog.TimeKey, t.UTC())
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: l.levelVar,
|
||||
ReplaceAttr: replaceAttr,
|
||||
},
|
||||
}
|
||||
|
||||
if tty {
|
||||
@@ -82,27 +62,51 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// EnableDebugLogging switches the log level to debug.
|
||||
func (l *Logger) EnableDebugLogging() {
|
||||
l.levelVar.Set(slog.LevelDebug)
|
||||
// Recreate logger with debug level
|
||||
tty := false
|
||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||
tty = true
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused
|
||||
// Always use UTC for timestamps
|
||||
if a.Key == slog.TimeKey {
|
||||
if t, ok := a.Value.Any().(time.Time); ok {
|
||||
return slog.Time(slog.TimeKey, t.UTC())
|
||||
}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}
|
||||
|
||||
if tty {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
}
|
||||
|
||||
l.logger = slog.New(handler)
|
||||
slog.SetDefault(l.logger)
|
||||
l.logger.Debug("debug logging enabled", "debug", true)
|
||||
}
|
||||
|
||||
// Get returns the underlying slog.Logger.
|
||||
func (l *Logger) Get() *slog.Logger {
|
||||
return l.logger
|
||||
}
|
||||
|
||||
// Identify logs the application name and version at startup.
|
||||
func (l *Logger) Identify() {
|
||||
l.logger.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"buildarch", l.params.Globals.Buildarch,
|
||||
)
|
||||
}
|
||||
|
||||
// Writer returns an io.Writer suitable for standard library
|
||||
// loggers.
|
||||
// Helper methods to maintain compatibility with existing code
|
||||
func (l *Logger) Writer() io.Writer {
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
@@ -1,59 +1,65 @@
|
||||
package logger_test
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
func testGlobals() *globals.Globals {
|
||||
return &globals.Globals{
|
||||
Appname: "test-app",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Set up globals
|
||||
globals.Appname = "test-app"
|
||||
globals.Version = "1.0.0"
|
||||
globals.Buildarch = "test-arch"
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
params := logger.LoggerParams{
|
||||
Globals: testGlobals(),
|
||||
g, err := globals.New(lc)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New() error = %v", err)
|
||||
}
|
||||
|
||||
l, err := logger.New(lc, params)
|
||||
params := LoggerParams{
|
||||
Globals: g,
|
||||
}
|
||||
|
||||
logger, err := New(lc, params)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
if l.Get() == nil {
|
||||
if logger.Get() == nil {
|
||||
t.Error("Get() returned nil logger")
|
||||
}
|
||||
|
||||
// Test that we can log without panic
|
||||
l.Get().Info("test message", "key", "value")
|
||||
logger.Get().Info("test message", "key", "value")
|
||||
}
|
||||
|
||||
func TestEnableDebugLogging(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Set up globals
|
||||
globals.Appname = "test-app"
|
||||
globals.Version = "1.0.0"
|
||||
globals.Buildarch = "test-arch"
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
params := logger.LoggerParams{
|
||||
Globals: testGlobals(),
|
||||
g, err := globals.New(lc)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New() error = %v", err)
|
||||
}
|
||||
|
||||
l, err := logger.New(lc, params)
|
||||
params := LoggerParams{
|
||||
Globals: g,
|
||||
}
|
||||
|
||||
logger, err := New(lc, params)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
// Enable debug logging should not panic
|
||||
l.EnableDebugLogging()
|
||||
logger.EnableDebugLogging()
|
||||
|
||||
// Test debug logging
|
||||
l.Get().Debug("debug message", "test", true)
|
||||
logger.Get().Debug("debug message", "test", true)
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,5 +1,3 @@
|
||||
// Package middleware provides HTTP middleware for logging, auth,
|
||||
// CORS, and metrics.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
@@ -8,6 +6,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
@@ -15,48 +16,25 @@ import (
|
||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||
"github.com/slok/go-http-metrics/middleware/std"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const (
|
||||
// corsMaxAge is the maximum time (in seconds) that a
|
||||
// preflight response can be cached.
|
||||
corsMaxAge = 300
|
||||
)
|
||||
|
||||
//nolint:revive // MiddlewareParams is a standard fx naming convention.
|
||||
// nolint:revive // MiddlewareParams is a standard fx naming convention
|
||||
type MiddlewareParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Session *session.Session
|
||||
}
|
||||
|
||||
// Middleware provides HTTP middleware for logging, CORS, auth, and
|
||||
// metrics.
|
||||
type Middleware struct {
|
||||
log *slog.Logger
|
||||
params *MiddlewareParams
|
||||
session *session.Session
|
||||
}
|
||||
|
||||
// New creates a Middleware from the provided fx parameters.
|
||||
//
|
||||
//nolint:revive // lc parameter is required by fx even if unused.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params MiddlewareParams,
|
||||
) (*Middleware, error) {
|
||||
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
||||
s := new(Middleware)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.session = params.Session
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -68,24 +46,19 @@ func ipFromHostPort(hp string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(h) > 0 && h[0] == '[' {
|
||||
return h[1 : len(h)-1]
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
statusCode int
|
||||
}
|
||||
|
||||
// newLoggingResponseWriter wraps w and records status codes.
|
||||
func newLoggingResponseWriter(
|
||||
w http.ResponseWriter,
|
||||
) *loggingResponseWriter {
|
||||
// nolint:revive // unexported type is only used internally
|
||||
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
|
||||
return &loggingResponseWriter{w, http.StatusOK}
|
||||
}
|
||||
|
||||
@@ -94,30 +67,23 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Logging returns middleware that logs each HTTP request with
|
||||
// timing and metadata.
|
||||
// type Middleware func(http.Handler) http.Handler
|
||||
// this returns a Middleware that is designed to do every request through the
|
||||
// mux, note the signature:
|
||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lrw := newLoggingResponseWriter(w)
|
||||
lrw := NewLoggingResponseWriter(w)
|
||||
ctx := r.Context()
|
||||
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
requestID := ""
|
||||
|
||||
if reqID := ctx.Value(
|
||||
middleware.RequestIDKey,
|
||||
); reqID != nil {
|
||||
if reqID := ctx.Value(middleware.RequestIDKey); reqID != nil {
|
||||
if id, ok := reqID.(string); ok {
|
||||
requestID = id
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("http request",
|
||||
"request_start", start,
|
||||
"method", r.Method,
|
||||
@@ -137,86 +103,40 @@ 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 {
|
||||
if s.params.Config.IsDev() {
|
||||
// In development, allow any origin for local testing.
|
||||
return cors.Handler(cors.Options{
|
||||
// CHANGEME! these are defaults, change them to suit your needs or
|
||||
// read from environment/viper.
|
||||
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{
|
||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||
},
|
||||
AllowedHeaders: []string{
|
||||
"Accept", "Authorization",
|
||||
"Content-Type", "X-CSRF-Token",
|
||||
},
|
||||
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: corsMaxAge,
|
||||
MaxAge: 300, // Maximum value not ignored by any of major browsers
|
||||
})
|
||||
}
|
||||
|
||||
// In production, the web UI is server-rendered so
|
||||
// cross-origin requests are not expected. Return a no-op
|
||||
// middleware.
|
||||
return func(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth returns middleware that checks for a valid session.
|
||||
// Unauthenticated users are redirected to the login page.
|
||||
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
func (s *Middleware) Auth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
sess, err := s.session.Get(r)
|
||||
if err != nil {
|
||||
s.log.Debug(
|
||||
"auth middleware: failed to get session",
|
||||
"error", err,
|
||||
)
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !s.session.IsAuthenticated(sess) {
|
||||
s.log.Debug(
|
||||
"auth middleware: unauthenticated request",
|
||||
"path", r.URL.Path,
|
||||
"method", r.Method,
|
||||
)
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: implement proper authentication
|
||||
s.log.Debug("AUTH: before request")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics returns middleware that records Prometheus HTTP metrics.
|
||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||
mdlw := ghmm.New(ghmm.Config{
|
||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
||||
})
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return std.Handler("", mdlw, next)
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsAuth returns middleware that protects metrics endpoints
|
||||
// with basic auth.
|
||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
return basicauth.New(
|
||||
"metrics",
|
||||
@@ -227,65 +147,3 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// SecurityHeaders returns middleware that sets production security
|
||||
// headers on every response: HSTS, X-Content-Type-Options,
|
||||
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
|
||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
w.Header().Set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=63072000; includeSubDomains; preload",
|
||||
)
|
||||
w.Header().Set(
|
||||
"X-Content-Type-Options", "nosniff",
|
||||
)
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
)
|
||||
w.Header().Set(
|
||||
"Referrer-Policy",
|
||||
"strict-origin-when-cross-origin",
|
||||
)
|
||||
w.Header().Set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const testKeySize = 32
|
||||
|
||||
// testMiddleware creates a Middleware with minimal dependencies
|
||||
// for testing. It uses a real session.Session backed by an
|
||||
// in-memory cookie store.
|
||||
func testMiddleware(
|
||||
t *testing.T,
|
||||
env string,
|
||||
) (*middleware.Middleware, *session.Session) {
|
||||
t.Helper()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: env,
|
||||
}
|
||||
|
||||
// Create a real session manager with a known key
|
||||
key := make([]byte, testKeySize)
|
||||
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key)
|
||||
|
||||
m := middleware.NewForTest(log, cfg, sessManager)
|
||||
|
||||
return m, sessManager
|
||||
}
|
||||
|
||||
// --- Logging Middleware Tests ---
|
||||
|
||||
func TestLogging_SetsStatusCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.Logging()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
_, err := w.Write([]byte("created"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
assert.Equal(t, "created", w.Body.String())
|
||||
}
|
||||
|
||||
func TestLogging_DefaultStatusOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.Logging()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, err := w.Write([]byte("ok"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
// When no explicit WriteHeader is called, default is 200
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestLogging_PassesThroughToNext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.Logging()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost, "/api/webhook", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.True(
|
||||
t, called,
|
||||
"logging middleware should call the next handler",
|
||||
)
|
||||
}
|
||||
|
||||
// --- LoggingResponseWriter Tests ---
|
||||
|
||||
func TestLoggingResponseWriter_CapturesStatusCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
||||
|
||||
// Default should be 200
|
||||
assert.Equal(
|
||||
t, http.StatusOK,
|
||||
middleware.LoggingResponseWriterStatusCode(lrw),
|
||||
)
|
||||
|
||||
// WriteHeader should capture the status code
|
||||
lrw.WriteHeader(http.StatusNotFound)
|
||||
|
||||
assert.Equal(
|
||||
t, http.StatusNotFound,
|
||||
middleware.LoggingResponseWriterStatusCode(lrw),
|
||||
)
|
||||
|
||||
// Underlying writer should also get the status code
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestLoggingResponseWriter_WriteDelegatesToUnderlying(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
lrw := middleware.NewLoggingResponseWriterForTest(w)
|
||||
|
||||
n, err := lrw.Write([]byte("hello world"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 11, n)
|
||||
assert.Equal(t, "hello world", w.Body.String())
|
||||
}
|
||||
|
||||
// --- CORS Middleware Tests ---
|
||||
|
||||
func TestCORS_DevMode_AllowsAnyOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.CORS()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
// Preflight request
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodOptions, "/api/test", nil,
|
||||
)
|
||||
req.Header.Set("Origin", "http://localhost:3000")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
// In dev mode, CORS should allow any origin
|
||||
assert.Equal(
|
||||
t, "*",
|
||||
w.Header().Get("Access-Control-Allow-Origin"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestCORS_ProdMode_NoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.CORS()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/api/test", nil,
|
||||
)
|
||||
req.Header.Set("Origin", "http://evil.com")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.True(
|
||||
t, called,
|
||||
"prod CORS middleware should pass through to handler",
|
||||
)
|
||||
// In prod, no CORS headers should be set (no-op middleware)
|
||||
assert.Empty(
|
||||
t,
|
||||
w.Header().Get("Access-Control-Allow-Origin"),
|
||||
"prod mode should not set CORS headers",
|
||||
)
|
||||
}
|
||||
|
||||
// --- RequireAuth Middleware Tests ---
|
||||
|
||||
func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/dashboard", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.False(
|
||||
t, called,
|
||||
"handler should not be called for "+
|
||||
"unauthenticated request",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestRequireAuth_AuthenticatedSession_PassesThrough(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
))
|
||||
|
||||
// Create an authenticated session by making a request,
|
||||
// setting session data, and saving the session cookie
|
||||
setupReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/setup", nil,
|
||||
)
|
||||
setupW := httptest.NewRecorder()
|
||||
|
||||
sess, err := sessManager.Get(setupReq)
|
||||
require.NoError(t, err)
|
||||
sessManager.SetUser(sess, "user-123", "testuser")
|
||||
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
||||
|
||||
// Extract the cookie from the setup response
|
||||
cookies := setupW.Result().Cookies()
|
||||
require.NotEmpty(t, cookies, "session cookie should be set")
|
||||
|
||||
// Make the actual request with the session cookie
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/dashboard", nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.True(
|
||||
t, called,
|
||||
"handler should be called for authenticated request",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
m, sessManager := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
))
|
||||
|
||||
// Create a session but don't authenticate it
|
||||
setupReq := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/setup", nil,
|
||||
)
|
||||
setupW := httptest.NewRecorder()
|
||||
|
||||
sess, err := sessManager.Get(setupReq)
|
||||
require.NoError(t, err)
|
||||
// Don't call SetUser -- session exists but is not
|
||||
// authenticated
|
||||
require.NoError(t, sessManager.Save(setupReq, setupW, sess))
|
||||
|
||||
cookies := setupW.Result().Cookies()
|
||||
require.NotEmpty(t, cookies)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/dashboard", nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.False(
|
||||
t, called,
|
||||
"handler should not be called for "+
|
||||
"unauthenticated session",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// --- Helper Tests ---
|
||||
|
||||
func TestIpFromHostPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"ipv4 with port", "192.168.1.1:8080", "192.168.1.1"},
|
||||
{"ipv6 with port", "[::1]:8080", "::1"},
|
||||
{"invalid format", "not-a-host-port", ""},
|
||||
{"empty string", "", ""},
|
||||
{"localhost", "127.0.0.1:80", "127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := middleware.IPFromHostPort(tt.input)
|
||||
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- MetricsAuth Tests ---
|
||||
|
||||
// metricsAuthMiddleware creates a Middleware configured for
|
||||
// metrics auth testing. This helper de-duplicates the setup in
|
||||
// metrics auth test functions.
|
||||
func metricsAuthMiddleware(
|
||||
t *testing.T,
|
||||
) *middleware.Middleware {
|
||||
t.Helper()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
MetricsUsername: "admin",
|
||||
MetricsPassword: "secret",
|
||||
}
|
||||
|
||||
key := make([]byte, testKeySize)
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
|
||||
|
||||
sessManager := session.NewForTest(store, cfg, log, key)
|
||||
|
||||
return middleware.NewForTest(log, cfg, sessManager)
|
||||
}
|
||||
|
||||
func TestMetricsAuth_ValidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := metricsAuthMiddleware(t)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.MetricsAuth()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/metrics", nil,
|
||||
)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.True(
|
||||
t, called,
|
||||
"handler should be called with valid basic auth",
|
||||
)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestMetricsAuth_InvalidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := metricsAuthMiddleware(t)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.MetricsAuth()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/metrics", nil,
|
||||
)
|
||||
req.SetBasicAuth("admin", "wrong-password")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.False(
|
||||
t, called,
|
||||
"handler should not be called with invalid basic auth",
|
||||
)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestMetricsAuth_NoCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := metricsAuthMiddleware(t)
|
||||
|
||||
var called bool
|
||||
|
||||
handler := m.MetricsAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
},
|
||||
))
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/metrics", nil,
|
||||
)
|
||||
// No basic auth header
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.False(
|
||||
t, called,
|
||||
"handler should not be called without credentials",
|
||||
)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
// --- CORS Dev Mode Detailed Tests ---
|
||||
|
||||
func TestCORS_DevMode_AllowsMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.CORS()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
|
||||
// Preflight for POST
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodOptions, "/api/webhooks", nil,
|
||||
)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
allowMethods := w.Header().Get("Access-Control-Allow-Methods")
|
||||
assert.Contains(t, allowMethods, "POST")
|
||||
}
|
||||
|
||||
// --- Base64 key validation for completeness ---
|
||||
|
||||
func TestSessionKeyFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify that the session initialization correctly validates
|
||||
// key format. A proper 32-byte key encoded as base64 should
|
||||
// work.
|
||||
key := make([]byte, testKeySize)
|
||||
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(key)
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, decoded, testKeySize)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
)
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
func TestLoginRateLimit_AllowsGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
var callCount int
|
||||
|
||||
handler := m.LoginRateLimit()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
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",
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
+5
-30
@@ -1,36 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// httpReadTimeout is the maximum duration for reading the
|
||||
// entire request, including the body.
|
||||
httpReadTimeout = 10 * time.Second
|
||||
|
||||
// httpWriteTimeout is the maximum duration before timing out
|
||||
// writes of the response. It must stay above the router's
|
||||
// requestTimeout (60s, in routes.go) so the middleware timeout
|
||||
// fires first and returns a clean 503, rather than the transport
|
||||
// cutting the connection at the socket write deadline.
|
||||
httpWriteTimeout = 65 * time.Second
|
||||
|
||||
// httpMaxHeaderBytes is the maximum number of bytes the
|
||||
// server will read parsing the request headers.
|
||||
httpMaxHeaderBytes = 1 << 20
|
||||
)
|
||||
|
||||
func (s *Server) serveUntilShutdown() {
|
||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: listenAddr,
|
||||
ReadTimeout: httpReadTimeout,
|
||||
WriteTimeout: httpWriteTimeout,
|
||||
MaxHeaderBytes: httpMaxHeaderBytes,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
Handler: s,
|
||||
}
|
||||
|
||||
@@ -39,21 +21,14 @@ func (s *Server) serveUntilShutdown() {
|
||||
s.SetupRoutes()
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.log.Error("listen error", "error", err)
|
||||
|
||||
if s.cancelFunc != nil {
|
||||
s.cancelFunc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP delegates to the router.
|
||||
func (s *Server) ServeHTTP(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) {
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
+45
-95
@@ -4,68 +4,66 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/static"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"sneak.berlin/go/webhooker/static"
|
||||
)
|
||||
|
||||
// maxFormBodySize is the maximum allowed request body size (in
|
||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||
// submission while preventing abuse from oversized payloads.
|
||||
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() {
|
||||
s.router = chi.NewRouter()
|
||||
s.setupGlobalMiddleware()
|
||||
s.setupRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupGlobalMiddleware() {
|
||||
// the mux .Use() takes a http.Handler wrapper func, like most
|
||||
// things that deal with "middlewares" like alice et c, and will
|
||||
// call ServeHTTP on it. These middlewares applied by the mux (you
|
||||
// can .Use() more than one) will be applied to every request into
|
||||
// the service.
|
||||
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.Logging())
|
||||
|
||||
// Metrics middleware (only if credentials are configured)
|
||||
// add metrics middleware only if we can serve them behind auth
|
||||
if s.params.Config.MetricsUsername != "" {
|
||||
s.router.Use(s.mw.Metrics())
|
||||
}
|
||||
|
||||
// set up CORS headers
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(requestTimeout))
|
||||
|
||||
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||
// true so panics still bubble up to the Recoverer middleware.
|
||||
// timeout for request context; your handlers must finish within
|
||||
// this window:
|
||||
s.router.Use(middleware.Timeout(60 * time.Second))
|
||||
|
||||
// this adds a sentry reporting middleware if and only if sentry is
|
||||
// enabled via setting of SENTRY_DSN in env.
|
||||
if s.sentryEnabled {
|
||||
// Options docs at
|
||||
// https://docs.sentry.io/platforms/go/guides/http/
|
||||
// we set sentry to repanic so that all panics bubble up to the
|
||||
// Recoverer chi middleware above.
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
})
|
||||
s.router.Use(sentryHandler.Handle)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// ROUTES
|
||||
// complete docs: https://github.com/go-chi/chi
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
|
||||
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) {
|
||||
// API routes will be added here.
|
||||
// TODO: Add API routes here
|
||||
})
|
||||
|
||||
s.router.Get(
|
||||
"/.well-known/healthcheck",
|
||||
"/.well-known/healthcheck.json",
|
||||
s.h.HandleHealthCheck(),
|
||||
)
|
||||
|
||||
@@ -73,90 +71,42 @@ func (s *Server) setupRoutes() {
|
||||
if s.params.Config.MetricsUsername != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get(
|
||||
"/metrics",
|
||||
http.HandlerFunc(
|
||||
promhttp.Handler().ServeHTTP,
|
||||
),
|
||||
)
|
||||
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
|
||||
})
|
||||
}
|
||||
|
||||
s.setupPageRoutes()
|
||||
s.setupUserRoutes()
|
||||
s.setupSourceRoutes()
|
||||
s.setupWebhookRoutes()
|
||||
}
|
||||
|
||||
func (s *Server) setupPageRoutes() {
|
||||
// pages that are rendered server-side
|
||||
s.router.Route("/pages", func(r chi.Router) {
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.LoginRateLimit())
|
||||
// Login page (no auth required)
|
||||
r.Get("/login", s.h.HandleLoginPage())
|
||||
r.Post("/login", s.h.HandleLoginSubmit())
|
||||
})
|
||||
|
||||
// Logout (auth required)
|
||||
r.Post("/logout", s.h.HandleLogout())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupUserRoutes() {
|
||||
// User profile routes
|
||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupSourceRoutes() {
|
||||
// Webhook source management routes (require authentication)
|
||||
s.router.Route("/sources", func(r chi.Router) {
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Get("/", s.h.HandleSourceList())
|
||||
r.Get("/new", s.h.HandleSourceCreate())
|
||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||
// TODO: Add authentication middleware here
|
||||
r.Get("/", s.h.HandleSourceList()) // List all sources
|
||||
r.Get("/new", s.h.HandleSourceCreate()) // Show create form
|
||||
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
|
||||
})
|
||||
|
||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||
r.Use(s.mw.CSRF())
|
||||
r.Use(s.mw.RequireAuth())
|
||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||
r.Get("/", s.h.HandleSourceDetail())
|
||||
r.Get("/edit", s.h.HandleSourceEdit())
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||
r.Post("/delete", s.h.HandleSourceDelete())
|
||||
r.Get("/logs", s.h.HandleSourceLogs())
|
||||
r.Post(
|
||||
"/entrypoints",
|
||||
s.h.HandleEntrypointCreate(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/delete",
|
||||
s.h.HandleEntrypointDelete(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints/{entrypointID}/toggle",
|
||||
s.h.HandleEntrypointToggle(),
|
||||
)
|
||||
r.Post("/targets", s.h.HandleTargetCreate())
|
||||
r.Post(
|
||||
"/targets/{targetID}/delete",
|
||||
s.h.HandleTargetDelete(),
|
||||
)
|
||||
r.Post(
|
||||
"/targets/{targetID}/toggle",
|
||||
s.h.HandleTargetToggle(),
|
||||
)
|
||||
// TODO: Add authentication middleware here
|
||||
r.Get("/", s.h.HandleSourceDetail()) // View source details
|
||||
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
|
||||
r.Post("/delete", s.h.HandleSourceDelete()) // Delete source
|
||||
r.Get("/logs", s.h.HandleSourceLogs()) // View source logs
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setupWebhookRoutes() {
|
||||
s.router.HandleFunc(
|
||||
"/webhook/{uuid}",
|
||||
s.h.HandleWebhook(),
|
||||
)
|
||||
// Webhook endpoint - accepts all HTTP methods
|
||||
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
|
||||
}
|
||||
|
||||
+34
-60
@@ -1,5 +1,3 @@
|
||||
// Package server wires up HTTP routes and manages the
|
||||
// application lifecycle.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -12,31 +10,30 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/globals"
|
||||
"git.eeqj.de/sneak/webhooker/internal/handlers"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"git.eeqj.de/sneak/webhooker/internal/middleware"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/handlers"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
// spooky action at a distance!
|
||||
// this populates the environment
|
||||
// from a ./.env file automatically
|
||||
// for development configuration.
|
||||
// .env contents should be things like
|
||||
// `DBURL=postgres://user:pass@.../`
|
||||
// (without the backticks, of course)
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
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.
|
||||
// ServerParams is a standard fx naming convention for dependency injection
|
||||
// nolint:golint
|
||||
type ServerParams struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
@@ -44,13 +41,12 @@ type ServerParams struct {
|
||||
Handlers *handlers.Handlers
|
||||
}
|
||||
|
||||
// Server is the main HTTP server that wires up routes and manages
|
||||
// graceful shutdown.
|
||||
type Server struct {
|
||||
startupTime time.Time
|
||||
exitCode int
|
||||
sentryEnabled bool
|
||||
log *slog.Logger
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
httpServer *http.Server
|
||||
router *chi.Mux
|
||||
@@ -59,8 +55,6 @@ type Server struct {
|
||||
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) {
|
||||
s := new(Server)
|
||||
s.params = params
|
||||
@@ -69,23 +63,19 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
|
||||
s.log = params.Logger.Get()
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(ctx context.Context) error {
|
||||
s.startupTime = time.Now()
|
||||
go s.Run()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
s.cleanShutdown(ctx)
|
||||
|
||||
s.cleanShutdown()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Run configures Sentry and starts serving HTTP requests.
|
||||
func (s *Server) Run() {
|
||||
s.configure()
|
||||
|
||||
@@ -95,12 +85,6 @@ func (s *Server) Run() {
|
||||
s.serve()
|
||||
}
|
||||
|
||||
// MaintenanceMode returns whether the server is in maintenance
|
||||
// mode.
|
||||
func (s *Server) MaintenanceMode() bool {
|
||||
return s.params.Config.MaintenanceMode
|
||||
}
|
||||
|
||||
func (s *Server) enableSentry() {
|
||||
s.sentryEnabled = false
|
||||
|
||||
@@ -110,78 +94,68 @@ func (s *Server) enableSentry() {
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: s.params.Config.SentryDSN,
|
||||
Release: fmt.Sprintf(
|
||||
"%s-%s",
|
||||
s.params.Globals.Appname,
|
||||
s.params.Globals.Version,
|
||||
),
|
||||
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("sentry init failure", "error", err)
|
||||
// Don't use fatal since we still want the service to run
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("sentry error reporting activated")
|
||||
s.sentryEnabled = true
|
||||
}
|
||||
|
||||
func (s *Server) serve() int {
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
s.cancelFunc = cancelFunc
|
||||
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
|
||||
|
||||
// signal watcher
|
||||
go func() {
|
||||
c := make(chan os.Signal, 1)
|
||||
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
// block and wait for signal
|
||||
sig := <-c
|
||||
s.log.Info("signal received", "signal", sig.String())
|
||||
|
||||
if s.cancelFunc != nil {
|
||||
// cancelling the main context will trigger a clean
|
||||
// shutdown via the fx OnStop hook.
|
||||
// shutdown.
|
||||
s.cancelFunc()
|
||||
}
|
||||
}()
|
||||
|
||||
go s.serveUntilShutdown()
|
||||
|
||||
<-ctx.Done()
|
||||
// Shutdown is handled by the fx OnStop hook (cleanShutdown).
|
||||
// Do not call cleanShutdown() here to avoid double invocation.
|
||||
<-s.ctx.Done()
|
||||
s.cleanShutdown()
|
||||
return s.exitCode
|
||||
}
|
||||
|
||||
func (s *Server) cleanupForExit() {
|
||||
s.log.Info("cleaning up")
|
||||
// TODO: close database connections, flush buffers, etc.
|
||||
}
|
||||
|
||||
func (s *Server) cleanShutdown(ctx context.Context) {
|
||||
func (s *Server) cleanShutdown() {
|
||||
// initiate clean shutdown
|
||||
s.exitCode = 0
|
||||
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||
ctx, shutdownTimeout,
|
||||
)
|
||||
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
err := s.httpServer.Shutdown(ctxShutdown)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"server clean shutdown failed", "error", err,
|
||||
)
|
||||
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
|
||||
s.log.Error("server clean shutdown failed", "error", err)
|
||||
}
|
||||
|
||||
s.cleanupForExit()
|
||||
|
||||
if s.sentryEnabled {
|
||||
sentry.Flush(sentryFlushTimeout)
|
||||
sentry.Flush(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) MaintenanceMode() bool {
|
||||
return s.params.Config.MaintenanceMode
|
||||
}
|
||||
|
||||
func (s *Server) configure() {
|
||||
// identify ourselves in the logs
|
||||
s.params.Logger.Identify()
|
||||
|
||||
+38
-168
@@ -1,107 +1,59 @@
|
||||
// Package session manages HTTP session storage and authentication
|
||||
// state.
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/internal/config"
|
||||
"git.eeqj.de/sneak/webhooker/internal/logger"
|
||||
"github.com/gorilla/sessions"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionName is the name of the session cookie.
|
||||
// SessionName is the name of the session cookie
|
||||
SessionName = "webhooker_session"
|
||||
|
||||
// UserIDKey is the session key for user ID.
|
||||
// UserIDKey is the session key for user ID
|
||||
UserIDKey = "user_id"
|
||||
|
||||
// UsernameKey is the session key for username.
|
||||
// UsernameKey is the session key for username
|
||||
UsernameKey = "username"
|
||||
|
||||
// AuthenticatedKey is the session key for authentication
|
||||
// status.
|
||||
// AuthenticatedKey is the session key for authentication status
|
||||
AuthenticatedKey = "authenticated"
|
||||
|
||||
// sessionKeyLength is the required length in bytes for the
|
||||
// session authentication key.
|
||||
sessionKeyLength = 32
|
||||
|
||||
// sessionMaxAgeDays is the session cookie lifetime in days.
|
||||
sessionMaxAgeDays = 7
|
||||
|
||||
// secondsPerDay is the number of seconds in a day.
|
||||
secondsPerDay = 86400
|
||||
)
|
||||
|
||||
// ErrSessionKeyLength is returned when the decoded session key
|
||||
// does not have the expected length.
|
||||
var ErrSessionKeyLength = errors.New("session key length mismatch")
|
||||
|
||||
// Params holds dependencies injected by fx.
|
||||
type Params struct {
|
||||
// nolint:revive // SessionParams is a standard fx naming convention
|
||||
type SessionParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Session manages encrypted session storage.
|
||||
// Session manages encrypted session storage
|
||||
type Session struct {
|
||||
store *sessions.CookieStore
|
||||
key []byte // raw 32-byte auth key, also used for CSRF cookie signing
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new session manager. The cookie store is
|
||||
// initialized during the fx OnStart phase after the database is
|
||||
// connected, using a session key that is auto-generated and stored
|
||||
// in the database.
|
||||
func New(
|
||||
lc fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Session, error) {
|
||||
s := &Session{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
// New creates a new session manager
|
||||
func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
|
||||
if params.Config.SessionKey == "" {
|
||||
return nil, fmt.Errorf("SESSION_KEY environment variable is required")
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
sessionKey, err := params.Database.GetOrCreateSessionKey()
|
||||
// Decode the base64 session key
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(params.Config.SessionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to get session key: %w", err,
|
||||
)
|
||||
return nil, fmt.Errorf("invalid SESSION_KEY format: %w", err)
|
||||
}
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(
|
||||
sessionKey,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid session key format: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if len(keyBytes) != sessionKeyLength {
|
||||
return fmt.Errorf(
|
||||
"%w: want %d, got %d",
|
||||
ErrSessionKeyLength,
|
||||
sessionKeyLength,
|
||||
len(keyBytes),
|
||||
)
|
||||
if len(keyBytes) != 32 {
|
||||
return nil, fmt.Errorf("SESSION_KEY must be 32 bytes (got %d)", len(keyBytes))
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore(keyBytes)
|
||||
@@ -109,147 +61,65 @@ func New(
|
||||
// Configure cookie options for security
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
||||
MaxAge: 86400 * 7, // 7 days
|
||||
HttpOnly: true,
|
||||
Secure: !params.Config.IsDev(),
|
||||
Secure: !params.Config.IsDev(), // HTTPS in production
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
s.key = keyBytes
|
||||
s.store = store
|
||||
s.log.Info("session manager initialized")
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
s := &Session{
|
||||
store: store,
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get retrieves a session for the request.
|
||||
func (s *Session) Get(
|
||||
r *http.Request,
|
||||
) (*sessions.Session, error) {
|
||||
// Get retrieves a session for the request
|
||||
func (s *Session) Get(r *http.Request) (*sessions.Session, error) {
|
||||
return s.store.Get(r, SessionName)
|
||||
}
|
||||
|
||||
// GetKey returns the raw 32-byte authentication key used for
|
||||
// session encryption. This key is also suitable for CSRF cookie
|
||||
// signing.
|
||||
func (s *Session) GetKey() []byte {
|
||||
return s.key
|
||||
}
|
||||
|
||||
// Save saves the session.
|
||||
func (s *Session) Save(
|
||||
r *http.Request,
|
||||
w http.ResponseWriter,
|
||||
sess *sessions.Session,
|
||||
) error {
|
||||
// Save saves the session
|
||||
func (s *Session) Save(r *http.Request, w http.ResponseWriter, sess *sessions.Session) error {
|
||||
return sess.Save(r, w)
|
||||
}
|
||||
|
||||
// SetUser sets the user information in the session.
|
||||
func (s *Session) SetUser(
|
||||
sess *sessions.Session,
|
||||
userID, username string,
|
||||
) {
|
||||
// SetUser sets the user information in the session
|
||||
func (s *Session) SetUser(sess *sessions.Session, userID, username string) {
|
||||
sess.Values[UserIDKey] = userID
|
||||
sess.Values[UsernameKey] = username
|
||||
sess.Values[AuthenticatedKey] = true
|
||||
}
|
||||
|
||||
// ClearUser removes user information from the session.
|
||||
// ClearUser removes user information from the session
|
||||
func (s *Session) ClearUser(sess *sessions.Session) {
|
||||
delete(sess.Values, UserIDKey)
|
||||
delete(sess.Values, UsernameKey)
|
||||
delete(sess.Values, AuthenticatedKey)
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if the session has an authenticated
|
||||
// user.
|
||||
// IsAuthenticated checks if the session has an authenticated user
|
||||
func (s *Session) IsAuthenticated(sess *sessions.Session) bool {
|
||||
auth, ok := sess.Values[AuthenticatedKey].(bool)
|
||||
|
||||
return ok && auth
|
||||
}
|
||||
|
||||
// GetUserID retrieves the user ID from the session.
|
||||
func (s *Session) GetUserID(
|
||||
sess *sessions.Session,
|
||||
) (string, bool) {
|
||||
// GetUserID retrieves the user ID from the session
|
||||
func (s *Session) GetUserID(sess *sessions.Session) (string, bool) {
|
||||
userID, ok := sess.Values[UserIDKey].(string)
|
||||
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
// GetUsername retrieves the username from the session.
|
||||
func (s *Session) GetUsername(
|
||||
sess *sessions.Session,
|
||||
) (string, bool) {
|
||||
// GetUsername retrieves the username from the session
|
||||
func (s *Session) GetUsername(sess *sessions.Session) (string, bool) {
|
||||
username, ok := sess.Values[UsernameKey].(string)
|
||||
|
||||
return username, ok
|
||||
}
|
||||
|
||||
// Destroy invalidates the session.
|
||||
// Destroy invalidates the session
|
||||
func (s *Session) Destroy(sess *sessions.Session) {
|
||||
sess.Options.MaxAge = -1
|
||||
s.ClearUser(sess)
|
||||
}
|
||||
|
||||
// Regenerate creates a new session with the same values but a
|
||||
// fresh ID. The old session is destroyed (MaxAge = -1) and saved,
|
||||
// then a new session is created. This prevents session fixation
|
||||
// attacks by ensuring the session ID changes after privilege
|
||||
// escalation (e.g. login).
|
||||
func (s *Session) Regenerate(
|
||||
r *http.Request,
|
||||
w http.ResponseWriter,
|
||||
oldSess *sessions.Session,
|
||||
) (*sessions.Session, error) {
|
||||
// Copy the values from the old session
|
||||
oldValues := make(map[any]any)
|
||||
maps.Copy(oldValues, oldSess.Values)
|
||||
|
||||
// Destroy the old session
|
||||
oldSess.Options.MaxAge = -1
|
||||
s.ClearUser(oldSess)
|
||||
|
||||
err := oldSess.Save(r, w)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to destroy old session: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Create a new session (gorilla/sessions generates a new ID)
|
||||
newSess, err := s.store.New(r, SessionName)
|
||||
if err != nil {
|
||||
// store.New may return an error alongside a new empty
|
||||
// session if the old cookie is now invalid. That is
|
||||
// expected after we destroyed it above. Only fail on a
|
||||
// nil session.
|
||||
if newSess == nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to create new session: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the copied values into the new session
|
||||
maps.Copy(newSess.Values, oldValues)
|
||||
|
||||
// Apply the standard session options (the destroyed old
|
||||
// session had MaxAge = -1, which store.New might inherit
|
||||
// from the cookie).
|
||||
newSess.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: secondsPerDay * sessionMaxAgeDays,
|
||||
HttpOnly: true,
|
||||
Secure: !s.config.IsDev(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
return newSess, nil
|
||||
}
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
package session_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
const testKeySize = 32
|
||||
|
||||
// testSession creates a Session with a real cookie store for
|
||||
// testing.
|
||||
func testSession(t *testing.T) *session.Session {
|
||||
t.Helper()
|
||||
|
||||
key := make([]byte, testKeySize)
|
||||
|
||||
for i := range key {
|
||||
key[i] = byte(i + 42)
|
||||
}
|
||||
|
||||
store := sessions.NewCookieStore(key)
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Environment: config.EnvironmentDev,
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
return session.NewForTest(store, cfg, log, key)
|
||||
}
|
||||
|
||||
// --- Get and Save Tests ---
|
||||
|
||||
func TestGet_NewSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sess)
|
||||
assert.True(
|
||||
t, sess.IsNew,
|
||||
"session should be new when no cookie is present",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGet_ExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
// Create and save a session
|
||||
req1 := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
|
||||
sess1, err := s.Get(req1)
|
||||
require.NoError(t, err)
|
||||
|
||||
sess1.Values["test_key"] = "test_value"
|
||||
require.NoError(t, s.Save(req1, w1, sess1))
|
||||
|
||||
// Extract cookies
|
||||
cookies := w1.Result().Cookies()
|
||||
require.NotEmpty(t, cookies)
|
||||
|
||||
// Make a new request with the session cookie
|
||||
req2 := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
for _, c := range cookies {
|
||||
req2.AddCookie(c)
|
||||
}
|
||||
|
||||
sess2, err := s.Get(req2)
|
||||
require.NoError(t, err)
|
||||
assert.False(
|
||||
t, sess2.IsNew,
|
||||
"session should not be new when cookie is present",
|
||||
)
|
||||
assert.Equal(t, "test_value", sess2.Values["test_key"])
|
||||
}
|
||||
|
||||
func TestSave_SetsCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
sess.Values["key"] = "value"
|
||||
|
||||
err = s.Save(req, w, sess)
|
||||
require.NoError(t, err)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.NotEmpty(t, cookies, "Save should set a cookie")
|
||||
|
||||
// Verify the cookie has the expected name
|
||||
var found bool
|
||||
|
||||
for _, c := range cookies {
|
||||
if c.Name == session.SessionName {
|
||||
found = true
|
||||
|
||||
assert.True(
|
||||
t, c.HttpOnly,
|
||||
"session cookie should be HTTP-only",
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(
|
||||
t, found,
|
||||
"should find a cookie named %s", session.SessionName,
|
||||
)
|
||||
}
|
||||
|
||||
// --- SetUser and User Retrieval Tests ---
|
||||
|
||||
func TestSetUser_SetsAllFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-abc-123", "alice")
|
||||
|
||||
assert.Equal(
|
||||
t, "user-abc-123", sess.Values[session.UserIDKey],
|
||||
)
|
||||
assert.Equal(
|
||||
t, "alice", sess.Values[session.UsernameKey],
|
||||
)
|
||||
assert.Equal(
|
||||
t, true, sess.Values[session.AuthenticatedKey],
|
||||
)
|
||||
}
|
||||
|
||||
func TestGetUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Before setting user
|
||||
userID, ok := s.GetUserID(sess)
|
||||
assert.False(
|
||||
t, ok, "should return false when no user ID is set",
|
||||
)
|
||||
assert.Empty(t, userID)
|
||||
|
||||
// After setting user
|
||||
s.SetUser(sess, "user-xyz", "bob")
|
||||
|
||||
userID, ok = s.GetUserID(sess)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user-xyz", userID)
|
||||
}
|
||||
|
||||
func TestGetUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Before setting user
|
||||
username, ok := s.GetUsername(sess)
|
||||
assert.False(
|
||||
t, ok, "should return false when no username is set",
|
||||
)
|
||||
assert.Empty(t, username)
|
||||
|
||||
// After setting user
|
||||
s.SetUser(sess, "user-xyz", "bob")
|
||||
|
||||
username, ok = s.GetUsername(sess)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "bob", username)
|
||||
}
|
||||
|
||||
// --- IsAuthenticated Tests ---
|
||||
|
||||
func TestIsAuthenticated_NoSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"new session should not be authenticated",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_AfterSetUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-123", "alice")
|
||||
assert.True(t, s.IsAuthenticated(sess))
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_AfterClearUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-123", "alice")
|
||||
require.True(t, s.IsAuthenticated(sess))
|
||||
|
||||
s.ClearUser(sess)
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"should not be authenticated after ClearUser",
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsAuthenticated_WrongType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set authenticated to a non-bool value
|
||||
sess.Values[session.AuthenticatedKey] = "yes"
|
||||
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"should return false for non-bool authenticated value",
|
||||
)
|
||||
}
|
||||
|
||||
// --- ClearUser Tests ---
|
||||
|
||||
func TestClearUser_RemovesAllKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-123", "alice")
|
||||
s.ClearUser(sess)
|
||||
|
||||
_, hasUserID := sess.Values[session.UserIDKey]
|
||||
assert.False(t, hasUserID, "UserIDKey should be removed")
|
||||
|
||||
_, hasUsername := sess.Values[session.UsernameKey]
|
||||
assert.False(t, hasUsername, "UsernameKey should be removed")
|
||||
|
||||
_, hasAuth := sess.Values[session.AuthenticatedKey]
|
||||
assert.False(
|
||||
t, hasAuth, "AuthenticatedKey should be removed",
|
||||
)
|
||||
}
|
||||
|
||||
// --- Destroy Tests ---
|
||||
|
||||
func TestDestroy_InvalidatesSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-123", "alice")
|
||||
|
||||
s.Destroy(sess)
|
||||
|
||||
// After Destroy: MaxAge should be -1 (delete cookie) and
|
||||
// user data cleared
|
||||
assert.Equal(
|
||||
t, -1, sess.Options.MaxAge,
|
||||
"Destroy should set MaxAge to -1",
|
||||
)
|
||||
assert.False(
|
||||
t, s.IsAuthenticated(sess),
|
||||
"should not be authenticated after Destroy",
|
||||
)
|
||||
|
||||
_, hasUserID := sess.Values[session.UserIDKey]
|
||||
assert.False(t, hasUserID, "Destroy should clear user ID")
|
||||
}
|
||||
|
||||
// --- Session Persistence Round-Trip ---
|
||||
|
||||
func TestSessionPersistence_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
// Step 1: Create session, set user, save
|
||||
req1 := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
|
||||
sess1, err := s.Get(req1)
|
||||
require.NoError(t, err)
|
||||
s.SetUser(sess1, "user-round-trip", "charlie")
|
||||
require.NoError(t, s.Save(req1, w1, sess1))
|
||||
|
||||
cookies := w1.Result().Cookies()
|
||||
require.NotEmpty(t, cookies)
|
||||
|
||||
// Step 2: New request with cookies -- session data should
|
||||
// persist
|
||||
req2 := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/profile", nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req2.AddCookie(c)
|
||||
}
|
||||
|
||||
sess2, err := s.Get(req2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(
|
||||
t, s.IsAuthenticated(sess2),
|
||||
"session should be authenticated after round-trip",
|
||||
)
|
||||
|
||||
userID, ok := s.GetUserID(sess2)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user-round-trip", userID)
|
||||
|
||||
username, ok := s.GetUsername(sess2)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "charlie", username)
|
||||
}
|
||||
|
||||
// --- Constants Tests ---
|
||||
|
||||
func TestSessionConstants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "webhooker_session", session.SessionName)
|
||||
assert.Equal(t, "user_id", session.UserIDKey)
|
||||
assert.Equal(t, "username", session.UsernameKey)
|
||||
assert.Equal(t, "authenticated", session.AuthenticatedKey)
|
||||
}
|
||||
|
||||
// --- Edge Cases ---
|
||||
|
||||
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
|
||||
sess, err := s.Get(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
s.SetUser(sess, "user-1", "alice")
|
||||
assert.True(t, s.IsAuthenticated(sess))
|
||||
|
||||
// Overwrite with a different user
|
||||
s.SetUser(sess, "user-2", "bob")
|
||||
|
||||
userID, ok := s.GetUserID(sess)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "user-2", userID)
|
||||
|
||||
username, ok := s.GetUsername(sess)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "bob", username)
|
||||
}
|
||||
|
||||
func TestDestroy_ThenSave_DeletesCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := testSession(t)
|
||||
|
||||
// Create a session
|
||||
req1 := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
|
||||
sess, err := s.Get(req1)
|
||||
require.NoError(t, err)
|
||||
s.SetUser(sess, "user-123", "alice")
|
||||
require.NoError(t, s.Save(req1, w1, sess))
|
||||
|
||||
cookies := w1.Result().Cookies()
|
||||
require.NotEmpty(t, cookies)
|
||||
|
||||
// Destroy and save
|
||||
req2 := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/logout", nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req2.AddCookie(c)
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
|
||||
sess2, err := s.Get(req2)
|
||||
require.NoError(t, err)
|
||||
s.Destroy(sess2)
|
||||
require.NoError(t, s.Save(req2, w2, sess2))
|
||||
|
||||
// The cookie should have MaxAge = -1 (browser should delete)
|
||||
responseCookies := w2.Result().Cookies()
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
|
||||
for _, c := range responseCookies {
|
||||
if c.Name == session.SessionName {
|
||||
sessionCookie = c
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(
|
||||
t, sessionCookie,
|
||||
"should have a session cookie in response",
|
||||
)
|
||||
assert.Negative(
|
||||
t, sessionCookie.MaxAge,
|
||||
"destroyed session cookie should have negative MaxAge",
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
)
|
||||
|
||||
// NewForTest creates a Session with a pre-configured cookie store for use
|
||||
// in tests. This bypasses the fx lifecycle and database dependency, allowing
|
||||
// middleware and handler tests to use real session functionality. The key
|
||||
// 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{
|
||||
store: store,
|
||||
key: key,
|
||||
config: cfg,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
# Configuration Module (Go)
|
||||
|
||||
A simple, clean, and generic configuration management system that supports multiple environments and automatic value resolution. This module is completely standalone and can be used in any Go project.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple API**: Just `config.Get()` and `config.GetSecret()`
|
||||
- **Type-safe helpers**: `config.GetString()`, `config.GetInt()`, `config.GetBool()`
|
||||
- **Environment Support**: Separate configs for different environments (dev/prod/staging/etc)
|
||||
- **Value Resolution**: Automatic resolution of special values:
|
||||
- `$ENV:VARIABLE` - Read from environment variable
|
||||
- `$GSM:secret-name` - Read from Google Secret Manager
|
||||
- `$ASM:secret-name` - Read from AWS Secrets Manager
|
||||
- `$FILE:/path/to/file` - Read from file contents
|
||||
- **Hierarchical Defaults**: Environment-specific values override defaults
|
||||
- **YAML-based**: Easy to read and edit configuration files
|
||||
- **Thread-safe**: Safe for concurrent use
|
||||
- **Testable**: Uses afero filesystem abstraction for easy testing
|
||||
- **Minimal Dependencies**: Only requires YAML parser and cloud SDKs (optional)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.eeqj.de/sneak/webhooker/pkg/config
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set the environment explicitly
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get configuration values
|
||||
baseURL := config.GetString("baseURL")
|
||||
apiTimeout := config.GetInt("timeout", 30)
|
||||
debugMode := config.GetBool("debugMode", false)
|
||||
|
||||
// Get secret values
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
dbPassword := config.GetSecretString("db_password", "default")
|
||||
|
||||
// Get all values (for debugging)
|
||||
allConfig := config.GetAllConfig()
|
||||
allSecrets := config.GetAllSecrets()
|
||||
|
||||
// Reload configuration from file
|
||||
if err := config.Reload(); err != nil {
|
||||
fmt.Printf("Failed to reload config: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration File Structure
|
||||
|
||||
Create a `config.yaml` file in your project root:
|
||||
|
||||
```yaml
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
baseURL: https://dev.example.com
|
||||
debugMode: true
|
||||
timeout: 30
|
||||
secrets:
|
||||
api_key: dev-key-12345
|
||||
db_password: $ENV:DEV_DB_PASSWORD
|
||||
|
||||
prod:
|
||||
config:
|
||||
baseURL: https://prod.example.com
|
||||
debugMode: false
|
||||
timeout: 10
|
||||
GCPProject: my-project-123
|
||||
AWSRegion: us-west-2
|
||||
secrets:
|
||||
api_key: $GSM:prod-api-key
|
||||
db_password: $ASM:prod/db/password
|
||||
|
||||
configDefaults:
|
||||
app_name: my-app
|
||||
timeout: 30
|
||||
log_level: INFO
|
||||
port: 8080
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Environment Selection**: Call `config.SetEnvironment("prod")` to select which environment to use
|
||||
|
||||
2. **Value Lookup**: When you call `config.Get("key")`:
|
||||
- First checks `environments.<env>.config.key`
|
||||
- Falls back to `configDefaults.key`
|
||||
- Returns the default value if not found
|
||||
|
||||
3. **Secret Lookup**: When you call `config.GetSecret("key")`:
|
||||
- Looks in `environments.<env>.secrets.key`
|
||||
- Returns the default value if not found
|
||||
|
||||
4. **Value Resolution**: If a value starts with a special prefix:
|
||||
- `$ENV:` - Reads from environment variable
|
||||
- `$GSM:` - Fetches from Google Secret Manager (requires GCPProject to be set in config)
|
||||
- `$ASM:` - Fetches from AWS Secrets Manager (uses AWSRegion from config or defaults to us-east-1)
|
||||
- `$FILE:` - Reads from file (supports `~` expansion)
|
||||
|
||||
## Type-Safe Access
|
||||
|
||||
The module provides type-safe helper functions:
|
||||
|
||||
```go
|
||||
// String values
|
||||
baseURL := config.GetString("baseURL", "http://localhost")
|
||||
|
||||
// Integer values
|
||||
port := config.GetInt("port", 8080)
|
||||
|
||||
// Boolean values
|
||||
debug := config.GetBool("debug", false)
|
||||
|
||||
// Secret string values
|
||||
apiKey := config.GetSecretString("api_key", "default-key")
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
For local development, you can:
|
||||
|
||||
1. Use environment variables:
|
||||
```yaml
|
||||
secrets:
|
||||
api_key: $ENV:LOCAL_API_KEY
|
||||
```
|
||||
|
||||
2. Use local files:
|
||||
```yaml
|
||||
secrets:
|
||||
api_key: $FILE:~/.secrets/api-key.txt
|
||||
```
|
||||
|
||||
3. Create a `config.local.yaml` (gitignored) with literal values for testing
|
||||
|
||||
## Cloud Provider Support
|
||||
|
||||
### Google Secret Manager
|
||||
|
||||
To use GSM resolution (`$GSM:` prefix):
|
||||
1. Set `GCPProject` in your config
|
||||
2. Ensure proper authentication (e.g., `GOOGLE_APPLICATION_CREDENTIALS` environment variable)
|
||||
3. The module will automatically initialize the GSM client when needed
|
||||
|
||||
### AWS Secrets Manager
|
||||
|
||||
To use ASM resolution (`$ASM:` prefix):
|
||||
1. Optionally set `AWSRegion` in your config (defaults to us-east-1)
|
||||
2. Ensure proper authentication (e.g., AWS credentials in environment or IAM role)
|
||||
3. The module will automatically initialize the ASM client when needed
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Loading from a Specific File
|
||||
|
||||
```go
|
||||
// Load configuration from a specific file
|
||||
if err := config.LoadFile("/path/to/config.yaml"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Configuration Values
|
||||
|
||||
```go
|
||||
// Get all configuration for current environment
|
||||
allConfig := config.GetAllConfig()
|
||||
for key, value := range allConfig {
|
||||
fmt.Printf("%s: %v\n", key, value)
|
||||
}
|
||||
|
||||
// Get all secrets (be careful with logging!)
|
||||
allSecrets := config.GetAllSecrets()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The module uses the [afero](https://github.com/spf13/afero) filesystem abstraction, making it easy to test without real files:
|
||||
|
||||
```go
|
||||
package myapp_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/spf13/afero"
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func TestMyApp(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Write a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
apiURL: http://test.example.com
|
||||
secrets:
|
||||
apiKey: test-key-123
|
||||
`
|
||||
afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644)
|
||||
|
||||
// Use the test filesystem
|
||||
config.SetFs(fs)
|
||||
config.SetEnvironment("test")
|
||||
|
||||
// Now your tests use the in-memory config
|
||||
if url := config.GetString("apiURL"); url != "http://test.example.com" {
|
||||
t.Errorf("Expected test URL, got %s", url)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unit Testing with Isolated Config
|
||||
|
||||
For unit tests, you can create isolated configuration managers:
|
||||
|
||||
```go
|
||||
func TestMyComponent(t *testing.T) {
|
||||
// Create a test-specific manager
|
||||
manager := config.NewManager()
|
||||
|
||||
// Use in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644)
|
||||
manager.SetFs(fs)
|
||||
|
||||
// Test with isolated configuration
|
||||
manager.SetEnvironment("test")
|
||||
value := manager.Get("someKey", "default")
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If a config file is not found when using the default loader, an error is returned
|
||||
- If a key is not found, the default value is returned
|
||||
- If a special value cannot be resolved (e.g., env var not set, file not found), `nil` is returned
|
||||
- Cloud provider errors are logged but return `nil` to allow graceful degradation
|
||||
|
||||
## Thread Safety
|
||||
|
||||
All operations are thread-safe. The module uses read-write mutexes to ensure safe concurrent access to configuration data.
|
||||
|
||||
## Example Integration
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read environment from your app-specific env var
|
||||
environment := os.Getenv("APP_ENV")
|
||||
if environment == "" {
|
||||
environment = "dev"
|
||||
}
|
||||
|
||||
config.SetEnvironment(environment)
|
||||
|
||||
// Now use configuration throughout your app
|
||||
databaseURL := config.GetString("database_url")
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
|
||||
log.Printf("Running in %s environment", environment)
|
||||
log.Printf("Database URL: %s", databaseURL)
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Python Version
|
||||
|
||||
The Go version maintains API compatibility with the Python version where possible:
|
||||
|
||||
| Python | Go |
|
||||
|--------|-----|
|
||||
| `config.get('key')` | `config.Get("key")` or `config.GetString("key")` |
|
||||
| `config.getSecret('key')` | `config.GetSecret("key")` or `config.GetSecretString("key")` |
|
||||
| `config.set_environment('prod')` | `config.SetEnvironment("prod")` |
|
||||
| `config.reload()` | `config.Reload()` |
|
||||
| `config.get_all_config()` | `config.GetAllConfig()` |
|
||||
| `config.get_all_secrets()` | `config.GetAllSecrets()` |
|
||||
|
||||
## License
|
||||
|
||||
This module is designed to be standalone and can be extracted into its own repository with your preferred license.
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package config provides a simple, clean, and generic configuration management system
|
||||
// that supports multiple environments and automatic value resolution.
|
||||
//
|
||||
// Features:
|
||||
// - Simple API: Just config.Get() and config.GetSecret()
|
||||
// - Environment Support: Separate configs for different environments (dev/prod/staging/etc)
|
||||
// - Value Resolution: Automatic resolution of special values:
|
||||
// - $ENV:VARIABLE - Read from environment variable
|
||||
// - $GSM:secret-name - Read from Google Secret Manager
|
||||
// - $ASM:secret-name - Read from AWS Secrets Manager
|
||||
// - $FILE:/path/to/file - Read from file contents
|
||||
// - Hierarchical Defaults: Environment-specific values override defaults
|
||||
// - YAML-based: Easy to read and edit configuration files
|
||||
// - Zero Dependencies: Only depends on yaml and cloud provider SDKs (optional)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import "git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
//
|
||||
// // Set the environment explicitly
|
||||
// config.SetEnvironment("prod")
|
||||
//
|
||||
// // Get configuration values
|
||||
// baseURL := config.Get("baseURL")
|
||||
// apiTimeout := config.GetInt("timeout", 30)
|
||||
//
|
||||
// // Get secret values
|
||||
// apiKey := config.GetSecret("api_key")
|
||||
// dbPassword := config.GetSecret("db_password", "default")
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Global configuration manager instance
|
||||
var (
|
||||
globalManager *Manager
|
||||
mu sync.Mutex // Protect global manager updates
|
||||
)
|
||||
|
||||
// getManager returns the global configuration manager, creating it if necessary
|
||||
func getManager() *Manager {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if globalManager == nil {
|
||||
globalManager = NewManager()
|
||||
}
|
||||
return globalManager
|
||||
}
|
||||
|
||||
// SetEnvironment sets the active environment.
|
||||
func SetEnvironment(environment string) {
|
||||
getManager().SetEnvironment(environment)
|
||||
}
|
||||
|
||||
// SetFs sets the filesystem to use for all file operations.
|
||||
// This is primarily useful for testing with an in-memory filesystem.
|
||||
func SetFs(fs afero.Fs) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Create a new manager with the specified filesystem
|
||||
newManager := NewManager()
|
||||
newManager.SetFs(fs)
|
||||
|
||||
// Replace the global manager
|
||||
globalManager = newManager
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value.
|
||||
//
|
||||
// This looks for values in the following order:
|
||||
// 1. Environment-specific config (environments.<env>.config.<key>)
|
||||
// 2. Config defaults (configDefaults.<key>)
|
||||
//
|
||||
// Values are resolved if they contain special prefixes:
|
||||
// - $ENV:VARIABLE_NAME - reads from environment variable
|
||||
// - $GSM:secret-name - reads from Google Secret Manager
|
||||
// - $ASM:secret-name - reads from AWS Secrets Manager
|
||||
// - $FILE:/path/to/file - reads from file
|
||||
func Get(key string, defaultValue ...interface{}) interface{} {
|
||||
var def interface{}
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
return getManager().Get(key, def)
|
||||
}
|
||||
|
||||
// GetString retrieves a configuration value as a string.
|
||||
func GetString(key string, defaultValue ...string) string {
|
||||
var def string
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := Get(key, def)
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// GetInt retrieves a configuration value as an integer.
|
||||
func GetInt(key string, defaultValue ...int) int {
|
||||
var def int
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := Get(key, def)
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// GetBool retrieves a configuration value as a boolean.
|
||||
func GetBool(key string, defaultValue ...bool) bool {
|
||||
var def bool
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := Get(key, def)
|
||||
if b, ok := val.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret value.
|
||||
//
|
||||
// This looks for secrets defined in environments.<env>.secrets.<key>
|
||||
func GetSecret(key string, defaultValue ...interface{}) interface{} {
|
||||
var def interface{}
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
return getManager().GetSecret(key, def)
|
||||
}
|
||||
|
||||
// GetSecretString retrieves a secret value as a string.
|
||||
func GetSecretString(key string, defaultValue ...string) string {
|
||||
var def string
|
||||
if len(defaultValue) > 0 {
|
||||
def = defaultValue[0]
|
||||
}
|
||||
val := GetSecret(key, def)
|
||||
if s, ok := val.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Reload reloads the configuration from file.
|
||||
func Reload() error {
|
||||
return getManager().Reload()
|
||||
}
|
||||
|
||||
// GetAllConfig returns all configuration values for the current environment.
|
||||
func GetAllConfig() map[string]interface{} {
|
||||
return getManager().GetAllConfig()
|
||||
}
|
||||
|
||||
// GetAllSecrets returns all secrets for the current environment.
|
||||
func GetAllSecrets() map[string]interface{} {
|
||||
return getManager().GetAllSecrets()
|
||||
}
|
||||
|
||||
// LoadFile loads configuration from a specific file.
|
||||
func LoadFile(configFile string) error {
|
||||
return getManager().LoadFile(configFile)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
manager := NewManager()
|
||||
if manager == nil {
|
||||
t.Fatal("NewManager returned nil")
|
||||
}
|
||||
if manager.config == nil {
|
||||
t.Error("Manager config map is nil")
|
||||
}
|
||||
if manager.loader == nil {
|
||||
t.Error("Manager loader is nil")
|
||||
}
|
||||
if manager.resolvedCache == nil {
|
||||
t.Error("Manager resolvedCache is nil")
|
||||
}
|
||||
if manager.fs == nil {
|
||||
t.Error("Manager fs is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_FindConfigFile(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
loader := NewLoader(fs)
|
||||
|
||||
// Create a config file in the filesystem
|
||||
configContent := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
testKey: testValue
|
||||
secrets:
|
||||
testSecret: secretValue
|
||||
configDefaults:
|
||||
defaultKey: defaultValue
|
||||
`
|
||||
// Create the file in the current directory
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Test finding the config file
|
||||
foundPath, err := loader.FindConfigFile("config.yaml")
|
||||
if err != nil {
|
||||
t.Errorf("FindConfigFile failed: %v", err)
|
||||
}
|
||||
|
||||
// In memory fs, the path should be exactly what we created
|
||||
if foundPath != "config.yaml" {
|
||||
t.Errorf("Expected config.yaml, got %s", foundPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoader_LoadYAML(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
loader := NewLoader(fs)
|
||||
|
||||
// Create a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
testKey: testValue
|
||||
configDefaults:
|
||||
defaultKey: defaultValue
|
||||
`
|
||||
if err := afero.WriteFile(fs, "test-config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Load the YAML
|
||||
config, err := loader.LoadYAML("test-config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the structure
|
||||
envs, ok := config["environments"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("environments not found or wrong type")
|
||||
}
|
||||
|
||||
testEnv, ok := envs["test"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("test environment not found")
|
||||
}
|
||||
|
||||
testConfig2, ok := testEnv["config"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("test config not found")
|
||||
}
|
||||
|
||||
if testConfig2["testKey"] != "testValue" {
|
||||
t.Errorf("Expected testKey=testValue, got %v", testConfig2["testKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_ResolveEnv(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
resolver := NewResolver("", "", fs)
|
||||
|
||||
// Set a test environment variable
|
||||
os.Setenv("TEST_CONFIG_VAR", "test-value")
|
||||
defer os.Unsetenv("TEST_CONFIG_VAR")
|
||||
|
||||
// Test resolving environment variable
|
||||
result := resolver.Resolve("$ENV:TEST_CONFIG_VAR")
|
||||
if result != "test-value" {
|
||||
t.Errorf("Expected 'test-value', got %v", result)
|
||||
}
|
||||
|
||||
// Test non-existent env var
|
||||
result = resolver.Resolve("$ENV:NON_EXISTENT_VAR")
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for non-existent env var, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_ResolveFile(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
resolver := NewResolver("", "", fs)
|
||||
|
||||
// Create a test file
|
||||
secretContent := "my-secret-value"
|
||||
if err := afero.WriteFile(fs, "/test-secret.txt", []byte(secretContent+"\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
// Test resolving file
|
||||
result := resolver.Resolve("$FILE:/test-secret.txt")
|
||||
if result != secretContent {
|
||||
t.Errorf("Expected '%s', got %v", secretContent, result)
|
||||
}
|
||||
|
||||
// Test non-existent file
|
||||
result = resolver.Resolve("$FILE:/non/existent/file")
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for non-existent file, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetAndSet(t *testing.T) {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
apiURL: http://dev.example.com
|
||||
timeout: 30
|
||||
debug: true
|
||||
secrets:
|
||||
apiKey: dev-key-123
|
||||
prod:
|
||||
config:
|
||||
apiURL: https://prod.example.com
|
||||
timeout: 10
|
||||
debug: false
|
||||
secrets:
|
||||
apiKey: $ENV:PROD_API_KEY
|
||||
configDefaults:
|
||||
appName: TestApp
|
||||
timeout: 20
|
||||
port: 8080
|
||||
`
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Create manager and set the filesystem
|
||||
manager := NewManager()
|
||||
manager.SetFs(fs)
|
||||
|
||||
// Load config should find the file automatically
|
||||
manager.SetEnvironment("dev")
|
||||
|
||||
// Test getting config values
|
||||
if v := manager.Get("apiURL", ""); v != "http://dev.example.com" {
|
||||
t.Errorf("Expected dev apiURL, got %v", v)
|
||||
}
|
||||
|
||||
if v := manager.Get("timeout", 0); v != 30 {
|
||||
t.Errorf("Expected timeout=30, got %v", v)
|
||||
}
|
||||
|
||||
if v := manager.Get("debug", false); v != true {
|
||||
t.Errorf("Expected debug=true, got %v", v)
|
||||
}
|
||||
|
||||
// Test default values
|
||||
if v := manager.Get("appName", ""); v != "TestApp" {
|
||||
t.Errorf("Expected appName from defaults, got %v", v)
|
||||
}
|
||||
|
||||
// Test getting secrets
|
||||
if v := manager.GetSecret("apiKey", ""); v != "dev-key-123" {
|
||||
t.Errorf("Expected dev apiKey, got %v", v)
|
||||
}
|
||||
|
||||
// Switch to prod environment
|
||||
manager.SetEnvironment("prod")
|
||||
|
||||
if v := manager.Get("apiURL", ""); v != "https://prod.example.com" {
|
||||
t.Errorf("Expected prod apiURL, got %v", v)
|
||||
}
|
||||
|
||||
// Test environment variable resolution in secrets
|
||||
os.Setenv("PROD_API_KEY", "prod-key-456")
|
||||
defer os.Unsetenv("PROD_API_KEY")
|
||||
|
||||
if v := manager.GetSecret("apiKey", ""); v != "prod-key-456" {
|
||||
t.Errorf("Expected resolved env var for apiKey, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalAPI(t *testing.T) {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test config file
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
stringVal: hello
|
||||
intVal: 42
|
||||
boolVal: true
|
||||
secrets:
|
||||
secret1: test-secret
|
||||
configDefaults:
|
||||
defaultString: world
|
||||
`
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Use the global API with the test filesystem
|
||||
SetFs(fs)
|
||||
SetEnvironment("test")
|
||||
|
||||
// Test type-safe getters
|
||||
if v := GetString("stringVal"); v != "hello" {
|
||||
t.Errorf("Expected 'hello', got %v", v)
|
||||
}
|
||||
|
||||
if v := GetInt("intVal"); v != 42 {
|
||||
t.Errorf("Expected 42, got %v", v)
|
||||
}
|
||||
|
||||
if v := GetBool("boolVal"); v != true {
|
||||
t.Errorf("Expected true, got %v", v)
|
||||
}
|
||||
|
||||
if v := GetSecretString("secret1"); v != "test-secret" {
|
||||
t.Errorf("Expected 'test-secret', got %v", v)
|
||||
}
|
||||
|
||||
// Test defaults
|
||||
if v := GetString("defaultString"); v != "world" {
|
||||
t.Errorf("Expected 'world', got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_SetFs(t *testing.T) {
|
||||
// Create manager with default OS filesystem
|
||||
manager := NewManager()
|
||||
|
||||
// Create an in-memory filesystem
|
||||
memFs := afero.NewMemMapFs()
|
||||
|
||||
// Write a config file to the memory fs
|
||||
testConfig := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
testKey: fromMemory
|
||||
configDefaults:
|
||||
defaultKey: memoryDefault
|
||||
`
|
||||
if err := afero.WriteFile(memFs, "config.yaml", []byte(testConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
// Set the filesystem
|
||||
manager.SetFs(memFs)
|
||||
manager.SetEnvironment("test")
|
||||
|
||||
// Test that it reads from the memory filesystem
|
||||
if v := manager.Get("testKey", ""); v != "fromMemory" {
|
||||
t.Errorf("Expected 'fromMemory', got %v", v)
|
||||
}
|
||||
|
||||
if v := manager.Get("defaultKey", ""); v != "memoryDefault" {
|
||||
t.Errorf("Expected 'memoryDefault', got %v", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// ExampleSetFs demonstrates how to use an in-memory filesystem for testing
|
||||
func ExampleSetFs() {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a test configuration file
|
||||
configYAML := `
|
||||
environments:
|
||||
test:
|
||||
config:
|
||||
baseURL: https://test.example.com
|
||||
debugMode: true
|
||||
secrets:
|
||||
apiKey: test-key-12345
|
||||
production:
|
||||
config:
|
||||
baseURL: https://api.example.com
|
||||
debugMode: false
|
||||
configDefaults:
|
||||
appName: Test Application
|
||||
timeout: 30
|
||||
`
|
||||
|
||||
// Write the config to the in-memory filesystem
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Use the in-memory filesystem
|
||||
config.SetFs(fs)
|
||||
config.SetEnvironment("test")
|
||||
|
||||
// Now all config operations use the in-memory filesystem
|
||||
fmt.Printf("Base URL: %s\n", config.GetString("baseURL"))
|
||||
fmt.Printf("Debug Mode: %v\n", config.GetBool("debugMode"))
|
||||
fmt.Printf("App Name: %s\n", config.GetString("appName"))
|
||||
|
||||
// Output:
|
||||
// Base URL: https://test.example.com
|
||||
// Debug Mode: true
|
||||
// App Name: Test Application
|
||||
}
|
||||
|
||||
// TestWithAferoFilesystem shows how to test with different filesystem implementations
|
||||
func TestWithAferoFilesystem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFs func() afero.Fs
|
||||
environment string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "in-memory filesystem",
|
||||
setupFs: func() afero.Fs {
|
||||
fs := afero.NewMemMapFs()
|
||||
config := `
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
apiURL: http://localhost:8080
|
||||
`
|
||||
afero.WriteFile(fs, "config.yaml", []byte(config), 0644)
|
||||
return fs
|
||||
},
|
||||
environment: "dev",
|
||||
key: "apiURL",
|
||||
expected: "http://localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "readonly filesystem",
|
||||
setupFs: func() afero.Fs {
|
||||
memFs := afero.NewMemMapFs()
|
||||
config := `
|
||||
environments:
|
||||
staging:
|
||||
config:
|
||||
apiURL: https://staging.example.com
|
||||
`
|
||||
afero.WriteFile(memFs, "config.yaml", []byte(config), 0644)
|
||||
// Wrap in a read-only filesystem
|
||||
return afero.NewReadOnlyFs(memFs)
|
||||
},
|
||||
environment: "staging",
|
||||
key: "apiURL",
|
||||
expected: "https://staging.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a new manager for each test to ensure isolation
|
||||
manager := config.NewManager()
|
||||
manager.SetFs(tt.setupFs())
|
||||
manager.SetEnvironment(tt.environment)
|
||||
|
||||
result := manager.Get(tt.key, "")
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileResolution shows how $FILE: resolution works with afero
|
||||
func TestFileResolution(t *testing.T) {
|
||||
// Create an in-memory filesystem
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create a secret file
|
||||
secretContent := "super-secret-api-key"
|
||||
if err := afero.WriteFile(fs, "/secrets/api-key.txt", []byte(secretContent), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a config that references the file
|
||||
configYAML := `
|
||||
environments:
|
||||
prod:
|
||||
secrets:
|
||||
apiKey: $FILE:/secrets/api-key.txt
|
||||
`
|
||||
if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Use the filesystem
|
||||
config.SetFs(fs)
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get the secret - it should resolve from the file
|
||||
apiKey := config.GetSecretString("apiKey")
|
||||
if apiKey != secretContent {
|
||||
t.Errorf("Expected %s, got %s", secretContent, apiKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/webhooker/pkg/config"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
// Set the environment explicitly
|
||||
config.SetEnvironment("dev")
|
||||
|
||||
// Get configuration values
|
||||
baseURL := config.GetString("baseURL")
|
||||
timeout := config.GetInt("timeout", 30)
|
||||
debugMode := config.GetBool("debugMode", false)
|
||||
|
||||
fmt.Printf("Base URL: %s\n", baseURL)
|
||||
fmt.Printf("Timeout: %d\n", timeout)
|
||||
fmt.Printf("Debug Mode: %v\n", debugMode)
|
||||
|
||||
// Get secret values
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
if apiKey != "" {
|
||||
fmt.Printf("API Key: %s...\n", apiKey[:8])
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleSetEnvironment() {
|
||||
// Your application determines which environment to use
|
||||
// This could come from command line args, env vars, etc.
|
||||
environment := os.Getenv("APP_ENV")
|
||||
if environment == "" {
|
||||
environment = "development"
|
||||
}
|
||||
|
||||
// Set the environment explicitly
|
||||
config.SetEnvironment(environment)
|
||||
|
||||
// Now use configuration throughout your application
|
||||
fmt.Printf("Environment: %s\n", environment)
|
||||
fmt.Printf("App Name: %s\n", config.GetString("app_name"))
|
||||
}
|
||||
|
||||
func ExampleGetString() {
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get a string configuration value with a default
|
||||
baseURL := config.GetString("baseURL", "http://localhost:8080")
|
||||
fmt.Printf("Base URL: %s\n", baseURL)
|
||||
}
|
||||
|
||||
func ExampleGetInt() {
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get an integer configuration value with a default
|
||||
port := config.GetInt("port", 8080)
|
||||
fmt.Printf("Port: %d\n", port)
|
||||
}
|
||||
|
||||
func ExampleGetBool() {
|
||||
config.SetEnvironment("dev")
|
||||
|
||||
// Get a boolean configuration value with a default
|
||||
debugMode := config.GetBool("debugMode", false)
|
||||
fmt.Printf("Debug Mode: %v\n", debugMode)
|
||||
}
|
||||
|
||||
func ExampleGetSecretString() {
|
||||
config.SetEnvironment("prod")
|
||||
|
||||
// Get a secret string value
|
||||
apiKey := config.GetSecretString("api_key")
|
||||
if apiKey != "" {
|
||||
// Be careful not to log the full secret!
|
||||
fmt.Printf("API Key configured: yes\n")
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleLoadFile() {
|
||||
// Load configuration from a specific file
|
||||
if err := config.LoadFile("/path/to/config.yaml"); err != nil {
|
||||
log.Printf("Failed to load config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
config.SetEnvironment("staging")
|
||||
fmt.Printf("Loaded configuration from custom file\n")
|
||||
}
|
||||
|
||||
func ExampleReload() {
|
||||
config.SetEnvironment("dev")
|
||||
|
||||
// Get initial value
|
||||
oldValue := config.GetString("some_key")
|
||||
|
||||
// ... config file might have been updated ...
|
||||
|
||||
// Reload configuration from file
|
||||
if err := config.Reload(); err != nil {
|
||||
log.Printf("Failed to reload config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get potentially updated value
|
||||
newValue := config.GetString("some_key")
|
||||
fmt.Printf("Value changed: %v\n", oldValue != newValue)
|
||||
}
|
||||
|
||||
// Example config.yaml structure:
|
||||
/*
|
||||
environments:
|
||||
development:
|
||||
config:
|
||||
baseURL: http://localhost:8000
|
||||
debugMode: true
|
||||
port: 8000
|
||||
secrets:
|
||||
api_key: dev-key-12345
|
||||
|
||||
production:
|
||||
config:
|
||||
baseURL: https://api.example.com
|
||||
debugMode: false
|
||||
port: 443
|
||||
GCPProject: my-project-123
|
||||
AWSRegion: us-west-2
|
||||
secrets:
|
||||
api_key: $GSM:prod-api-key
|
||||
db_password: $ASM:prod/db/password
|
||||
|
||||
configDefaults:
|
||||
app_name: My Application
|
||||
timeout: 30
|
||||
log_level: INFO
|
||||
port: 8080
|
||||
*/
|
||||
@@ -0,0 +1,41 @@
|
||||
module git.eeqj.de/sneak/webhooker/pkg/config
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.50.0
|
||||
github.com/spf13/afero v1.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute v1.23.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v1.1.3 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/oauth2 v0.13.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/api v0.149.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect
|
||||
google.golang.org/grpc v1.59.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/secretmanager v1.11.4
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME=
|
||||
cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk=
|
||||
cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0=
|
||||
cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc=
|
||||
cloud.google.com/go/iam v1.1.3/go.mod h1:3khUlaBXfPKKe7huYgEpDn6FtgRyMEqbkvBxrQyY5SE=
|
||||
cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k=
|
||||
cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI=
|
||||
github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
|
||||
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY=
|
||||
google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
|
||||
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
|
||||
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Loader handles loading configuration from YAML files.
|
||||
type Loader struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewLoader creates a new configuration loader.
|
||||
func NewLoader(fs afero.Fs) *Loader {
|
||||
return &Loader{
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// FindConfigFile searches for a configuration file by looking up the directory tree.
|
||||
func (l *Loader) FindConfigFile(filename string) (string, error) {
|
||||
if filename == "" {
|
||||
filename = "config.yaml"
|
||||
}
|
||||
|
||||
// First check if the file exists in the current directory (simple case)
|
||||
if _, err := l.fs.Stat(filename); err == nil {
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// For more complex cases, try to walk up the directory tree
|
||||
// Start from current directory or root for in-memory filesystems
|
||||
currentDir := "."
|
||||
|
||||
// Try to get the absolute path, but if it fails (e.g., in-memory fs),
|
||||
// just use the current directory
|
||||
if absPath, err := filepath.Abs("."); err == nil {
|
||||
currentDir = absPath
|
||||
}
|
||||
|
||||
// Search up the directory tree
|
||||
for {
|
||||
configPath := filepath.Join(currentDir, filename)
|
||||
if _, err := l.fs.Stat(configPath); err == nil {
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// Move up one directory
|
||||
parentDir := filepath.Dir(currentDir)
|
||||
if parentDir == currentDir || currentDir == "." || currentDir == "/" {
|
||||
// Reached the root directory or can't go up further
|
||||
break
|
||||
}
|
||||
currentDir = parentDir
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("configuration file %s not found in directory tree", filename)
|
||||
}
|
||||
|
||||
// LoadYAML loads a YAML file and returns the parsed configuration.
|
||||
func (l *Loader) LoadYAML(filePath string) (map[string]interface{}, error) {
|
||||
data, err := afero.ReadFile(l.fs, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML from %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
config = make(map[string]interface{})
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// MergeConfigs performs a deep merge of two configuration maps.
|
||||
// The override map values take precedence over the base map.
|
||||
func (l *Loader) MergeConfigs(base, override map[string]interface{}) map[string]interface{} {
|
||||
if base == nil {
|
||||
base = make(map[string]interface{})
|
||||
}
|
||||
|
||||
for key, value := range override {
|
||||
if baseValue, exists := base[key]; exists {
|
||||
// If both values are maps, merge them recursively
|
||||
if baseMap, baseOk := baseValue.(map[string]interface{}); baseOk {
|
||||
if overrideMap, overrideOk := value.(map[string]interface{}); overrideOk {
|
||||
base[key] = l.MergeConfigs(baseMap, overrideMap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, override the value
|
||||
base[key] = value
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Manager manages application configuration with value resolution.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
config map[string]interface{}
|
||||
environment string
|
||||
resolver *Resolver
|
||||
loader *Loader
|
||||
configFile string
|
||||
resolvedCache map[string]interface{}
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewManager creates a new configuration manager.
|
||||
func NewManager() *Manager {
|
||||
fs := afero.NewOsFs()
|
||||
return &Manager{
|
||||
config: make(map[string]interface{}),
|
||||
loader: NewLoader(fs),
|
||||
resolvedCache: make(map[string]interface{}),
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFs sets the filesystem to use for all file operations.
|
||||
// This is primarily useful for testing with an in-memory filesystem.
|
||||
func (m *Manager) SetFs(fs afero.Fs) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.fs = fs
|
||||
m.loader = NewLoader(fs)
|
||||
|
||||
// If we have a resolver, recreate it with the new fs
|
||||
if m.resolver != nil {
|
||||
gcpProject := ""
|
||||
awsRegion := "us-east-1"
|
||||
|
||||
// Try to get the current settings
|
||||
if gcpProj := m.getConfigValue("GCPProject", ""); gcpProj != nil {
|
||||
if str, ok := gcpProj.(string); ok {
|
||||
gcpProject = str
|
||||
}
|
||||
}
|
||||
if awsReg := m.getConfigValue("AWSRegion", "us-east-1"); awsReg != nil {
|
||||
if str, ok := awsReg.(string); ok {
|
||||
awsRegion = str
|
||||
}
|
||||
}
|
||||
|
||||
m.resolver = NewResolver(gcpProject, awsRegion, fs)
|
||||
}
|
||||
|
||||
// Clear caches as filesystem changed
|
||||
m.resolvedCache = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// LoadFile loads configuration from a specific file.
|
||||
func (m *Manager) LoadFile(configFile string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
config, err := m.loader.LoadYAML(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.config = config
|
||||
m.configFile = configFile
|
||||
m.resolvedCache = make(map[string]interface{}) // Clear cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfig loads the configuration from file.
|
||||
func (m *Manager) loadConfig() error {
|
||||
if m.configFile == "" {
|
||||
// Try to find config.yaml
|
||||
configPath, err := m.loader.FindConfigFile("config.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.configFile = configPath
|
||||
}
|
||||
|
||||
config, err := m.loader.LoadYAML(m.configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.config = config
|
||||
m.resolvedCache = make(map[string]interface{}) // Clear cache
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEnvironment sets the active environment.
|
||||
func (m *Manager) SetEnvironment(environment string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.environment = strings.ToLower(environment)
|
||||
|
||||
// Create resolver with GCP project and AWS region if available
|
||||
gcpProject := m.getConfigValue("GCPProject", "")
|
||||
awsRegion := m.getConfigValue("AWSRegion", "us-east-1")
|
||||
|
||||
if gcpProjectStr, ok := gcpProject.(string); ok {
|
||||
if awsRegionStr, ok := awsRegion.(string); ok {
|
||||
m.resolver = NewResolver(gcpProjectStr, awsRegionStr, m.fs)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resolved cache when environment changes
|
||||
m.resolvedCache = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value.
|
||||
func (m *Manager) Get(key string, defaultValue interface{}) interface{} {
|
||||
m.mu.RLock()
|
||||
|
||||
// Ensure config is loaded
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
// Need to upgrade to write lock to load config
|
||||
m.mu.RUnlock()
|
||||
m.mu.Lock()
|
||||
// Double-check after acquiring write lock
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
if err := m.loadConfig(); err != nil {
|
||||
log.Printf("Failed to load config: %v", err)
|
||||
m.mu.Unlock()
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
// Downgrade back to read lock
|
||||
m.mu.Unlock()
|
||||
m.mu.RLock()
|
||||
}
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("config.%s", key)
|
||||
if cached, ok := m.resolvedCache[cacheKey]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Try environment-specific config first
|
||||
var rawValue interface{}
|
||||
if m.environment != "" {
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if ok {
|
||||
if env, ok := envMap[m.environment].(map[string]interface{}); ok {
|
||||
if config, ok := env["config"].(map[string]interface{}); ok {
|
||||
if val, exists := config[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to configDefaults
|
||||
if rawValue == nil {
|
||||
if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok {
|
||||
if val, exists := defaults[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rawValue == nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Resolve the value if we have a resolver
|
||||
var resolvedValue interface{}
|
||||
if m.resolver != nil {
|
||||
resolvedValue = m.resolver.Resolve(rawValue)
|
||||
} else {
|
||||
resolvedValue = rawValue
|
||||
}
|
||||
|
||||
// Cache the resolved value
|
||||
m.resolvedCache[cacheKey] = resolvedValue
|
||||
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
// GetSecret retrieves a secret value for the current environment.
|
||||
func (m *Manager) GetSecret(key string, defaultValue interface{}) interface{} {
|
||||
m.mu.RLock()
|
||||
|
||||
// Ensure config is loaded
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
// Need to upgrade to write lock to load config
|
||||
m.mu.RUnlock()
|
||||
m.mu.Lock()
|
||||
// Double-check after acquiring write lock
|
||||
if m.config == nil || len(m.config) == 0 {
|
||||
if err := m.loadConfig(); err != nil {
|
||||
log.Printf("Failed to load config: %v", err)
|
||||
m.mu.Unlock()
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
// Downgrade back to read lock
|
||||
m.mu.Unlock()
|
||||
m.mu.RLock()
|
||||
}
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.environment == "" {
|
||||
log.Printf("No environment set when getting secret '%s'", key)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Get the current environment's config
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
env, ok := envMap[m.environment].(map[string]interface{})
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
secrets, ok := env["secrets"].(map[string]interface{})
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
secretValue, exists := secrets[key]
|
||||
if !exists {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Resolve the value
|
||||
if m.resolver != nil {
|
||||
resolved := m.resolver.Resolve(secretValue)
|
||||
if resolved == nil {
|
||||
return defaultValue
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
return secretValue
|
||||
}
|
||||
|
||||
// getConfigValue is an internal helper to get config values without locking.
|
||||
func (m *Manager) getConfigValue(key string, defaultValue interface{}) interface{} {
|
||||
// Try environment-specific config first
|
||||
var rawValue interface{}
|
||||
if m.environment != "" {
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if ok {
|
||||
if env, ok := envMap[m.environment].(map[string]interface{}); ok {
|
||||
if config, ok := env["config"].(map[string]interface{}); ok {
|
||||
if val, exists := config[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to configDefaults
|
||||
if rawValue == nil {
|
||||
if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok {
|
||||
if val, exists := defaults[key]; exists {
|
||||
rawValue = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rawValue == nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return rawValue
|
||||
}
|
||||
|
||||
// Reload reloads the configuration from file.
|
||||
func (m *Manager) Reload() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.loadConfig()
|
||||
}
|
||||
|
||||
// GetAllConfig returns all configuration values for the current environment.
|
||||
func (m *Manager) GetAllConfig() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Start with configDefaults
|
||||
if defaults, ok := m.config["configDefaults"].(map[string]interface{}); ok {
|
||||
for k, v := range defaults {
|
||||
if m.resolver != nil {
|
||||
result[k] = m.resolver.Resolve(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override with environment-specific config
|
||||
if m.environment != "" {
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if ok {
|
||||
if env, ok := envMap[m.environment].(map[string]interface{}); ok {
|
||||
if config, ok := env["config"].(map[string]interface{}); ok {
|
||||
for k, v := range config {
|
||||
if m.resolver != nil {
|
||||
result[k] = m.resolver.Resolve(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllSecrets returns all secrets for the current environment.
|
||||
func (m *Manager) GetAllSecrets() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if m.environment == "" {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
envMap, ok := m.config["environments"].(map[string]interface{})
|
||||
if !ok {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
env, ok := envMap[m.environment].(map[string]interface{})
|
||||
if !ok {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
secrets, ok := env["secrets"].(map[string]interface{})
|
||||
if !ok {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Resolve all secrets
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range secrets {
|
||||
if m.resolver != nil {
|
||||
result[k] = m.resolver.Resolve(v)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
secretmanager "cloud.google.com/go/secretmanager/apiv1"
|
||||
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/secretsmanager"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// Resolver handles resolution of configuration values with special prefixes.
|
||||
type Resolver struct {
|
||||
gcpProject string
|
||||
awsRegion string
|
||||
gsmClient *secretmanager.Client
|
||||
asmClient *secretsmanager.SecretsManager
|
||||
awsSession *session.Session
|
||||
specialValue *regexp.Regexp
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewResolver creates a new value resolver.
|
||||
func NewResolver(gcpProject, awsRegion string, fs afero.Fs) *Resolver {
|
||||
return &Resolver{
|
||||
gcpProject: gcpProject,
|
||||
awsRegion: awsRegion,
|
||||
specialValue: regexp.MustCompile(`^\$([A-Z]+):(.+)$`),
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resolves a configuration value that may contain special prefixes.
|
||||
func (r *Resolver) Resolve(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return r.resolveString(v)
|
||||
case map[string]interface{}:
|
||||
// Recursively resolve map values
|
||||
result := make(map[string]interface{})
|
||||
for k, val := range v {
|
||||
result[k] = r.Resolve(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
// Recursively resolve slice items
|
||||
result := make([]interface{}, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = r.Resolve(val)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
// Return non-string values as-is
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveString resolves a string value that may contain a special prefix.
|
||||
func (r *Resolver) resolveString(value string) interface{} {
|
||||
matches := r.specialValue.FindStringSubmatch(value)
|
||||
if matches == nil {
|
||||
return value
|
||||
}
|
||||
|
||||
resolverType := matches[1]
|
||||
resolverValue := matches[2]
|
||||
|
||||
switch resolverType {
|
||||
case "ENV":
|
||||
return r.resolveEnv(resolverValue)
|
||||
case "GSM":
|
||||
return r.resolveGSM(resolverValue)
|
||||
case "ASM":
|
||||
return r.resolveASM(resolverValue)
|
||||
case "FILE":
|
||||
return r.resolveFile(resolverValue)
|
||||
default:
|
||||
log.Printf("Unknown resolver type: %s", resolverType)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEnv resolves an environment variable.
|
||||
func (r *Resolver) resolveEnv(envVar string) interface{} {
|
||||
value := os.Getenv(envVar)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// resolveGSM resolves a Google Secret Manager secret.
|
||||
func (r *Resolver) resolveGSM(secretName string) interface{} {
|
||||
if r.gcpProject == "" {
|
||||
log.Printf("GCP project not configured for GSM resolution")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize GSM client if needed
|
||||
if r.gsmClient == nil {
|
||||
ctx := context.Background()
|
||||
client, err := secretmanager.NewClient(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create GSM client: %v", err)
|
||||
return nil
|
||||
}
|
||||
r.gsmClient = client
|
||||
}
|
||||
|
||||
// Build the resource name
|
||||
name := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", r.gcpProject, secretName)
|
||||
|
||||
// Access the secret
|
||||
ctx := context.Background()
|
||||
req := &secretmanagerpb.AccessSecretVersionRequest{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
result, err := r.gsmClient.AccessSecretVersion(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("Failed to access GSM secret %s: %v", secretName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return string(result.Payload.Data)
|
||||
}
|
||||
|
||||
// resolveASM resolves an AWS Secrets Manager secret.
|
||||
func (r *Resolver) resolveASM(secretName string) interface{} {
|
||||
// Initialize AWS session if needed
|
||||
if r.awsSession == nil {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(r.awsRegion),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create AWS session: %v", err)
|
||||
return nil
|
||||
}
|
||||
r.awsSession = sess
|
||||
}
|
||||
|
||||
// Initialize ASM client if needed
|
||||
if r.asmClient == nil {
|
||||
r.asmClient = secretsmanager.New(r.awsSession)
|
||||
}
|
||||
|
||||
// Get the secret value
|
||||
input := &secretsmanager.GetSecretValueInput{
|
||||
SecretId: aws.String(secretName),
|
||||
}
|
||||
|
||||
result, err := r.asmClient.GetSecretValue(input)
|
||||
if err != nil {
|
||||
log.Printf("Failed to access ASM secret %s: %v", secretName, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return the secret string
|
||||
if result.SecretString != nil {
|
||||
return *result.SecretString
|
||||
}
|
||||
|
||||
// If it's binary data, we can't handle it as a string config value
|
||||
log.Printf("ASM secret %s contains binary data, which is not supported", secretName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFile resolves a file's contents.
|
||||
func (r *Resolver) resolveFile(filePath string) interface{} {
|
||||
// Expand user home directory if present
|
||||
if strings.HasPrefix(filePath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get user home directory: %v", err)
|
||||
return nil
|
||||
}
|
||||
filePath = filepath.Join(home, filePath[2:])
|
||||
}
|
||||
|
||||
data, err := afero.ReadFile(r.fs, filePath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read file %s: %v", filePath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip whitespace/newlines from file contents
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// Close closes any open clients.
|
||||
func (r *Resolver) Close() error {
|
||||
if r.gsmClient != nil {
|
||||
return r.gsmClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/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
@@ -1,15 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/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
@@ -1,12 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/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
@@ -1,12 +0,0 @@
|
||||
#!/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,108 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Source the templates */
|
||||
@source "../../templates/**/*.html";
|
||||
|
||||
/* Material Design inspired theme customization */
|
||||
@theme {
|
||||
/* Primary colors */
|
||||
--color-primary-50: #e3f2fd;
|
||||
--color-primary-100: #bbdefb;
|
||||
--color-primary-200: #90caf9;
|
||||
--color-primary-300: #64b5f6;
|
||||
--color-primary-400: #42a5f5;
|
||||
--color-primary-500: #2196f3;
|
||||
--color-primary-600: #1e88e5;
|
||||
--color-primary-700: #1976d2;
|
||||
--color-primary-800: #1565c0;
|
||||
--color-primary-900: #0d47a1;
|
||||
|
||||
/* Error colors */
|
||||
--color-error-50: #ffebee;
|
||||
--color-error-500: #f44336;
|
||||
--color-error-700: #d32f2f;
|
||||
|
||||
/* Success colors */
|
||||
--color-success-50: #e8f5e9;
|
||||
--color-success-500: #4caf50;
|
||||
--color-success-700: #388e3c;
|
||||
|
||||
/* Warning colors */
|
||||
--color-warning-50: #fff3e0;
|
||||
--color-warning-500: #ff9800;
|
||||
--color-warning-700: #f57c00;
|
||||
|
||||
/* Material Design elevation shadows */
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||
--shadow-elevation-2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
--shadow-elevation-3: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
/* Material Design component styles */
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:ring-primary-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 active:bg-gray-100 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-error-500 text-white hover:bg-error-700 active:bg-red-800 focus:ring-red-500 shadow-elevation-1 hover:shadow-elevation-2;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden;
|
||||
}
|
||||
|
||||
.card-elevated {
|
||||
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden hover:shadow-elevation-2 transition-shadow;
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
.input {
|
||||
@apply w-full px-4 py-3 border border-gray-300 rounded-md text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-success {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-700;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error-50 text-error-700;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-50 text-primary-700;
|
||||
}
|
||||
|
||||
/* App bar / Navigation */
|
||||
.app-bar {
|
||||
@apply bg-white shadow-elevation-1 px-6 py-4;
|
||||
}
|
||||
|
||||
/* Alert / Message boxes */
|
||||
.alert-error {
|
||||
@apply p-4 rounded-md mb-4 bg-error-50 text-error-700 border border-error-500/20;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
@apply p-4 rounded-md mb-4 bg-success-50 text-success-700 border border-success-500/20;
|
||||
}
|
||||
}
|
||||
+59
-1
@@ -1 +1,59 @@
|
||||
/* Webhooker custom styles — see input.css for Tailwind theme */
|
||||
/* Webhooker main stylesheet */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom styles for Webhooker */
|
||||
|
||||
/* Navbar customization */
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.card {
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
|
||||
/* Background opacity utilities */
|
||||
.bg-opacity-10 {
|
||||
background-color: rgba(var(--bs-success-rgb), 0.1);
|
||||
}
|
||||
|
||||
.bg-primary.bg-opacity-10 {
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* User dropdown styling */
|
||||
.navbar .dropdown-toggle::after {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar .dropdown-menu {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Footer styling */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
padding: 2rem 0;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.display-4 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user