Compare commits

..

7 Commits

Author SHA1 Message Date
clawbot
b437955378 chore: add MIT LICENSE
All checks were successful
check / check (push) Successful in 56s
Add MIT license file with copyright holder Jeffrey Paul <sneak@sneak.berlin>.
2026-03-01 15:56:00 -08:00
clawbot
5462db565a feat: add auth middleware for protected routes
Add RequireAuth middleware that checks for a valid session and
redirects unauthenticated users to /pages/login. Applied to all
/sources and /source/{sourceID} routes. The middleware uses the
existing session package for authentication checks.

closes #9
2026-03-01 15:55:51 -08:00
clawbot
6ff2bc7647 fix: remove redundant godotenv import
The godotenv/autoload import was duplicated in both config.go and
server.go. Keep it only in config.go where configuration is loaded.

closes #11
2026-03-01 15:53:43 -08:00
clawbot
291e60adb2 refactor: simplify config to prefer env vars
Configuration now prefers environment variables over config.yaml values.
Each config field has a corresponding env var (DBURL, PORT, DEBUG, etc.)
that takes precedence when set. The config.yaml fallback is preserved
for development convenience.

closes #10
2026-03-01 15:52:05 -08:00
clawbot
fd3ca22012 refactor: use slog.LevelVar for dynamic log levels
Replace the pattern of recreating the logger handler when enabling debug
logging. Now use slog.LevelVar which allows changing the log level
dynamically without recreating the handler or logger instance.

closes #8
2026-03-01 15:49:21 -08:00
clawbot
68c2a4df36 refactor: use go:embed for templates
Templates are now embedded using //go:embed and parsed once at startup
with template.Must(template.ParseFS(...)). This avoids re-parsing
template files from disk on every request and removes the dependency
on template files being present at runtime.

closes #7
2026-03-01 15:47:22 -08:00
clawbot
6031167c78 refactor: rename Processor to Webhook and Webhook to Entrypoint
The top-level entity that groups entrypoints and targets is now called
Webhook (was Processor). The inbound URL endpoint entity is now called
Entrypoint (was Webhook). This rename affects database models, handler
comments, routes, and README documentation.

closes #12
2026-03-01 15:44:22 -08:00
73 changed files with 3093 additions and 8524 deletions

4
.gitignore vendored
View File

@@ -29,9 +29,9 @@ Thumbs.db
# Environment and config files # Environment and config files
.env .env
.env.local .env.local
config.yaml
# Data directory (SQLite databases) # Database files
data/
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3

View File

@@ -34,6 +34,7 @@ RUN set -eux; \
# Copy go module files and download dependencies # Copy go module files and download dependencies
COPY go.mod go.sum ./ COPY go.mod go.sum ./
COPY pkg/config/go.mod pkg/config/go.sum ./pkg/config/
RUN go mod download RUN go mod download
# Copy source code # Copy source code
@@ -56,11 +57,7 @@ WORKDIR /app
# Copy binary from builder # Copy binary from builder
COPY --from=builder /build/bin/webhooker . COPY --from=builder /build/bin/webhooker .
# Create data directory for all SQLite databases (main app DB + RUN chown -R webhooker:webhooker /app
# per-webhook event DBs). DATA_DIR defaults to /data in production.
RUN mkdir -p /data
RUN chown -R webhooker:webhooker /app /data
USER webhooker USER webhooker

View File

@@ -1,4 +1,4 @@
.PHONY: test lint fmt fmt-check check build run dev deps docker clean hooks css .PHONY: test lint fmt fmt-check check build run dev deps docker clean hooks
# Default target # Default target
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
@@ -41,6 +41,3 @@ hooks:
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit @printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit @chmod +x .git/hooks/pre-commit
@echo "pre-commit hook installed" @echo "pre-commit hook installed"
css:
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify

1002
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
package main package main
import ( import (
"runtime"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
@@ -23,6 +24,7 @@ var (
func main() { func main() {
globals.Appname = appname globals.Appname = appname
globals.Version = version globals.Version = version
globals.Buildarch = runtime.GOARCH
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -30,17 +32,12 @@ func main() {
logger.New, logger.New,
config.New, config.New,
database.New, database.New,
database.NewWebhookDBManager,
healthcheck.New, healthcheck.New,
session.New, session.New,
handlers.New, handlers.New,
middleware.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, server.New,
), ),
fx.Invoke(func(*server.Server, *delivery.Engine) {}), fx.Invoke(func(*server.Server) {}),
).Run() ).Run()
} }

View File

@@ -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: ""

28
go.mod
View File

@@ -1,6 +1,6 @@
module sneak.berlin/go/webhooker module sneak.berlin/go/webhooker
go 1.24.0 go 1.23.0
toolchain go1.24.1 toolchain go1.24.1
@@ -14,23 +14,35 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.18.0 github.com/prometheus/client_golang v1.18.0
github.com/slok/go-http-metrics v0.11.0 github.com/slok/go-http-metrics v0.11.0
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
go.uber.org/fx v1.20.1 go.uber.org/fx v1.20.1
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
golang.org/x/time v0.14.0
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5 gorm.io/gorm v1.25.5
modernc.org/sqlite v1.28.0 modernc.org/sqlite v1.28.0
sneak.berlin/go/webhooker/pkg/config v0.0.0-00010101000000-000000000000
) )
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/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // 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/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -41,15 +53,25 @@ require (
github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.17.0 // indirect go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.23.0 // indirect go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // 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/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.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 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 google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect lukechampine.com/uint128 v1.2.0 // indirect
@@ -62,3 +84,5 @@ require (
modernc.org/strutil v1.1.3 // indirect modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect modernc.org/token v1.0.1 // indirect
) )
replace sneak.berlin/go/webhooker/pkg/config => ./pkg/config

140
go.sum
View File

@@ -1,11 +1,28 @@
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 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= 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 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,6 +30,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI= 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/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 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
@@ -21,7 +42,30 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/golang/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.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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -29,8 +73,15 @@ 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/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 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
@@ -39,6 +90,10 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@@ -62,6 +117,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/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 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= 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 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
@@ -74,12 +130,21 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0= 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/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
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.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 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= 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.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 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
@@ -92,34 +157,105 @@ 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/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 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= 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 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 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/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 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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 h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 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= 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-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 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= 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 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 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 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
pkgconfig "sneak.berlin/go/webhooker/pkg/config"
// Populates the environment from a ./.env file automatically for // Populates the environment from a ./.env file automatically for
// development configuration. Kept in one place only (here). // development configuration. Kept in one place only (here).
@@ -21,6 +22,9 @@ const (
EnvironmentDev = "dev" EnvironmentDev = "dev"
// EnvironmentProd represents production environment // EnvironmentProd represents production environment
EnvironmentProd = "prod" EnvironmentProd = "prod"
// DevSessionKey is an insecure default session key for development
// This is "webhooker-dev-session-key-insecure!" base64 encoded
DevSessionKey = "d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE="
) )
// nolint:revive // ConfigParams is a standard fx naming convention // nolint:revive // ConfigParams is a standard fx naming convention
@@ -31,16 +35,20 @@ type ConfigParams struct {
} }
type Config struct { type Config struct {
DataDir string DBURL string
Debug bool Debug bool
MaintenanceMode bool MaintenanceMode bool
Environment string DevelopmentMode bool
MetricsPassword string DevAdminUsername string
MetricsUsername string DevAdminPassword string
Port int Environment string
SentryDSN string MetricsPassword string
params *ConfigParams MetricsUsername string
log *slog.Logger 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
@@ -53,30 +61,38 @@ func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd return c.Environment == EnvironmentProd
} }
// envString returns the value of the named environment variable, or // envString returns the env var value if set, otherwise falls back to pkgconfig.
// an empty string if not set. func envString(envKey, configKey string) string {
func envString(key string) string { if v := os.Getenv(envKey); v != "" {
return os.Getenv(key) return v
}
return pkgconfig.GetString(configKey)
} }
// envBool returns the value of the named environment variable parsed as a // envSecretString returns the env var value if set, otherwise falls back to pkgconfig secrets.
// boolean. Returns defaultValue if not set. func envSecretString(envKey, configKey string) string {
func envBool(key string, defaultValue bool) bool { if v := os.Getenv(envKey); v != "" {
if v := os.Getenv(key); v != "" { return v
}
return pkgconfig.GetSecretString(configKey)
}
// envBool returns the env var value parsed as bool, otherwise falls back to pkgconfig.
func envBool(envKey, configKey string) bool {
if v := os.Getenv(envKey); v != "" {
return strings.EqualFold(v, "true") || v == "1" return strings.EqualFold(v, "true") || v == "1"
} }
return defaultValue return pkgconfig.GetBool(configKey)
} }
// envInt returns the value of the named environment variable parsed as an // envInt returns the env var value parsed as int, otherwise falls back to pkgconfig.
// integer. Returns defaultValue if not set or unparseable. func envInt(envKey, configKey string, defaultValue ...int) int {
func envInt(key string, defaultValue int) int { if v := os.Getenv(envKey); v != "" {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil { if i, err := strconv.Atoi(v); err == nil {
return i return i
} }
} }
return defaultValue return pkgconfig.GetInt(configKey, defaultValue...)
} }
// nolint:revive // lc parameter is required by fx even if unused // nolint:revive // lc parameter is required by fx even if unused
@@ -95,28 +111,40 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
EnvironmentDev, EnvironmentProd, environment) EnvironmentDev, EnvironmentProd, environment)
} }
// Load configuration values from environment variables // Set the environment in the config package (for fallback resolution)
pkgconfig.SetEnvironment(environment)
// Load configuration values — env vars take precedence over config.yaml
s := &Config{ s := &Config{
DataDir: envString("DATA_DIR"), DBURL: envString("DBURL", "dburl"),
Debug: envBool("DEBUG", false), Debug: envBool("DEBUG", "debug"),
MaintenanceMode: envBool("MAINTENANCE_MODE", false), MaintenanceMode: envBool("MAINTENANCE_MODE", "maintenanceMode"),
Environment: environment, DevelopmentMode: envBool("DEVELOPMENT_MODE", "developmentMode"),
MetricsUsername: envString("METRICS_USERNAME"), DevAdminUsername: envString("DEV_ADMIN_USERNAME", "devAdminUsername"),
MetricsPassword: envString("METRICS_PASSWORD"), DevAdminPassword: envString("DEV_ADMIN_PASSWORD", "devAdminPassword"),
Port: envInt("PORT", 8080), Environment: environment,
SentryDSN: envString("SENTRY_DSN"), MetricsUsername: envString("METRICS_USERNAME", "metricsUsername"),
log: log, MetricsPassword: envString("METRICS_PASSWORD", "metricsPassword"),
params: &params, Port: envInt("PORT", "port", 8080),
SentryDSN: envSecretString("SENTRY_DSN", "sentryDSN"),
SessionKey: envSecretString("SESSION_KEY", "sessionKey"),
log: log,
params: &params,
} }
// Set default DataDir based on environment. All SQLite databases // Validate database URL
// (main application DB and per-webhook event DBs) live here. if s.DBURL == "" {
if s.DataDir == "" { return nil, fmt.Errorf("database URL (DBURL) is required")
if s.IsProd() { }
s.DataDir = "/data"
} else { // In production, require session key
s.DataDir = "./data" 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 { if s.Debug {
@@ -129,7 +157,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"port", s.Port, "port", s.Port,
"debug", s.Debug, "debug", s.Debug,
"maintenanceMode", s.MaintenanceMode, "maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir, "developmentMode", s.DevelopmentMode,
"hasSessionKey", s.SessionKey != "",
"hasSentryDSN", s.SentryDSN != "", "hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "", "hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "",
) )

View File

@@ -4,14 +4,66 @@ import (
"os" "os"
"testing" "testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
pkgconfig "sneak.berlin/go/webhooker/pkg/config"
) )
// 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) { func TestEnvironmentConfig(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -24,7 +76,6 @@ func TestEnvironmentConfig(t *testing.T) {
{ {
name: "default is dev", name: "default is dev",
envValue: "", envValue: "",
envVars: map[string]string{},
expectError: false, expectError: false,
isDev: true, isDev: true,
isProd: false, isProd: false,
@@ -32,15 +83,17 @@ func TestEnvironmentConfig(t *testing.T) {
{ {
name: "explicit dev", name: "explicit dev",
envValue: "dev", envValue: "dev",
envVars: map[string]string{},
expectError: false, expectError: false,
isDev: true, isDev: true,
isProd: false, isProd: false,
}, },
{ {
name: "explicit prod", name: "explicit prod with session key",
envValue: "prod", envValue: "prod",
envVars: map[string]string{}, envVars: map[string]string{
"SESSION_KEY": "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
"DBURL": "postgres://prod:prod@localhost:5432/prod?sslmode=require",
},
expectError: false, expectError: false,
isDev: false, isDev: false,
isProd: true, isProd: true,
@@ -48,19 +101,21 @@ func TestEnvironmentConfig(t *testing.T) {
{ {
name: "invalid environment", name: "invalid environment",
envValue: "staging", envValue: "staging",
envVars: map[string]string{},
expectError: true, expectError: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Create in-memory filesystem with test config
fs := afero.NewMemMapFs()
require.NoError(t, createTestConfig(fs))
pkgconfig.SetFs(fs)
// Set environment variable if specified // Set environment variable if specified
if tt.envValue != "" { if tt.envValue != "" {
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue) os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue)
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT") defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
} else {
os.Unsetenv("WEBHOOKER_ENVIRONMENT")
} }
// Set additional environment variables // Set additional environment variables
@@ -104,3 +159,142 @@ func TestEnvironmentConfig(t *testing.T) {
}) })
} }
} }
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)
}
}
})
}
}

View File

@@ -2,14 +2,8 @@ package database
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/base64"
"errors"
"fmt"
"log/slog" "log/slog"
"os"
"path/filepath"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -51,17 +45,13 @@ func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
} }
func (d *Database) connect() error { func (d *Database) connect() error {
// Ensure the data directory exists before opening the database. dbURL := d.params.Config.DBURL
dataDir := d.params.Config.DataDir if dbURL == "" {
if err := os.MkdirAll(dataDir, 0750); err != nil { // Default to SQLite for development
return fmt.Errorf("creating data directory %s: %w", dataDir, err) dbURL = "file:webhooker.db?cache=shared&mode=rwc"
} }
// Construct the main application database path inside DATA_DIR. // First, open the database with the pure Go driver
dbPath := filepath.Join(dataDir, "webhooker.db")
dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", dbPath)
// Open the database with the pure Go SQLite driver
sqlDB, err := sql.Open("sqlite", dbURL) sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil { if err != nil {
d.log.Error("failed to open database", "error", err) d.log.Error("failed to open database", "error", err)
@@ -78,7 +68,7 @@ func (d *Database) connect() error {
} }
d.db = db d.db = db
d.log.Info("connected to database", "path", dbPath) d.log.Info("connected to database", "database", dbURL)
// Run migrations // Run migrations
return d.migrate() return d.migrate()
@@ -128,11 +118,11 @@ func (d *Database) migrate() error {
return err return err
} }
// Log the password - this will only happen once on first startup
d.log.Info("admin user created", d.log.Info("admin user created",
"username", "admin", "username", "admin",
"password", password, "password", password,
"message", "SAVE THIS PASSWORD - it will not be shown again!", "message", "SAVE THIS PASSWORD - it will not be shown again!")
)
} }
return nil return nil
@@ -152,35 +142,3 @@ func (d *Database) close() error {
func (d *Database) DB() *gorm.DB { func (d *Database) DB() *gorm.DB {
return d.db return d.db
} }
// GetOrCreateSessionKey retrieves the session encryption key from the
// settings table. If no key exists, a cryptographically secure random
// 32-byte key is generated, base64-encoded, and stored for future use.
func (d *Database) GetOrCreateSessionKey() (string, error) {
var setting Setting
result := d.db.Where(&Setting{Key: "session_key"}).First(&setting)
if result.Error == nil {
return setting.Value, nil
}
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return "", fmt.Errorf("failed to query session key: %w", result.Error)
}
// Generate a new cryptographically secure 32-byte key
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return "", fmt.Errorf("failed to generate session key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(keyBytes)
setting = Setting{
Key: "session_key",
Value: encoded,
}
if err := d.db.Create(&setting).Error; err != nil {
return "", fmt.Errorf("failed to store session key: %w", err)
}
d.log.Info("generated new session key and stored in database")
return encoded, nil
}

View File

@@ -4,19 +4,45 @@ import (
"context" "context"
"testing" "testing"
"github.com/spf13/afero"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
pkgconfig "sneak.berlin/go/webhooker/pkg/config"
) )
func TestDatabaseConnection(t *testing.T) { func TestDatabaseConnection(t *testing.T) {
// Set up in-memory config so the test does not depend on config.yaml on disk
fs := afero.NewMemMapFs()
testConfigYAML := `
environments:
dev:
config:
port: 8080
debug: false
maintenanceMode: false
developmentMode: true
environment: dev
dburl: "file::memory:?cache=shared"
secrets:
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=
sentryDSN: ""
configDefaults:
port: 8080
`
if err := afero.WriteFile(fs, "config.yaml", []byte(testConfigYAML), 0644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
pkgconfig.SetFs(fs)
// Set up test dependencies // Set up test dependencies
lc := fxtest.NewLifecycle(t) lc := fxtest.NewLifecycle(t)
// Create globals // Create globals
globals.Appname = "webhooker-test" globals.Appname = "webhooker-test"
globals.Version = "test" globals.Version = "test"
globals.Buildarch = "test"
g, err := globals.New(lc) g, err := globals.New(lc)
if err != nil { if err != nil {
@@ -29,12 +55,18 @@ func TestDatabaseConnection(t *testing.T) {
t.Fatalf("Failed to create logger: %v", err) t.Fatalf("Failed to create logger: %v", err)
} }
// Create config with DataDir pointing to a temp directory // Create config
c := &config.Config{ c, err := config.New(lc, config.ConfigParams{
DataDir: t.TempDir(), Globals: g,
Environment: "dev", Logger: l,
})
if err != nil {
t.Fatalf("Failed to create config: %v", err)
} }
// Override DBURL to use a temp file-based SQLite (in-memory doesn't persist across connections)
c.DBURL = "file:" + t.TempDir() + "/test.db?cache=shared&mode=rwc"
// Create database // Create database
db, err := New(lc, DatabaseParams{ db, err := New(lc, DatabaseParams{
Config: c, Config: c,

View File

@@ -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"`
}

View File

@@ -5,6 +5,7 @@ type TargetType string
const ( const (
TargetTypeHTTP TargetType = "http" TargetTypeHTTP TargetType = "http"
TargetTypeRetry TargetType = "retry"
TargetTypeDatabase TargetType = "database" TargetTypeDatabase TargetType = "database"
TargetTypeLog TargetType = "log" TargetTypeLog TargetType = "log"
) )
@@ -21,7 +22,7 @@ type Target struct {
// Configuration fields (JSON stored based on type) // Configuration fields (JSON stored based on type)
Config string `gorm:"type:text" json:"config"` // JSON configuration Config string `gorm:"type:text" json:"config"` // JSON configuration
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff) // For retry targets
MaxRetries int `json:"max_retries,omitempty"` MaxRetries int `json:"max_retries,omitempty"`
MaxQueueSize int `json:"max_queue_size,omitempty"` MaxQueueSize int `json:"max_queue_size,omitempty"`

View File

@@ -1,16 +1,15 @@
package database package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for all models
// 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.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
&Setting{},
&User{}, &User{},
&APIKey{}, &APIKey{},
&Webhook{}, &Webhook{},
&Entrypoint{}, &Entrypoint{},
&Target{}, &Target{},
&Event{},
&Delivery{},
&DeliveryResult{},
) )
} }

View File

@@ -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})),
}
}

View File

@@ -1,183 +0,0 @@
package database
import (
"context"
"database/sql"
"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"
)
// nolint:revive // WebhookDBManagerParams is a standard fx naming convention
type WebhookDBManagerParams struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
// WebhookDBManager manages per-webhook SQLite database files for event storage.
// Each webhook gets its own dedicated database containing Events, Deliveries,
// and DeliveryResults. Database connections are opened lazily and cached.
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
if err := os.MkdirAll(m.dataDir, 0750); err != nil {
return nil, fmt.Errorf("creating data directory %s: %w", m.dataDir, err)
}
lc.Append(fx.Hook{
OnStop: func(_ context.Context) error { //nolint:revive // ctx unused but required by fx
return m.CloseAll()
},
})
m.log.Info("webhook database manager initialized", "data_dir", m.dataDir)
return m, nil
}
// dbPath returns the filesystem path for a webhook's database file.
func (m *WebhookDBManager) dbPath(webhookID string) string {
return filepath.Join(m.dataDir, fmt.Sprintf("events-%s.db", webhookID))
}
// openDB opens (or creates) a per-webhook SQLite database and runs migrations.
func (m *WebhookDBManager) openDB(webhookID string) (*gorm.DB, error) {
path := m.dbPath(webhookID)
dbURL := fmt.Sprintf("file:%s?cache=shared&mode=rwc", path)
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
return nil, fmt.Errorf("opening webhook database %s: %w", webhookID, err)
}
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
sqlDB.Close()
return nil, fmt.Errorf("connecting to webhook database %s: %w", webhookID, err)
}
// Run migrations for event-tier models only
if err := db.AutoMigrate(&Event{}, &Delivery{}, &DeliveryResult{}); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("migrating webhook database %s: %w", webhookID, err)
}
m.log.Info("opened per-webhook database", "webhook_id", webhookID, "path", path)
return db, nil
}
// GetDB returns the database connection for a webhook, creating the database
// file lazily if it doesn't exist. This handles both new webhooks and existing
// webhooks that were created before per-webhook databases were introduced.
func (m *WebhookDBManager) GetDB(webhookID string) (*gorm.DB, error) {
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
cachedDB, castOK := val.(*gorm.DB)
if !castOK {
return nil, fmt.Errorf("invalid cached database type for webhook %s", webhookID)
}
return 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 and use theirs
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
if loaded {
// Another goroutine created it first; close our duplicate
if sqlDB, closeErr := db.DB(); closeErr == nil {
sqlDB.Close()
}
existingDB, castOK := actual.(*gorm.DB)
if !castOK {
return nil, fmt.Errorf("invalid cached database type for webhook %s", webhookID)
}
return existingDB, nil
}
return db, nil
}
// CreateDB explicitly creates a new per-webhook database file and runs migrations.
// This is called when a new webhook is created.
func (m *WebhookDBManager) CreateDB(webhookID string) error {
_, 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.
// This performs a hard delete — the file is permanently removed.
func (m *WebhookDBManager) DeleteDB(webhookID string) error {
// Close and remove from cache
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
if gormDB, castOK := val.(*gorm.DB); castOK {
if sqlDB, err := gormDB.DB(); err == nil {
sqlDB.Close()
}
}
}
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
if err := os.Remove(path + suffix); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("deleting webhook database file %s%s: %w", path, suffix, err)
}
}
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 interface{}) bool {
if gormDB, castOK := value.(*gorm.DB); castOK {
if sqlDB, err := gormDB.DB(); err == nil {
if closeErr := sqlDB.Close(); 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
}

View File

@@ -1,272 +0,0 @@
package database
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"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
func setupTestWebhookDBManager(t *testing.T) (*WebhookDBManager, *fxtest.Lifecycle) {
t.Helper()
lc := fxtest.NewLifecycle(t)
globals.Appname = "webhooker-test"
globals.Version = "test"
g, err := globals.New(lc)
require.NoError(t, err)
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 := NewWebhookDBManager(lc, WebhookDBManagerParams{
Config: cfg,
Logger: l,
})
require.NoError(t, err)
return mgr, lc
}
func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
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 := &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 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) {
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
// Create the DB and write some data
require.NoError(t, mgr.CreateDB(webhookID))
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
event := &Event{
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) {
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) {
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)
// Create an event
event := &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)
// Create a delivery
delivery := &Delivery{
EventID: event.ID,
TargetID: targetID,
Status: DeliveryStatusPending,
}
require.NoError(t, db.Create(delivery).Error)
// Query pending deliveries
var pending []Delivery
require.NoError(t, db.Where("status = ?", DeliveryStatusPending).
Preload("Event").
Find(&pending).Error)
require.Len(t, pending, 1)
assert.Equal(t, event.ID, pending[0].EventID)
assert.Equal(t, "POST", pending[0].Event.Method)
// Create a delivery result
result := &DeliveryResult{
DeliveryID: delivery.ID,
AttemptNum: 1,
Success: true,
StatusCode: 200,
Duration: 42,
}
require.NoError(t, db.Create(result).Error)
// Update delivery status
require.NoError(t, db.Model(delivery).Update("status", DeliveryStatusDelivered).Error)
// Verify no more pending deliveries
var stillPending []Delivery
require.NoError(t, db.Where("status = ?", DeliveryStatusPending).Find(&stillPending).Error)
assert.Empty(t, stillPending)
}
func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
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 := &Event{
WebhookID: webhook1,
EntrypointID: uuid.New().String(),
Method: "POST",
Body: `{"webhook": 1}`,
ContentType: "application/json",
}
event2 := &Event{
WebhookID: webhook2,
EntrypointID: uuid.New().String(),
Method: "PUT",
Body: `{"webhook": 2}`,
ContentType: "application/json",
}
require.NoError(t, db1.Create(event1).Error)
require.NoError(t, db2.Create(event2).Error)
// Verify isolation: each DB only has its own events
var count1 int64
db1.Model(&Event{}).Count(&count1)
assert.Equal(t, int64(1), count1)
var count2 int64
db2.Model(&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 []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) {
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
// Create a few DBs
for i := 0; i < 3; i++ {
require.NoError(t, mgr.CreateDB(uuid.New().String()))
}
// CloseAll should close all connections without error
require.NoError(t, mgr.CloseAll())
// Stop lifecycle (CloseAll already called, but shouldn't panic)
require.NoError(t, lc.Stop(ctx))
}

View File

@@ -1,162 +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. Deliveries flow through.
CircuitClosed CircuitState = iota
// CircuitOpen means the circuit has tripped. Deliveries are skipped
// until the cooldown expires.
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 for a probe delivery.
defaultCooldown = 30 * time.Second
)
// CircuitBreaker implements the circuit breaker pattern for a single
// delivery target. It tracks consecutive failures and prevents
// hammering a down target by temporarily stopping delivery attempts.
//
// States:
// - Closed (normal): deliveries flow through; consecutive failures
// are counted.
// - Open (tripped): deliveries are skipped; a cooldown timer is
// running. After the cooldown expires the state moves to HalfOpen.
// - HalfOpen (probing): one probe delivery is allowed. If it
// succeeds the circuit closes; if it fails the circuit reopens.
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. It returns
// true if the delivery should be attempted, false if the circuit is
// open and the delivery should be skipped.
//
// When the circuit is open and the cooldown has elapsed, Allow
// transitions to half-open and permits exactly one probe delivery.
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case CircuitClosed:
return true
case CircuitOpen:
// Check if cooldown has elapsed
if time.Since(cb.lastFailure) >= cb.cooldown {
cb.state = CircuitHalfOpen
return true
}
return false
case CircuitHalfOpen:
// Only one probe at a time — reject additional attempts while
// a probe is in flight. The probe goroutine will call
// RecordSuccess or RecordFailure to resolve the state.
return false
default:
return true
}
}
// CooldownRemaining returns how much time is left before an open circuit
// transitions to half-open. Returns zero if the circuit is not open or
// the cooldown has already elapsed.
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 with zero failures.
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 CircuitHalfOpen:
// Probe failed — reopen immediately
cb.state = CircuitOpen
}
}
// State returns the current circuit state. Safe for concurrent use.
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"
}
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,936 +0,0 @@
package delivery
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
)
// testWebhookDB creates a real SQLite per-webhook database in a temp dir
// and runs the event-tier migrations (Event, Delivery, DeliveryResult).
func testWebhookDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "events-test.db")
dsn := fmt.Sprintf("file:%s?cache=shared&mode=rwc", dbPath)
sqlDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
t.Cleanup(func() { sqlDB.Close() })
db, err := gorm.Open(sqlite.Dialector{Conn: sqlDB}, &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Event{},
&database.Delivery{},
&database.DeliveryResult{},
))
return db
}
// testEngine builds an Engine with custom settings for testing. It does
// NOT call start() — callers control lifecycle for deterministic tests.
func testEngine(t *testing.T, workers int) *Engine {
t.Helper()
return &Engine{
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
client: &http.Client{Timeout: 5 * time.Second},
deliveryCh: make(chan DeliveryTask, deliveryChannelSize),
retryCh: make(chan DeliveryTask, retryChannelSize),
workers: workers,
}
}
// newHTTPTargetConfig returns a JSON config for an HTTP target
// pointing at the given URL.
func newHTTPTargetConfig(url string) string {
cfg := HTTPTargetConfig{URL: url}
data, err := json.Marshal(cfg)
if err != nil {
panic("failed to marshal HTTPTargetConfig: " + err.Error())
}
return string(data)
}
// seedEvent inserts an event into the per-webhook DB and returns it.
func seedEvent(t *testing.T, db *gorm.DB, body string) database.Event {
t.Helper()
event := database.Event{
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: "POST",
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: "application/json",
}
require.NoError(t, db.Create(&event).Error)
return event
}
// seedDelivery inserts a delivery for an event + target and returns it.
func seedDelivery(t *testing.T, db *gorm.DB, eventID, targetID string, status database.DeliveryStatus) database.Delivery {
t.Helper()
d := database.Delivery{
EventID: eventID,
TargetID: targetID,
Status: status,
}
require.NoError(t, db.Create(&d).Error)
return d
}
// --- Tests ---
func TestNotify_NonBlocking(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
// Fill the delivery channel to capacity
for i := 0; i < deliveryChannelSize; i++ {
e.deliveryCh <- DeliveryTask{DeliveryID: fmt.Sprintf("fill-%d", i)}
}
// Notify should NOT block even though channel is full
done := make(chan struct{})
go func() {
e.Notify([]DeliveryTask{
{DeliveryID: "overflow-1"},
{DeliveryID: "overflow-2"},
})
close(done)
}()
select {
case <-done:
// success: Notify returned without blocking
case <-time.After(2 * time.Second):
t.Fatal("Notify blocked when delivery channel was full")
}
}
func TestDeliverHTTP_Success(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
var received atomic.Bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received.Store(true)
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"ok":true}`)
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
event := seedEvent(t, db, `{"hello":"world"}`)
delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
task := &DeliveryTask{
DeliveryID: delivery.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
TargetID: targetID,
TargetName: "test-http",
TargetType: database.TargetTypeHTTP,
TargetConfig: newHTTPTargetConfig(ts.URL),
MaxRetries: 0,
AttemptNum: 1,
}
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-http",
Type: database.TargetTypeHTTP,
Config: newHTTPTargetConfig(ts.URL),
},
}
d.ID = delivery.ID
e.deliverHTTP(context.TODO(), db, d, task)
assert.True(t, received.Load(), "HTTP target should have received request")
// Check DB: delivery should be delivered
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status)
// Check that a result was recorded
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error)
assert.True(t, result.Success)
assert.Equal(t, http.StatusOK, result.StatusCode)
}
func TestDeliverHTTP_Failure(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "internal error")
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
event := seedEvent(t, db, `{"test":true}`)
delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
task := &DeliveryTask{
DeliveryID: delivery.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
TargetID: targetID,
TargetName: "test-http-fail",
TargetType: database.TargetTypeHTTP,
TargetConfig: newHTTPTargetConfig(ts.URL),
MaxRetries: 0,
AttemptNum: 1,
}
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-http-fail",
Type: database.TargetTypeHTTP,
Config: newHTTPTargetConfig(ts.URL),
},
}
d.ID = delivery.ID
e.deliverHTTP(context.TODO(), db, d, task)
// HTTP (fire-and-forget) marks as failed on non-2xx
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusFailed, updated.Status)
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error)
assert.False(t, result.Success)
assert.Equal(t, http.StatusInternalServerError, result.StatusCode)
}
func TestDeliverDatabase_ImmediateSuccess(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{"db":"target"}`)
delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: delivery.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-db",
Type: database.TargetTypeDatabase,
},
}
d.ID = delivery.ID
e.deliverDatabase(db, d)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status,
"database target should immediately succeed")
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error)
assert.True(t, result.Success)
assert.Equal(t, 0, result.StatusCode, "database target should not have an HTTP status code")
}
func TestDeliverLog_ImmediateSuccess(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{"log":"target"}`)
delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: delivery.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-log",
Type: database.TargetTypeLog,
},
}
d.ID = delivery.ID
e.deliverLog(db, d)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status,
"log target should immediately succeed")
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error)
assert.True(t, result.Success)
}
func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
event := seedEvent(t, db, `{"retry":"ok"}`)
delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
task := &DeliveryTask{
DeliveryID: delivery.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
TargetID: targetID,
TargetName: "test-http-retry",
TargetType: database.TargetTypeHTTP,
TargetConfig: newHTTPTargetConfig(ts.URL),
MaxRetries: 5,
AttemptNum: 1,
}
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-http-retry",
Type: database.TargetTypeHTTP,
Config: newHTTPTargetConfig(ts.URL),
MaxRetries: 5,
},
}
d.ID = delivery.ID
d.Target.ID = targetID
e.deliverHTTP(context.TODO(), db, d, task)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status)
// Circuit breaker should have recorded success
cb := e.getCircuitBreaker(targetID)
assert.Equal(t, CircuitClosed, cb.State())
}
func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
event := seedEvent(t, db, `{"retry":"exhaust"}`)
delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusRetrying)
maxRetries := 3
task := &DeliveryTask{
DeliveryID: delivery.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
TargetID: targetID,
TargetName: "test-http-exhaust",
TargetType: database.TargetTypeHTTP,
TargetConfig: newHTTPTargetConfig(ts.URL),
MaxRetries: maxRetries,
AttemptNum: maxRetries, // final attempt
}
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusRetrying,
Event: event,
Target: database.Target{
Name: "test-http-exhaust",
Type: database.TargetTypeHTTP,
Config: newHTTPTargetConfig(ts.URL),
MaxRetries: maxRetries,
},
}
d.ID = delivery.ID
d.Target.ID = targetID
e.deliverHTTP(context.TODO(), db, d, task)
// After max retries exhausted, delivery should be failed
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusFailed, updated.Status,
"delivery should be failed after max retries exhausted")
}
func TestDeliverHTTP_SchedulesRetryOnFailure(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
event := seedEvent(t, db, `{"retry":"schedule"}`)
delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
task := &DeliveryTask{
DeliveryID: delivery.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
TargetID: targetID,
TargetName: "test-http-schedule",
TargetType: database.TargetTypeHTTP,
TargetConfig: newHTTPTargetConfig(ts.URL),
MaxRetries: 5,
AttemptNum: 1,
}
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-http-schedule",
Type: database.TargetTypeHTTP,
Config: newHTTPTargetConfig(ts.URL),
MaxRetries: 5,
},
}
d.ID = delivery.ID
d.Target.ID = targetID
e.deliverHTTP(context.TODO(), db, d, task)
// Delivery should be in retrying status (not failed — retries remain)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusRetrying, updated.Status,
"delivery should be retrying when retries remain")
// The timer should fire a task into the retry channel. Wait briefly
// for the timer (backoff for attempt 1 is 1s, but we're just verifying
// the status was set correctly and a result was recorded).
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", delivery.ID).First(&result).Error)
assert.False(t, result.Success)
assert.Equal(t, 1, result.AttemptNum)
}
func TestExponentialBackoff_Durations(t *testing.T) {
t.Parallel()
// The engine uses: backoff = 2^(attemptNum-1) seconds
// attempt 1 → shift=0 → 1s
// attempt 2 → shift=1 → 2s
// attempt 3 → shift=2 → 4s
// attempt 4 → shift=3 → 8s
// attempt 5 → shift=4 → 16s
expected := []time.Duration{
1 * time.Second,
2 * time.Second,
4 * time.Second,
8 * time.Second,
16 * time.Second,
}
for attemptNum := 1; attemptNum <= 5; attemptNum++ {
shift := attemptNum - 1
if shift > 30 {
shift = 30
}
backoff := time.Duration(1<<uint(shift)) * time.Second //nolint:gosec // bounded above
assert.Equal(t, expected[attemptNum-1], backoff,
"backoff for attempt %d should be %v", attemptNum, expected[attemptNum-1])
}
}
func TestExponentialBackoff_CappedAt30(t *testing.T) {
t.Parallel()
// Verify shift is capped at 30 to avoid overflow
attemptNum := 50
shift := attemptNum - 1
if shift > 30 {
shift = 30
}
backoff := time.Duration(1<<uint(shift)) * time.Second //nolint:gosec // bounded above
assert.Equal(t, time.Duration(1<<30)*time.Second, backoff,
"backoff shift should be capped at 30")
}
func TestBodyPointer_SmallBodyInline(t *testing.T) {
t.Parallel()
// Body under MaxInlineBodySize should be included inline
smallBody := `{"small": true}`
assert.Less(t, len(smallBody), MaxInlineBodySize)
var bodyPtr *string
if len(smallBody) < MaxInlineBodySize {
bodyPtr = &smallBody
}
require.NotNil(t, bodyPtr, "small body should be inline (non-nil)")
assert.Equal(t, smallBody, *bodyPtr)
}
func TestBodyPointer_LargeBodyNil(t *testing.T) {
t.Parallel()
// Body at or above MaxInlineBodySize should be nil
largeBody := strings.Repeat("x", MaxInlineBodySize)
assert.GreaterOrEqual(t, len(largeBody), MaxInlineBodySize)
var bodyPtr *string
if len(largeBody) < MaxInlineBodySize {
bodyPtr = &largeBody
}
assert.Nil(t, bodyPtr, "large body (≥16KB) should be nil")
}
func TestBodyPointer_ExactBoundary(t *testing.T) {
t.Parallel()
// Body of exactly MaxInlineBodySize should be nil (the check is <, not <=)
exactBody := strings.Repeat("y", MaxInlineBodySize)
assert.Equal(t, MaxInlineBodySize, len(exactBody))
var bodyPtr *string
if len(exactBody) < MaxInlineBodySize {
bodyPtr = &exactBody
}
assert.Nil(t, bodyPtr, "body at exactly MaxInlineBodySize should be nil")
}
func TestWorkerPool_BoundedConcurrency(t *testing.T) {
if testing.Short() {
t.Skip("skipping concurrency test in short mode")
}
t.Parallel()
const numWorkers = 3
db := testWebhookDB(t)
// Track concurrent tasks
var (
mu sync.Mutex
concurrent int
maxSeen int
)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
concurrent++
if concurrent > maxSeen {
maxSeen = concurrent
}
mu.Unlock()
time.Sleep(100 * time.Millisecond) // simulate slow target
mu.Lock()
concurrent--
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
e := testEngine(t, numWorkers)
// We need a minimal dbManager-like setup. Since processNewTask
// needs dbManager, we'll drive workers by sending tasks through
// the delivery channel and manually calling deliverHTTP instead.
// Instead, let's directly test the worker pool by creating tasks
// and processing them through the channel.
// Create tasks for more work than workers
const numTasks = 10
tasks := make([]database.Delivery, numTasks)
targetCfg := newHTTPTargetConfig(ts.URL)
for i := 0; i < numTasks; i++ {
event := seedEvent(t, db, fmt.Sprintf(`{"task":%d}`, i))
delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
tasks[i] = database.Delivery{
EventID: event.ID,
TargetID: delivery.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: fmt.Sprintf("task-%d", i),
Type: database.TargetTypeHTTP,
Config: targetCfg,
},
}
tasks[i].ID = delivery.ID
}
// Build DeliveryTask structs for each delivery (needed by deliverHTTP)
deliveryTasks := make([]DeliveryTask, numTasks)
for i := 0; i < numTasks; i++ {
deliveryTasks[i] = DeliveryTask{
DeliveryID: tasks[i].ID,
EventID: tasks[i].EventID,
TargetID: tasks[i].TargetID,
TargetName: tasks[i].Target.Name,
TargetType: tasks[i].Target.Type,
TargetConfig: tasks[i].Target.Config,
MaxRetries: 0,
AttemptNum: 1,
}
}
// Process all tasks through a bounded pool of goroutines to simulate
// the engine's worker pool behavior
var wg sync.WaitGroup
taskCh := make(chan int, numTasks)
for i := 0; i < numTasks; i++ {
taskCh <- i
}
close(taskCh)
// Start exactly numWorkers goroutines
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range taskCh {
e.deliverHTTP(context.TODO(), db, &tasks[idx], &deliveryTasks[idx])
}
}()
}
wg.Wait()
mu.Lock()
observedMax := maxSeen
mu.Unlock()
assert.LessOrEqual(t, observedMax, numWorkers,
"should never exceed %d concurrent deliveries, saw %d", numWorkers, observedMax)
// All deliveries should be completed
for i := 0; i < numTasks; i++ {
var d database.Delivery
require.NoError(t, db.First(&d, "id = ?", tasks[i].ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, d.Status,
"task %d should be delivered", i)
}
}
func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
targetID := uuid.New().String()
// Pre-trip the circuit breaker for this target
cb := e.getCircuitBreaker(targetID)
for i := 0; i < defaultFailureThreshold; i++ {
cb.RecordFailure()
}
require.Equal(t, CircuitOpen, cb.State())
event := seedEvent(t, db, `{"cb":"blocked"}`)
delivery := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
task := &DeliveryTask{
DeliveryID: delivery.ID,
EventID: event.ID,
WebhookID: event.WebhookID,
TargetID: targetID,
TargetName: "test-cb-block",
TargetType: database.TargetTypeHTTP,
TargetConfig: newHTTPTargetConfig("http://will-not-be-called.invalid"),
MaxRetries: 5,
AttemptNum: 1,
}
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-cb-block",
Type: database.TargetTypeHTTP,
Config: newHTTPTargetConfig("http://will-not-be-called.invalid"),
MaxRetries: 5,
},
}
d.ID = delivery.ID
d.Target.ID = targetID
e.deliverHTTP(context.TODO(), db, d, task)
// Delivery should be retrying (circuit open, no attempt made)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, database.DeliveryStatusRetrying, updated.Status,
"delivery should be retrying when circuit breaker is open")
// No delivery result should have been recorded (no attempt was made)
var resultCount int64
db.Model(&database.DeliveryResult{}).Where("delivery_id = ?", delivery.ID).Count(&resultCount)
assert.Equal(t, int64(0), resultCount,
"no delivery result should be recorded when circuit is open")
}
func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
targetID := uuid.New().String()
cb1 := e.getCircuitBreaker(targetID)
require.NotNil(t, cb1)
assert.Equal(t, CircuitClosed, cb1.State())
// Same target should return the same circuit breaker
cb2 := e.getCircuitBreaker(targetID)
assert.Same(t, cb1, cb2, "same target ID should return the same circuit breaker")
// Different target should return a different circuit breaker
otherID := uuid.New().String()
cb3 := e.getCircuitBreaker(otherID)
assert.NotSame(t, cb1, cb3, "different target ID should return a different circuit breaker")
}
func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
cfg, err := e.parseHTTPConfig(`{"url":"https://example.com/hook","headers":{"X-Token":"secret"}}`)
require.NoError(t, err)
assert.Equal(t, "https://example.com/hook", cfg.URL)
assert.Equal(t, "secret", cfg.Headers["X-Token"])
}
func TestParseHTTPConfig_Empty(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.parseHTTPConfig("")
assert.Error(t, err, "empty config should return error")
}
func TestParseHTTPConfig_MissingURL(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.parseHTTPConfig(`{"headers":{"X-Token":"secret"}}`)
assert.Error(t, err, "config without URL should return error")
}
func TestScheduleRetry_SendsToRetryChannel(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
task := DeliveryTask{
DeliveryID: uuid.New().String(),
EventID: uuid.New().String(),
WebhookID: uuid.New().String(),
TargetID: uuid.New().String(),
AttemptNum: 2,
}
e.scheduleRetry(task, 10*time.Millisecond)
// Wait for the timer to fire
select {
case received := <-e.retryCh:
assert.Equal(t, task.DeliveryID, received.DeliveryID)
assert.Equal(t, task.AttemptNum, received.AttemptNum)
case <-time.After(2 * time.Second):
t.Fatal("retry task was not sent to retry channel within timeout")
}
}
func TestScheduleRetry_DropsWhenChannelFull(t *testing.T) {
t.Parallel()
e := &Engine{
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
retryCh: make(chan DeliveryTask, 1), // tiny buffer
}
// Fill the retry channel
e.retryCh <- DeliveryTask{DeliveryID: "fill"}
task := DeliveryTask{
DeliveryID: "overflow",
AttemptNum: 2,
}
// Should not panic or block
e.scheduleRetry(task, 0)
// Give timer a moment to fire
time.Sleep(50 * time.Millisecond)
// Only the original task should be in the channel
received := <-e.retryCh
assert.Equal(t, "fill", received.DeliveryID,
"only the original task should be in the channel (overflow was dropped)")
}
func TestIsForwardableHeader(t *testing.T) {
t.Parallel()
// Should forward
assert.True(t, isForwardableHeader("X-Custom-Header"))
assert.True(t, isForwardableHeader("Authorization"))
assert.True(t, isForwardableHeader("Accept"))
assert.True(t, isForwardableHeader("X-GitHub-Event"))
// Should NOT forward (hop-by-hop)
assert.False(t, isForwardableHeader("Host"))
assert.False(t, isForwardableHeader("Connection"))
assert.False(t, isForwardableHeader("Keep-Alive"))
assert.False(t, isForwardableHeader("Transfer-Encoding"))
assert.False(t, isForwardableHeader("Content-Length"))
}
func TestTruncate(t *testing.T) {
t.Parallel()
assert.Equal(t, "hello", truncate("hello", 10))
assert.Equal(t, "hello", truncate("hello", 5))
assert.Equal(t, "hel", truncate("hello", 3))
assert.Equal(t, "", truncate("", 5))
}
func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
t.Parallel()
var receivedHeaders http.Header
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
e := testEngine(t, 1)
cfg := &HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{"X-Target-Auth": "bearer xyz"},
}
event := &database.Event{
Method: "POST",
Headers: `{"X-Custom":["value1"],"Content-Type":["application/json"]}`,
Body: `{"test":true}`,
ContentType: "application/json",
}
statusCode, _, _, err := e.doHTTPRequest(cfg, event)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, statusCode)
// Check forwarded headers
assert.Equal(t, "value1", receivedHeaders.Get("X-Custom"))
assert.Equal(t, "bearer xyz", receivedHeaders.Get("X-Target-Auth"))
assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type"))
assert.Equal(t, "webhooker/1.0", receivedHeaders.Get("User-Agent"))
}
func TestProcessDelivery_RoutesToCorrectHandler(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
tests := []struct {
name string
targetType database.TargetType
wantStatus database.DeliveryStatus
}{
{"database target", database.TargetTypeDatabase, database.DeliveryStatusDelivered},
{"log target", database.TargetTypeLog, database.DeliveryStatusDelivered},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
event := seedEvent(t, db, `{"routing":"test"}`)
delivery := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: delivery.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-" + string(tt.targetType),
Type: tt.targetType,
},
}
d.ID = delivery.ID
task := &DeliveryTask{
DeliveryID: delivery.ID,
TargetType: tt.targetType,
}
e.processDelivery(context.TODO(), db, d, task)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", delivery.ID).Error)
assert.Equal(t, tt.wantStatus, updated.Status)
})
}
}
func TestMaxInlineBodySize_Constant(t *testing.T) {
t.Parallel()
// Verify the constant is 16KB as documented
assert.Equal(t, 16*1024, MaxInlineBodySize,
"MaxInlineBodySize should be 16KB (16384 bytes)")
}

View File

@@ -1,153 +0,0 @@
package delivery
import (
"context"
"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
)
// blockedNetworks contains all private/reserved IP ranges that should be
// blocked to prevent SSRF attacks. This includes RFC 1918 private
// addresses, loopback, link-local, and IPv6 equivalents.
//
//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{
// IPv4 private/reserved ranges
"127.0.0.0/8", // Loopback
"10.0.0.0/8", // RFC 1918 Class A private
"172.16.0.0/12", // RFC 1918 Class B private
"192.168.0.0/16", // RFC 1918 Class C private
"169.254.0.0/16", // Link-local (cloud metadata)
"0.0.0.0/8", // "This" network
"100.64.0.0/10", // Shared address space (CGN)
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"198.18.0.0/15", // Benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"224.0.0.0/4", // Multicast
"240.0.0.0/4", // Reserved for future use
// IPv6 private/reserved ranges
"::1/128", // Loopback
"fc00::/7", // Unique local addresses
"fe80::/10", // Link-local
}
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. It validates the URL format, resolves the hostname
// to IP addresses, and verifies that none of the resolved IPs are in
// blocked private/reserved ranges.
//
// Returns nil if the URL is safe, or an error describing the issue.
func ValidateTargetURL(targetURL string) error {
parsed, err := url.Parse(targetURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Only allow http and https schemes
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("unsupported URL scheme %q: only http and https are allowed", parsed.Scheme)
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("URL has no hostname")
}
// Check if the host is a raw IP address first
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("target IP %s is in a blocked private/reserved range", ip)
}
return nil
}
// Resolve hostname to IPs and check each one
ctx, cancel := context.WithTimeout(context.Background(), dnsResolutionTimeout)
defer cancel()
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
if len(ips) == 0 {
return fmt.Errorf("hostname %q resolved to no IP addresses", host)
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return fmt.Errorf("hostname %q resolves to blocked IP %s (private/reserved range)", host, ipAddr.IP)
}
}
return nil
}
// NewSSRFSafeTransport creates an http.Transport with a custom DialContext
// that blocks connections to private/reserved IP addresses. This provides
// defense-in-depth SSRF protection at the network layer, catching cases
// where DNS records change between target creation and delivery time
// (DNS rebinding attacks).
func NewSSRFSafeTransport() *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("ssrf: invalid address %q: %w", addr, err)
}
// Resolve hostname to IPs
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("ssrf: DNS resolution failed for %q: %w", host, err)
}
// Check all resolved IPs
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return nil, fmt.Errorf("ssrf: connection to %s (%s) blocked — private/reserved IP range", host, ipAddr.IP)
}
}
// Connect to the first allowed IP
var dialer net.Dialer
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
},
}
}

View File

@@ -1,142 +0,0 @@
package delivery
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsBlockedIP_PrivateRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ip string
blocked bool
}{
// Loopback
{"loopback 127.0.0.1", "127.0.0.1", true},
{"loopback 127.0.0.2", "127.0.0.2", true},
{"loopback 127.255.255.255", "127.255.255.255", true},
// RFC 1918 - Class A
{"10.0.0.0", "10.0.0.0", true},
{"10.0.0.1", "10.0.0.1", true},
{"10.255.255.255", "10.255.255.255", true},
// RFC 1918 - Class B
{"172.16.0.1", "172.16.0.1", true},
{"172.31.255.255", "172.31.255.255", true},
{"172.15.255.255", "172.15.255.255", false},
{"172.32.0.0", "172.32.0.0", false},
// RFC 1918 - Class C
{"192.168.0.1", "192.168.0.1", true},
{"192.168.255.255", "192.168.255.255", true},
// Link-local / cloud metadata
{"169.254.0.1", "169.254.0.1", true},
{"169.254.169.254", "169.254.169.254", true},
// Public IPs (should NOT be blocked)
{"8.8.8.8", "8.8.8.8", false},
{"1.1.1.1", "1.1.1.1", false},
{"93.184.216.34", "93.184.216.34", false},
// IPv6 loopback
{"::1", "::1", true},
// IPv6 unique local
{"fd00::1", "fd00::1", true},
{"fc00::1", "fc00::1", true},
// IPv6 link-local
{"fe80::1", "fe80::1", true},
// IPv6 public (should NOT be blocked)
{"2607:f8b0:4004:800::200e", "2607:f8b0:4004:800::200e", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tt.ip)
require.NotNil(t, ip, "failed to parse IP %s", tt.ip)
assert.Equal(t, tt.blocked, isBlockedIP(ip),
"isBlockedIP(%s) = %v, want %v", tt.ip, isBlockedIP(ip), tt.blocked)
})
}
}
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 := ValidateTargetURL(u)
assert.Error(t, err, "URL %s should be blocked", u)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel()
// These are public IPs and should be allowed
allowedURLs := []string{
"https://example.com/hook",
"http://93.184.216.34/webhook",
"https://hooks.slack.com/services/T00/B00/xxx",
}
for _, u := range allowedURLs {
t.Run(u, func(t *testing.T) {
t.Parallel()
err := ValidateTargetURL(u)
assert.NoError(t, err, "URL %s should be allowed", u)
})
}
}
func TestValidateTargetURL_InvalidScheme(t *testing.T) {
t.Parallel()
err := ValidateTargetURL("ftp://example.com/hook")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported URL scheme")
}
func TestValidateTargetURL_EmptyHost(t *testing.T) {
t.Parallel()
err := ValidateTargetURL("http:///path")
assert.Error(t, err)
}
func TestValidateTargetURL_InvalidURL(t *testing.T) {
t.Parallel()
err := ValidateTargetURL("://invalid")
assert.Error(t, err)
}
func TestBlockedNetworks_Initialized(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, blockedNetworks, "blockedNetworks should be initialized")
// Should have at least the main RFC 1918 + loopback + link-local ranges
assert.GreaterOrEqual(t, len(blockedNetworks), 8,
"should have at least 8 blocked network ranges")
}

View File

@@ -6,20 +6,23 @@ import (
// these get populated from main() and copied into the Globals object. // these get populated from main() and copied into the Globals object.
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
// 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) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Version: Version, Buildarch: Buildarch,
Version: Version,
} }
return n, nil return n, nil
} }

View File

@@ -10,6 +10,7 @@ func TestNew(t *testing.T) {
// Set test values // Set test values
Appname = "test-app" Appname = "test-app"
Version = "1.0.0" Version = "1.0.0"
Buildarch = "test-arch"
lc := fxtest.NewLifecycle(t) lc := fxtest.NewLifecycle(t)
globals, err := New(lc) globals, err := New(lc)
@@ -23,4 +24,7 @@ func TestNew(t *testing.T) {
if globals.Version != "1.0.0" { if globals.Version != "1.0.0" {
t.Errorf("Version = %v, want %v", 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")
}
} }

View File

@@ -78,23 +78,14 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
return return
} }
// Get the current session (may be pre-existing / attacker-set) // Create session
oldSess, err := h.session.Get(r) sess, err := h.session.Get(r)
if err != nil { if err != nil {
h.log.Error("failed to get session", "error", err) h.log.Error("failed to get session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
// Regenerate the session to prevent session fixation attacks.
// This destroys the old session ID and creates a new one.
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error("failed to regenerate session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Set user in session // Set user in session
h.session.SetUser(sess, user.ID, user.Username) h.session.SetUser(sess, user.ID, user.Username)

View File

@@ -9,11 +9,9 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates" "sneak.berlin/go/webhooker/templates"
) )
@@ -21,13 +19,11 @@ import (
// nolint:revive // HandlersParams is a standard fx naming convention // nolint:revive // HandlersParams is a standard fx naming convention
type HandlersParams struct { type HandlersParams struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Database *database.Database Database *database.Database
WebhookDBMgr *database.WebhookDBManager Healthcheck *healthcheck.Healthcheck
Healthcheck *healthcheck.Healthcheck Session *session.Session
Session *session.Session
Notifier delivery.Notifier
} }
type Handlers struct { type Handlers struct {
@@ -35,21 +31,15 @@ type Handlers struct {
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
db *database.Database db *database.Database
dbMgr *database.WebhookDBManager
session *session.Session session *session.Session
notifier delivery.Notifier
templates map[string]*template.Template templates map[string]*template.Template
} }
// parsePageTemplate parses a page-specific template set from the embedded FS. // parsePageTemplate parses a page-specific template set from the embedded FS.
// Each page template is combined with the shared base, htmlheader, and navbar templates. // Each page template is combined with the shared base, htmlheader, and navbar templates.
// The page file must be listed first so that its root action ({{template "base" .}})
// becomes the template set's entry point. If a shared partial (e.g. htmlheader.html)
// is listed first, its {{define}} block becomes the root — which is empty — and
// Execute() produces no output.
func parsePageTemplate(pageFile string) *template.Template { func parsePageTemplate(pageFile string) *template.Template {
return template.Must( return template.Must(
template.ParseFS(templates.Templates, pageFile, "base.html", "htmlheader.html", "navbar.html"), template.ParseFS(templates.Templates, "htmlheader.html", "navbar.html", "base.html", pageFile),
) )
} }
@@ -59,20 +49,13 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.hc = params.Healthcheck s.hc = params.Healthcheck
s.db = params.Database s.db = params.Database
s.dbMgr = params.WebhookDBMgr
s.session = params.Session s.session = params.Session
s.notifier = params.Notifier
// Parse all page templates once at startup // Parse all page templates once at startup
s.templates = map[string]*template.Template{ s.templates = map[string]*template.Template{
"index.html": parsePageTemplate("index.html"), "index.html": parsePageTemplate("index.html"),
"login.html": parsePageTemplate("login.html"), "login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"), "profile.html": parsePageTemplate("profile.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_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{ lc.Append(fx.Hook{
@@ -100,6 +83,14 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac
return json.NewDecoder(r.Body).Decode(v) 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 // UserInfo represents user information for templates
type UserInfo struct { type UserInfo struct {
ID string ID string
@@ -129,13 +120,9 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTe
} }
} }
// Get CSRF token from request context (set by CSRF middleware) // If data is a map, merge user info into it
csrfToken := middleware.CSRFToken(r)
// If data is a map, merge user info and CSRF token into it
if m, ok := data.(map[string]interface{}); ok { if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo m["User"] = userInfo
m["CSRFToken"] = csrfToken
if err := tmpl.Execute(w, m); err != nil { if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err) s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -145,15 +132,13 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTe
// Wrap data with base template data // Wrap data with base template data
type templateDataWrapper struct { type templateDataWrapper struct {
User *UserInfo User *UserInfo
CSRFToken string Data interface{}
Data interface{}
} }
wrapper := templateDataWrapper{ wrapper := templateDataWrapper{
User: userInfo, User: userInfo,
CSRFToken: csrfToken, Data: data,
Data: data,
} }
if err := tmpl.Execute(w, wrapper); err != nil { if err := tmpl.Execute(w, wrapper); err != nil {

View File

@@ -12,18 +12,12 @@ import (
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
// noopNotifier is a no-op delivery.Notifier for tests.
type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.DeliveryTask) {}
func TestHandleIndex(t *testing.T) { func TestHandleIndex(t *testing.T) {
var h *Handlers var h *Handlers
@@ -34,14 +28,17 @@ func TestHandleIndex(t *testing.T) {
logger.New, logger.New,
func() *config.Config { func() *config.Config {
return &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, func() *database.Database {
database.NewWebhookDBManager, // Mock database with a mock DB method
db := &database.Database{}
return db
},
healthcheck.New, healthcheck.New,
session.New, session.New,
func() delivery.Notifier { return &noopNotifier{} },
New, New,
), ),
fx.Populate(&h), fx.Populate(&h),
@@ -65,14 +62,16 @@ func TestRenderTemplate(t *testing.T) {
logger.New, logger.New,
func() *config.Config { func() *config.Config {
return &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, func() *database.Database {
database.NewWebhookDBManager, // Mock database
return &database.Database{}
},
healthcheck.New, healthcheck.New,
session.New, session.New,
func() delivery.Notifier { return &noopNotifier{} },
New, New,
), ),
fx.Populate(&h), fx.Populate(&h),

View File

@@ -8,6 +8,11 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
type IndexResponse struct {
Message string `json:"message"`
Version string `json:"version"`
}
func (s *Handlers) HandleIndex() http.HandlerFunc { func (s *Handlers) HandleIndex() http.HandlerFunc {
// Calculate server start time // Calculate server start time
startTime := time.Now() startTime := time.Now()

View File

@@ -1,733 +1,69 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/google/uuid"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
// WebhookListItem holds data for the webhook list view. // HandleSourceList shows a list of user's webhooks
type WebhookListItem struct {
database.Webhook
EntrypointCount int64
TargetCount int64
EventCount int64
}
// HandleSourceList shows a list of user's webhooks.
func (h *Handlers) HandleSourceList() http.HandlerFunc { func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook list page
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
var webhooks []database.Webhook
if err := h.db.DB().Where("user_id = ?", userID).Order("created_at DESC").Find(&webhooks).Error; err != nil {
h.log.Error("failed to list webhooks", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Build list items with counts
items := make([]WebhookListItem, len(webhooks))
for i := range webhooks {
items[i].Webhook = webhooks[i]
h.db.DB().Model(&database.Entrypoint{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].EntrypointCount)
h.db.DB().Model(&database.Target{}).Where("webhook_id = ?", webhooks[i].ID).Count(&items[i].TargetCount)
// Event count comes from per-webhook DB
if h.dbMgr.DBExists(webhooks[i].ID) {
if webhookDB, err := h.dbMgr.GetDB(webhooks[i].ID); err == nil {
webhookDB.Model(&database.Event{}).Count(&items[i].EventCount)
}
}
}
data := map[string]interface{}{
"Webhooks": items,
}
h.renderTemplate(w, r, "sources_list.html", data)
} }
} }
// HandleSourceCreate shows the form to create a new webhook. // HandleSourceCreate shows the form to create a new webhook
func (h *Handlers) HandleSourceCreate() http.HandlerFunc { func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{ // TODO: Implement webhook creation form
"Error": "", http.Error(w, "Not implemented", http.StatusNotImplemented)
}
h.renderTemplate(w, r, "sources_new.html", data)
} }
} }
// HandleSourceCreateSubmit handles the webhook creation form submission. // HandleSourceCreateSubmit handles the webhook creation form submission
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook creation logic
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
description := r.FormValue("description")
retentionStr := r.FormValue("retention_days")
if name == "" {
data := map[string]interface{}{
"Error": "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "sources_new.html", data)
return
}
retentionDays := 30
if retentionStr != "" {
if v, err := strconv.Atoi(retentionStr); err == nil && v > 0 {
retentionDays = v
}
}
tx := h.db.DB().Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
webhook := &database.Webhook{
UserID: userID,
Name: name,
Description: description,
RetentionDays: retentionDays,
}
if err := tx.Create(webhook).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create webhook", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Auto-create one entrypoint
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: "Default entrypoint",
Active: true,
}
if err := tx.Create(entrypoint).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create entrypoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit transaction", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create per-webhook event database
if err := h.dbMgr.CreateDB(webhook.ID); err != nil {
h.log.Error("failed to create webhook event database",
"webhook_id", webhook.ID,
"error", err,
)
// Non-fatal: the DB will be created lazily on first event
}
h.log.Info("webhook created",
"webhook_id", webhook.ID,
"name", name,
"user_id", userID,
)
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
} }
} }
// HandleSourceDetail shows details for a specific webhook. // HandleSourceDetail shows details for a specific webhook
func (h *Handlers) HandleSourceDetail() http.HandlerFunc { func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook detail page
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
var entrypoints []database.Entrypoint
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&entrypoints)
var targets []database.Target
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&targets)
// Recent events from per-webhook database
var events []database.Event
if h.dbMgr.DBExists(webhook.ID) {
if webhookDB, err := h.dbMgr.GetDB(webhook.ID); err == nil {
webhookDB.Where("webhook_id = ?", webhook.ID).Order("created_at DESC").Limit(20).Find(&events)
}
}
// Build host URL for display
host := r.Host
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
// Check X-Forwarded headers
if fwdProto := r.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
scheme = fwdProto
}
data := map[string]interface{}{
"Webhook": webhook,
"Entrypoints": entrypoints,
"Targets": targets,
"Events": events,
"BaseURL": scheme + "://" + host,
}
h.renderTemplate(w, r, "source_detail.html", data)
} }
} }
// HandleSourceEdit shows the form to edit a webhook. // HandleSourceEdit shows the form to edit a webhook
func (h *Handlers) HandleSourceEdit() http.HandlerFunc { func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook edit form
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
data := map[string]interface{}{
"Webhook": webhook,
"Error": "",
}
h.renderTemplate(w, r, "source_edit.html", data)
} }
} }
// HandleSourceEditSubmit handles the webhook edit form submission. // HandleSourceEditSubmit handles the webhook edit form submission
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook update logic
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
data := map[string]interface{}{
"Webhook": webhook,
"Error": "Name is required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, "source_edit.html", data)
return
}
webhook.Name = name
webhook.Description = r.FormValue("description")
if retStr := r.FormValue("retention_days"); retStr != "" {
if v, err := strconv.Atoi(retStr); err == nil && v > 0 {
webhook.RetentionDays = v
}
}
if err := h.db.DB().Save(&webhook).Error; err != nil {
h.log.Error("failed to update webhook", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
} }
} }
// HandleSourceDelete handles webhook deletion. // HandleSourceDelete handles webhook deletion
// Configuration data is soft-deleted in the main DB.
// The per-webhook event database file is hard-deleted (permanently removed).
func (h *Handlers) HandleSourceDelete() http.HandlerFunc { func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook deletion logic
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Soft-delete configuration in the main application database
tx := h.db.DB().Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Soft-delete entrypoints and targets (config tier)
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Entrypoint{})
tx.Where("webhook_id = ?", webhook.ID).Delete(&database.Target{})
tx.Delete(&webhook)
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit deletion", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Hard-delete the per-webhook event database file
if err := h.dbMgr.DeleteDB(webhook.ID); err != nil {
h.log.Error("failed to delete webhook event database",
"webhook_id", webhook.ID,
"error", err,
)
// Non-fatal: file may not exist if no events were ever received
}
h.log.Info("webhook deleted", "webhook_id", webhook.ID, "user_id", userID)
http.Redirect(w, r, "/sources", http.StatusSeeOther)
} }
} }
// HandleSourceLogs shows the request/response logs for a webhook. // HandleSourceLogs shows the request/response logs for a webhook
// Events and deliveries are read from the per-webhook database.
// Target information is loaded from the main application database.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc { func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r) // TODO: Implement webhook logs page
if !ok { http.Error(w, "Not implemented", http.StatusNotImplemented)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Load targets from main DB for display
var targets []database.Target
h.db.DB().Where("webhook_id = ?", webhook.ID).Find(&targets)
targetMap := make(map[string]database.Target, len(targets))
for _, t := range targets {
targetMap[t.ID] = t
}
// Pagination
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v > 0 {
page = v
}
}
perPage := 25
offset := (page - 1) * perPage
// EventWithDeliveries holds an event with its associated deliveries
type EventWithDeliveries struct {
database.Event
Deliveries []database.Delivery
}
var totalEvents int64
var eventsWithDeliveries []EventWithDeliveries
// Read events and deliveries from per-webhook database
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.log.Error("failed to get webhook database",
"webhook_id", webhook.ID,
"error", err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
webhookDB.Model(&database.Event{}).Where("webhook_id = ?", webhook.ID).Count(&totalEvents)
var events []database.Event
webhookDB.Where("webhook_id = ?", webhook.ID).
Order("created_at DESC").
Offset(offset).
Limit(perPage).
Find(&events)
eventsWithDeliveries = make([]EventWithDeliveries, len(events))
for i := range events {
eventsWithDeliveries[i].Event = events[i]
// Load deliveries from per-webhook DB (without Target preload)
webhookDB.Where("event_id = ?", events[i].ID).Find(&eventsWithDeliveries[i].Deliveries)
// Manually assign targets from main DB
for j := range eventsWithDeliveries[i].Deliveries {
if target, ok := targetMap[eventsWithDeliveries[i].Deliveries[j].TargetID]; ok {
eventsWithDeliveries[i].Deliveries[j].Target = target
}
}
}
}
totalPages := int(totalEvents) / perPage
if int(totalEvents)%perPage != 0 {
totalPages++
}
data := map[string]interface{}{
"Webhook": webhook,
"Events": eventsWithDeliveries,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": totalEvents,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.renderTemplate(w, r, "source_logs.html", data)
} }
} }
// HandleEntrypointCreate handles adding a new entrypoint to a webhook.
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
// Verify ownership
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
description := r.FormValue("description")
entrypoint := &database.Entrypoint{
WebhookID: webhook.ID,
Path: uuid.New().String(),
Description: description,
Active: true,
}
if err := h.db.DB().Create(entrypoint).Error; err != nil {
h.log.Error("failed to create entrypoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleTargetCreate handles adding a new target to a webhook.
func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
targetType := database.TargetType(r.FormValue("type"))
url := r.FormValue("url")
maxRetriesStr := r.FormValue("max_retries")
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
// Validate target type
switch targetType {
case database.TargetTypeHTTP, database.TargetTypeDatabase, database.TargetTypeLog:
// valid
default:
http.Error(w, "Invalid target type", http.StatusBadRequest)
return
}
// Build config JSON for HTTP targets
var configJSON string
if targetType == database.TargetTypeHTTP {
if url == "" {
http.Error(w, "URL is required for HTTP targets", http.StatusBadRequest)
return
}
// Validate URL against SSRF: block private/reserved IP ranges
if err := delivery.ValidateTargetURL(url); err != nil {
h.log.Warn("target URL blocked by SSRF protection",
"url", url,
"error", err,
)
http.Error(w, "Invalid target URL: "+err.Error(), http.StatusBadRequest)
return
}
cfg := map[string]interface{}{
"url": url,
}
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
configJSON = string(configBytes)
}
maxRetries := 0 // default: fire-and-forget (no retries)
if maxRetriesStr != "" {
if v, err := strconv.Atoi(maxRetriesStr); err == nil && v >= 0 {
maxRetries = v
}
}
target := &database.Target{
WebhookID: webhook.ID,
Name: name,
Type: targetType,
Active: true,
Config: configJSON,
MaxRetries: maxRetries,
}
if err := h.db.DB().Create(target).Error; err != nil {
h.log.Error("failed to create target", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleEntrypointDelete handles deleting an individual entrypoint.
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
entrypointID := chi.URLParam(r, "entrypointID")
// Verify webhook ownership
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Delete entrypoint (must belong to this webhook)
result := h.db.DB().Where("id = ? AND webhook_id = ?", entrypointID, webhook.ID).Delete(&database.Entrypoint{})
if result.Error != nil {
h.log.Error("failed to delete entrypoint", "error", result.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleEntrypointToggle handles toggling the active state of an entrypoint.
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
entrypointID := chi.URLParam(r, "entrypointID")
// Verify webhook ownership
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Find the entrypoint
var entrypoint database.Entrypoint
if err := h.db.DB().Where("id = ? AND webhook_id = ?", entrypointID, webhook.ID).First(&entrypoint).Error; err != nil {
http.NotFound(w, r)
return
}
// Toggle active state
entrypoint.Active = !entrypoint.Active
if err := h.db.DB().Save(&entrypoint).Error; err != nil {
h.log.Error("failed to toggle entrypoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleTargetDelete handles deleting an individual target.
func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
targetID := chi.URLParam(r, "targetID")
// Verify webhook ownership
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Delete target (must belong to this webhook)
result := h.db.DB().Where("id = ? AND webhook_id = ?", targetID, webhook.ID).Delete(&database.Target{})
if result.Error != nil {
h.log.Error("failed to delete target", "error", result.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// HandleTargetToggle handles toggling the active state of a target.
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
if !ok {
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
sourceID := chi.URLParam(r, "sourceID")
targetID := chi.URLParam(r, "targetID")
// Verify webhook ownership
var webhook database.Webhook
if err := h.db.DB().Where("id = ? AND user_id = ?", sourceID, userID).First(&webhook).Error; err != nil {
http.NotFound(w, r)
return
}
// Find the target
var target database.Target
if err := h.db.DB().Where("id = ? AND webhook_id = ?", targetID, webhook.ID).First(&target).Error; err != nil {
http.NotFound(w, r)
return
}
// Toggle active state
target.Active = !target.Active
if err := h.db.DB().Save(&target).Error; err != nil {
h.log.Error("failed to toggle target", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/source/"+webhook.ID, http.StatusSeeOther)
}
}
// getUserID extracts the user ID from the session.
func (h *Handlers) getUserID(r *http.Request) (string, bool) {
sess, err := h.session.Get(r)
if err != nil {
return "", false
}
if !h.session.IsAuthenticated(sess) {
return "", false
}
return h.session.GetUserID(sess)
}

View File

@@ -1,190 +1,41 @@
package handlers package handlers
import ( import (
"encoding/json"
"io"
"net/http" "net/http"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
) )
const ( // HandleWebhook handles incoming webhook requests at entrypoint URLs
// maxWebhookBodySize is the maximum allowed webhook request body (1 MB).
maxWebhookBodySize = 1 << 20
)
// HandleWebhook handles incoming webhook requests at entrypoint URLs.
// Only POST requests are accepted; all other methods return 405 Method Not Allowed.
// Events and deliveries are stored in the per-webhook database. The handler
// builds self-contained DeliveryTask structs with all target and event data
// so the delivery engine can process them without additional DB reads.
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { // Get entrypoint UUID from URL
w.Header().Set("Allow", "POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
entrypointUUID := chi.URLParam(r, "uuid") entrypointUUID := chi.URLParam(r, "uuid")
if entrypointUUID == "" { if entrypointUUID == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
// Log the incoming webhook request
h.log.Info("webhook request received", h.log.Info("webhook request received",
"entrypoint_uuid", entrypointUUID, "entrypoint_uuid", entrypointUUID,
"method", r.Method, "method", r.Method,
"remote_addr", r.RemoteAddr, "remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
) )
// Look up entrypoint by path (from main application DB) // Only POST methods are allowed for webhooks
var entrypoint database.Entrypoint if r.Method != http.MethodPost {
result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint) w.Header().Set("Allow", "POST")
if result.Error != nil { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
h.log.Debug("entrypoint not found", "path", entrypointUUID)
http.NotFound(w, r)
return return
} }
// Check if active // TODO: Implement webhook handling logic
if !entrypoint.Active { // Look up entrypoint by UUID, find parent webhook, fan out to targets
http.Error(w, "Gone", http.StatusGone) w.WriteHeader(http.StatusNotFound)
return _, err := w.Write([]byte("unimplemented"))
}
// Read body with size limit
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil { if err != nil {
h.log.Error("failed to read request body", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if len(body) > maxWebhookBodySize {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
// Serialize headers as JSON
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.log.Error("failed to serialize headers", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Find all active targets for this webhook (from main application DB)
var targets []database.Target
if targetErr := h.db.DB().Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; targetErr != nil {
h.log.Error("failed to query targets", "error", targetErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get the per-webhook database for event storage
webhookDB, err := h.dbMgr.GetDB(entrypoint.WebhookID)
if err != nil {
h.log.Error("failed to get webhook database",
"webhook_id", entrypoint.WebhookID,
"error", err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create the event and deliveries in a transaction on the per-webhook DB
tx := webhookDB.Begin()
if tx.Error != nil {
h.log.Error("failed to begin transaction", "error", tx.Error)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
event := &database.Event{
WebhookID: entrypoint.WebhookID,
EntrypointID: entrypoint.ID,
Method: r.Method,
Headers: string(headersJSON),
Body: string(body),
ContentType: r.Header.Get("Content-Type"),
}
if err := tx.Create(event).Error; err != nil {
tx.Rollback()
h.log.Error("failed to create event", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Prepare body pointer for inline transport (≤16KB bodies are
// included in the DeliveryTask so the engine needs no DB read).
var bodyPtr *string
if len(body) < delivery.MaxInlineBodySize {
bodyStr := string(body)
bodyPtr = &bodyStr
}
// Create delivery records and build self-contained delivery tasks
tasks := make([]delivery.DeliveryTask, 0, len(targets))
for i := range targets {
dlv := &database.Delivery{
EventID: event.ID,
TargetID: targets[i].ID,
Status: database.DeliveryStatusPending,
}
if err := tx.Create(dlv).Error; 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
}
tasks = append(tasks, delivery.DeliveryTask{
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,
})
}
if err := tx.Commit().Error; err != nil {
h.log.Error("failed to commit transaction", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Notify the delivery engine with self-contained delivery tasks.
// Each task carries all target config and event data inline so
// the engine can deliver without touching any database (in the
// ≤16KB happy path). The engine only writes to the DB to record
// delivery results after each attempt.
if len(tasks) > 0 {
h.notifier.Notify(tasks)
}
h.log.Info("webhook event created",
"event_id", event.ID,
"webhook_id", entrypoint.WebhookID,
"entrypoint_id", entrypoint.ID,
"target_count", len(targets),
)
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
h.log.Error("failed to write response", "error", err) h.log.Error("failed to write response", "error", err)
} }
} }

View File

@@ -82,6 +82,7 @@ func (l *Logger) Identify() {
l.logger.Info("starting", l.logger.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch,
) )
} }

View File

@@ -11,6 +11,7 @@ func TestNew(t *testing.T) {
// Set up globals // Set up globals
globals.Appname = "test-app" globals.Appname = "test-app"
globals.Version = "1.0.0" globals.Version = "1.0.0"
globals.Buildarch = "test-arch"
lc := fxtest.NewLifecycle(t) lc := fxtest.NewLifecycle(t)
g, err := globals.New(lc) g, err := globals.New(lc)
@@ -39,6 +40,7 @@ func TestEnableDebugLogging(t *testing.T) {
// Set up globals // Set up globals
globals.Appname = "test-app" globals.Appname = "test-app"
globals.Version = "1.0.0" globals.Version = "1.0.0"
globals.Buildarch = "test-arch"
lc := fxtest.NewLifecycle(t) lc := fxtest.NewLifecycle(t)
g, err := globals.New(lc) g, err := globals.New(lc)

View File

@@ -1,114 +0,0 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
)
const (
// csrfTokenLength is the byte length of generated CSRF tokens.
// 32 bytes = 64 hex characters, providing 256 bits of entropy.
csrfTokenLength = 32
// csrfSessionKey is the session key where the CSRF token is stored.
csrfSessionKey = "csrf_token"
// csrfFormField is the HTML form field name for the CSRF token.
csrfFormField = "csrf_token"
)
// csrfContextKey is the context key type for CSRF tokens.
type csrfContextKey struct{}
// CSRFToken retrieves the CSRF token from the request context.
// Returns an empty string if no token is present.
func CSRFToken(r *http.Request) string {
if token, ok := r.Context().Value(csrfContextKey{}).(string); ok {
return token
}
return ""
}
// CSRF returns middleware that provides CSRF protection for state-changing
// requests. For every request, it ensures a CSRF token exists in the
// session and makes it available via the request context. For POST, PUT,
// PATCH, and DELETE requests, it validates the submitted csrf_token form
// field against the session token. Requests with an invalid or missing
// token receive a 403 Forbidden response.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := m.session.Get(r)
if err != nil {
m.log.Error("csrf: failed to get session", "error", err)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Ensure a CSRF token exists in the session
token, ok := sess.Values[csrfSessionKey].(string)
if !ok {
token = ""
}
if token == "" {
token, err = generateCSRFToken()
if err != nil {
m.log.Error("csrf: failed to generate token", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
sess.Values[csrfSessionKey] = token
if saveErr := m.session.Save(r, w, sess); saveErr != nil {
m.log.Error("csrf: failed to save session", "error", saveErr)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Store token in context for templates
ctx := context.WithValue(r.Context(), csrfContextKey{}, token)
r = r.WithContext(ctx)
// Validate token on state-changing methods
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
submitted := r.FormValue(csrfFormField)
if !secureCompare(submitted, token) {
m.log.Warn("csrf: token mismatch",
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// generateCSRFToken creates a cryptographically random hex-encoded token.
func generateCSRFToken() (string, error) {
b := make([]byte, csrfTokenLength)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// secureCompare performs a constant-time string comparison to prevent
// timing attacks on CSRF token validation.
func secureCompare(a, b string) bool {
if len(a) != len(b) {
return false
}
var result byte
for i := 0; i < len(a); i++ {
result |= a[i] ^ b[i]
}
return result == 0
}

View File

@@ -1,184 +0,0 @@
package middleware
import (
"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"
)
func TestCSRF_GETSetsToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var gotToken string
handler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotToken = CSRFToken(r)
}))
req := httptest.NewRequest(http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.NotEmpty(t, gotToken, "CSRF token should be set in context on GET")
assert.Len(t, gotToken, csrfTokenLength*2, "CSRF token should be hex-encoded 32 bytes")
}
func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
// Use a separate handler for the GET to capture the token
var token string
getHandler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
token = CSRFToken(r)
}))
// GET to establish the session and capture token
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
require.NotEmpty(t, cookies)
require.NotEmpty(t, token)
// POST handler that tracks whether it was called
var called bool
postHandler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// POST with valid token
form := url.Values{csrfFormField: {token}}
postReq := httptest.NewRequest(http.MethodPost, "/form", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.True(t, called, "handler should be called with valid CSRF token")
}
func TestCSRF_POSTWithoutToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
// GET handler to establish session
getHandler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
// no-op — just establishes session
}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST handler that tracks whether it was called
var called bool
postHandler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// POST without CSRF token
postReq := httptest.NewRequest(http.MethodPost, "/form", nil)
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, "handler should NOT be called without CSRF token")
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
// GET handler to establish session
getHandler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
// no-op — just establishes session
}))
getReq := httptest.NewRequest(http.MethodGet, "/form", nil)
getW := httptest.NewRecorder()
getHandler.ServeHTTP(getW, getReq)
cookies := getW.Result().Cookies()
// POST handler that tracks whether it was called
var called bool
postHandler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// POST with wrong CSRF token
form := url.Values{csrfFormField: {"invalid-token-value"}}
postReq := httptest.NewRequest(http.MethodPost, "/form", strings.NewReader(form.Encode()))
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
postReq.AddCookie(c)
}
postW := httptest.NewRecorder()
postHandler.ServeHTTP(postW, postReq)
assert.False(t, called, "handler should NOT be called with invalid CSRF token")
assert.Equal(t, http.StatusForbidden, postW.Code)
}
func TestCSRF_GETDoesNotValidate(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var called bool
handler := m.CSRF()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
called = true
}))
// GET requests should pass through without CSRF validation
req := httptest.NewRequest(http.MethodGet, "/form", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.True(t, called, "GET requests should pass through CSRF middleware")
}
func TestCSRFToken_NoContext(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Empty(t, CSRFToken(req), "CSRFToken should return empty string when no token in context")
}
func TestGenerateCSRFToken(t *testing.T) {
t.Parallel()
token, err := generateCSRFToken()
require.NoError(t, err)
assert.Len(t, token, csrfTokenLength*2, "token should be hex-encoded")
// Verify uniqueness
token2, err := generateCSRFToken()
require.NoError(t, err)
assert.NotEqual(t, token, token2, "each generated token should be unique")
}
func TestSecureCompare(t *testing.T) {
t.Parallel()
assert.True(t, secureCompare("abc", "abc"))
assert.False(t, secureCompare("abc", "abd"))
assert.False(t, secureCompare("abc", "ab"))
assert.False(t, secureCompare("", "a"))
assert.True(t, secureCompare("", ""))
}

View File

@@ -108,22 +108,18 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
} }
func (s *Middleware) CORS() func(http.Handler) http.Handler { func (s *Middleware) CORS() func(http.Handler) http.Handler {
if s.params.Config.IsDev() { return cors.Handler(cors.Options{
// In development, allow any origin for local testing. // CHANGEME! these are defaults, change them to suit your needs or
return cors.Handler(cors.Options{ // read from environment/viper.
AllowedOrigins: []string{"*"}, // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedOrigins: []string{"*"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, // AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
ExposedHeaders: []string{"Link"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowCredentials: false, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
MaxAge: 300, ExposedHeaders: []string{"Link"},
}) AllowCredentials: false,
} 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. // RequireAuth returns middleware that checks for a valid session.
@@ -171,35 +167,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)
})
}
}

View File

@@ -1,471 +0,0 @@
package middleware
import (
"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/session"
)
// testMiddleware creates a Middleware with minimal dependencies for testing.
// It uses a real session.Session backed by an in-memory cookie store.
func testMiddleware(t *testing.T, env string) (*Middleware, *session.Session) {
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, 32)
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 := newTestSession(t, store, cfg, log)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
return m, sessManager
}
// newTestSession creates a session.Session with a pre-configured cookie store
// for testing. This avoids needing the fx lifecycle and database.
func newTestSession(t *testing.T, store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *session.Session {
t.Helper()
return session.NewForTest(store, cfg, log)
}
// --- Logging Middleware Tests ---
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)
if _, err := w.Write([]byte("created")); err != nil {
return
}
}))
req := httptest.NewRequest(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) {
if _, err := w.Write([]byte("ok")); err != nil {
return
}
}))
req := httptest.NewRequest(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.NewRequest(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 := NewLoggingResponseWriter(w)
// Default should be 200
assert.Equal(t, http.StatusOK, lrw.statusCode)
// WriteHeader should capture the status code
lrw.WriteHeader(http.StatusNotFound)
assert.Equal(t, http.StatusNotFound, lrw.statusCode)
// 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 := NewLoggingResponseWriter(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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 := ipFromHostPort(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// --- MetricsAuth Tests ---
func TestMetricsAuth_ValidCredentials(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
cfg := &config.Config{
Environment: config.EnvironmentDev,
MetricsUsername: "admin",
MetricsPassword: "secret",
}
key := make([]byte, 32)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
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()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
cfg := &config.Config{
Environment: config.EnvironmentDev,
MetricsUsername: "admin",
MetricsPassword: "secret",
}
key := make([]byte, 32)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
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()
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
cfg := &config.Config{
Environment: config.EnvironmentDev,
MetricsUsername: "admin",
MetricsPassword: "secret",
}
key := make([]byte, 32)
store := sessions.NewCookieStore(key)
store.Options = &sessions.Options{Path: "/", MaxAge: 86400}
sessManager := session.NewForTest(store, cfg, log)
m := &Middleware{
log: log,
params: &MiddlewareParams{
Config: cfg,
},
session: sessManager,
}
var called bool
handler := m.MetricsAuth()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
}))
req := httptest.NewRequest(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.NewRequest(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, 32)
for i := range key {
key[i] = byte(i + 1)
}
encoded := base64.StdEncoding.EncodeToString(key)
decoded, err := base64.StdEncoding.DecodeString(encoded)
require.NoError(t, err)
assert.Len(t, decoded, 32)
}

View File

@@ -1,172 +0,0 @@
package middleware
import (
"net"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
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
// limiterCleanupInterval is how often stale per-IP limiters are pruned.
limiterCleanupInterval = 5 * time.Minute
// limiterMaxAge is how long an unused limiter is kept before pruning.
limiterMaxAge = 10 * time.Minute
)
// ipLimiter holds a rate limiter and the time it was last used.
type ipLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
}
// rateLimiterMap manages per-IP rate limiters with periodic cleanup.
type rateLimiterMap struct {
mu sync.Mutex
limiters map[string]*ipLimiter
rate rate.Limit
burst int
}
// newRateLimiterMap creates a new per-IP rate limiter map.
func newRateLimiterMap(r rate.Limit, burst int) *rateLimiterMap {
rlm := &rateLimiterMap{
limiters: make(map[string]*ipLimiter),
rate: r,
burst: burst,
}
// Start background cleanup goroutine
go rlm.cleanup()
return rlm
}
// getLimiter returns the rate limiter for the given IP, creating one if
// it doesn't exist.
func (rlm *rateLimiterMap) getLimiter(ip string) *rate.Limiter {
rlm.mu.Lock()
defer rlm.mu.Unlock()
if entry, ok := rlm.limiters[ip]; ok {
entry.lastSeen = time.Now()
return entry.limiter
}
limiter := rate.NewLimiter(rlm.rate, rlm.burst)
rlm.limiters[ip] = &ipLimiter{
limiter: limiter,
lastSeen: time.Now(),
}
return limiter
}
// cleanup periodically removes stale rate limiters to prevent unbounded
// memory growth from unique IPs.
func (rlm *rateLimiterMap) cleanup() {
ticker := time.NewTicker(limiterCleanupInterval)
defer ticker.Stop()
for range ticker.C {
rlm.mu.Lock()
cutoff := time.Now().Add(-limiterMaxAge)
for ip, entry := range rlm.limiters {
if entry.lastSeen.Before(cutoff) {
delete(rlm.limiters, ip)
}
}
rlm.mu.Unlock()
}
}
// LoginRateLimit returns middleware that enforces per-IP rate limiting
// on login attempts. 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.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
// Calculate rate: loginRateLimit events per loginRateInterval
r := rate.Limit(float64(loginRateLimit) / loginRateInterval.Seconds())
rlm := newRateLimiterMap(r, loginRateLimit)
return func(next http.Handler) http.Handler {
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
}
ip := extractIP(r)
limiter := rlm.getLimiter(ip)
if !limiter.Allow() {
m.log.Warn("login rate limit exceeded",
"ip", ip,
"path", r.URL.Path,
)
http.Error(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// extractIP extracts the client IP address from the request. It checks
// X-Forwarded-For and X-Real-IP headers first (for reverse proxy setups),
// then falls back to RemoteAddr.
func extractIP(r *http.Request) string {
// Check X-Forwarded-For header (first IP in chain)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
// The first one is the original client
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
ip := xff[:i]
// Trim whitespace
for len(ip) > 0 && ip[0] == ' ' {
ip = ip[1:]
}
for len(ip) > 0 && ip[len(ip)-1] == ' ' {
ip = ip[:len(ip)-1]
}
if ip != "" {
return ip
}
break
}
}
trimmed := xff
for len(trimmed) > 0 && trimmed[0] == ' ' {
trimmed = trimmed[1:]
}
for len(trimmed) > 0 && trimmed[len(trimmed)-1] == ' ' {
trimmed = trimmed[:len(trimmed)-1]
}
if trimmed != "" {
return trimmed
}
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fall back to RemoteAddr
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}

View File

@@ -1,121 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/config"
)
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 := 0; i < 20; i++ {
req := httptest.NewRequest(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 := 0; i < loginRateLimit; i++ {
req := httptest.NewRequest(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.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "10.0.0.1:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code, "POST after limit should be 429")
assert.Equal(t, loginRateLimit, callCount)
}
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 i := 0; i < loginRateLimit; i++ {
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "1.2.3.4:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
}
// IP1 should be rate-limited
req := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req.RemoteAddr = "1.2.3.4:12345"
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
// IP2 should still be allowed
req2 := httptest.NewRequest(http.MethodPost, "/pages/login", nil)
req2.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")
}
func TestExtractIP_RemoteAddr(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.168.1.100:54321"
assert.Equal(t, "192.168.1.100", extractIP(req))
}
func TestExtractIP_XForwardedFor(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50, 70.41.3.18, 150.172.238.178")
assert.Equal(t, "203.0.113.50", extractIP(req))
}
func TestExtractIP_XRealIP(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Real-IP", "203.0.113.50")
assert.Equal(t, "203.0.113.50", extractIP(req))
}
func TestExtractIP_XForwardedForSingle(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.50")
assert.Equal(t, "203.0.113.50", extractIP(req))
}

View File

@@ -11,38 +11,49 @@ import (
"sneak.berlin/go/webhooker/static" "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
func (s *Server) SetupRoutes() { func (s *Server) SetupRoutes() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
// Global middleware stack — applied to every request. // 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.Recoverer)
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging()) 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 != "" { if s.params.Config.MetricsUsername != "" {
s.router.Use(s.mw.Metrics()) s.router.Use(s.mw.Metrics())
} }
// set up CORS headers
s.router.Use(s.mw.CORS()) s.router.Use(s.mw.CORS())
// timeout for request context; your handlers must finish within
// this window:
s.router.Use(middleware.Timeout(60 * time.Second)) s.router.Use(middleware.Timeout(60 * time.Second))
// Sentry error reporting (if SENTRY_DSN is set). Repanic is true // this adds a sentry reporting middleware if and only if sentry is
// so panics still bubble up to the Recoverer middleware above. // enabled via setting of SENTRY_DSN in env.
if s.sentryEnabled { 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{ sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true, Repanic: true,
}) })
s.router.Use(sentryHandler.Handle) s.router.Use(sentryHandler.Handle)
} }
// Routes ////////////////////////////////////////////////////////////////////////
// ROUTES
// complete docs: https://github.com/go-chi/chi
////////////////////////////////////////////////////////////////////////
s.router.Get("/", s.h.HandleIndex()) s.router.Get("/", s.h.HandleIndex())
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
@@ -64,18 +75,11 @@ func (s *Server) SetupRoutes() {
}) })
} }
// pages that are rendered server-side — CSRF-protected, body-size // pages that are rendered server-side
// limited, and with per-IP rate limiting on the login endpoint.
s.router.Route("/pages", func(r chi.Router) { s.router.Route("/pages", func(r chi.Router) {
r.Use(s.mw.CSRF()) // Login page (no auth required)
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Get("/login", s.h.HandleLoginPage())
r.Post("/login", s.h.HandleLoginSubmit())
// Login page — rate-limited to prevent brute-force attacks
r.Group(func(r chi.Router) {
r.Use(s.mw.LoginRateLimit())
r.Get("/login", s.h.HandleLoginPage())
r.Post("/login", s.h.HandleLoginSubmit())
})
// Logout (auth required) // Logout (auth required)
r.Post("/logout", s.h.HandleLogout()) r.Post("/logout", s.h.HandleLogout())
@@ -83,39 +87,26 @@ func (s *Server) SetupRoutes() {
// User profile routes // User profile routes
s.router.Route("/user/{username}", func(r chi.Router) { s.router.Route("/user/{username}", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Get("/", s.h.HandleProfile()) r.Get("/", s.h.HandleProfile())
}) })
// Webhook management routes (require authentication, CSRF-protected) // Webhook management routes (require authentication)
s.router.Route("/sources", func(r chi.Router) { s.router.Route("/sources", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Get("/", s.h.HandleSourceList()) // List all webhooks r.Get("/", s.h.HandleSourceList()) // List all webhooks
r.Get("/new", s.h.HandleSourceCreate()) // Show create form r.Get("/new", s.h.HandleSourceCreate()) // Show create form
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
}) })
s.router.Route("/source/{sourceID}", func(r chi.Router) { s.router.Route("/source/{sourceID}", func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Use(s.mw.RequireAuth()) r.Use(s.mw.RequireAuth())
r.Use(s.mw.MaxBodySize(maxFormBodySize)) r.Get("/", s.h.HandleSourceDetail()) // View webhook details
r.Get("/", s.h.HandleSourceDetail()) // View webhook details r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint
r.Post("/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete()) // Delete entrypoint
r.Post("/entrypoints/{entrypointID}/toggle", s.h.HandleEntrypointToggle()) // Toggle entrypoint active
r.Post("/targets", s.h.HandleTargetCreate()) // Add target
r.Post("/targets/{targetID}/delete", s.h.HandleTargetDelete()) // Delete target
r.Post("/targets/{targetID}/toggle", s.h.HandleTargetToggle()) // Toggle target active
}) })
// Entrypoint endpoint accepts incoming webhook POST requests only. // Entrypoint endpoint - accepts incoming webhook POST requests
// Using HandleFunc so the handler itself can return 405 for non-POST
// methods (chi's Method routing returns 405 without Allow header).
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook()) s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
} }

View File

@@ -21,7 +21,8 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
) )
// nolint:revive // ServerParams is a standard fx naming convention // ServerParams is a standard fx naming convention for dependency injection
// nolint:golint
type ServerParams struct { type ServerParams struct {
fx.In fx.In
Logger *logger.Logger Logger *logger.Logger
@@ -108,7 +109,7 @@ func (s *Server) serve() int {
s.log.Info("signal received", "signal", sig.String()) s.log.Info("signal received", "signal", sig.String())
if s.cancelFunc != nil { if s.cancelFunc != nil {
// cancelling the main context will trigger a clean // cancelling the main context will trigger a clean
// shutdown via the fx OnStop hook. // shutdown.
s.cancelFunc() s.cancelFunc()
} }
}() }()
@@ -116,13 +117,13 @@ func (s *Server) serve() int {
go s.serveUntilShutdown() go s.serveUntilShutdown()
<-s.ctx.Done() <-s.ctx.Done()
// Shutdown is handled by the fx OnStop hook (cleanShutdown). s.cleanShutdown()
// Do not call cleanShutdown() here to avoid a double invocation.
return s.exitCode return s.exitCode
} }
func (s *Server) cleanupForExit() { func (s *Server) cleanupForExit() {
s.log.Info("cleaning up") s.log.Info("cleaning up")
// TODO: close database connections, flush buffers, etc.
} }
func (s *Server) cleanShutdown() { func (s *Server) cleanShutdown() {

View File

@@ -1,7 +1,6 @@
package session package session
import ( import (
"context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -10,7 +9,6 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -31,9 +29,8 @@ const (
// nolint:revive // SessionParams is a standard fx naming convention // nolint:revive // SessionParams is a standard fx naming convention
type SessionParams struct { type SessionParams struct {
fx.In fx.In
Config *config.Config Config *config.Config
Database *database.Database Logger *logger.Logger
Logger *logger.Logger
} }
// Session manages encrypted session storage // Session manages encrypted session storage
@@ -43,48 +40,39 @@ type Session struct {
config *config.Config config *config.Config
} }
// New creates a new session manager. The cookie store is initialized // New creates a new session manager
// during the fx OnStart phase after the database is connected, using
// a session key that is auto-generated and stored in the database.
func New(lc fx.Lifecycle, params SessionParams) (*Session, error) { func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
if params.Config.SessionKey == "" {
return nil, fmt.Errorf("SESSION_KEY environment variable is required")
}
// Decode the base64 session key
keyBytes, err := base64.StdEncoding.DecodeString(params.Config.SessionKey)
if err != nil {
return nil, fmt.Errorf("invalid SESSION_KEY format: %w", err)
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("SESSION_KEY must be 32 bytes (got %d)", len(keyBytes))
}
store := sessions.NewCookieStore(keyBytes)
// Configure cookie options for security
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true,
Secure: !params.Config.IsDev(), // HTTPS in production
SameSite: http.SameSiteLaxMode,
}
s := &Session{ s := &Session{
store: store,
log: params.Logger.Get(), log: params.Logger.Get(),
config: params.Config, config: params.Config,
} }
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
sessionKey, err := params.Database.GetOrCreateSessionKey()
if err != nil {
return fmt.Errorf("failed to get session key: %w", err)
}
keyBytes, err := base64.StdEncoding.DecodeString(sessionKey)
if err != nil {
return fmt.Errorf("invalid session key format: %w", err)
}
if len(keyBytes) != 32 {
return fmt.Errorf("session key must be 32 bytes (got %d)", len(keyBytes))
}
store := sessions.NewCookieStore(keyBytes)
// Configure cookie options for security
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true,
Secure: !params.Config.IsDev(), // HTTPS in production
SameSite: http.SameSiteLaxMode,
}
s.store = store
s.log.Info("session manager initialized")
return nil
},
})
return s, nil return s, nil
} }
@@ -135,50 +123,3 @@ func (s *Session) Destroy(sess *sessions.Session) {
sess.Options.MaxAge = -1 sess.Options.MaxAge = -1
s.ClearUser(sess) s.ClearUser(sess)
} }
// Regenerate creates a new session with the same values but a fresh ID.
// The old session is destroyed (MaxAge = -1) and saved, then a new session
// is created. This prevents session fixation attacks by ensuring the
// session ID changes after privilege escalation (e.g. login).
func (s *Session) Regenerate(r *http.Request, w http.ResponseWriter, oldSess *sessions.Session) (*sessions.Session, error) {
// Copy the values from the old session
oldValues := make(map[interface{}]interface{})
for k, v := range oldSess.Values {
oldValues[k] = v
}
// Destroy the old session
oldSess.Options.MaxAge = -1
s.ClearUser(oldSess)
if err := oldSess.Save(r, w); err != nil {
return nil, fmt.Errorf("failed to destroy old session: %w", err)
}
// 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
for k, v := range oldValues {
newSess.Values[k] = v
}
// Apply the standard session options (the destroyed old session had
// MaxAge = -1, which store.New might inherit from the cookie).
newSess.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: true,
Secure: !s.config.IsDev(),
SameSite: http.SameSiteLaxMode,
}
return newSess, nil
}

View File

@@ -1,378 +0,0 @@
package session
import (
"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"
)
// testSession creates a Session with a real cookie store for testing.
func testSession(t *testing.T) *Session {
t.Helper()
key := make([]byte, 32)
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 NewForTest(store, cfg, log)
}
// --- Get and Save Tests ---
func TestGet_NewSession(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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 == 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", SessionName)
}
// --- SetUser and User Retrieval Tests ---
func TestSetUser_SetsAllFields(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
s.SetUser(sess, "user-abc-123", "alice")
assert.Equal(t, "user-abc-123", sess.Values[UserIDKey])
assert.Equal(t, "alice", sess.Values[UsernameKey])
assert.Equal(t, true, sess.Values[AuthenticatedKey])
}
func TestGetUserID(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(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.NewRequest(http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
// Set authenticated to a non-bool value
sess.Values[AuthenticatedKey] = "yes"
assert.False(t, s.IsAuthenticated(sess), "should return false for non-bool authenticated value")
}
// --- ClearUser Tests ---
func TestClearUser_RemovesAllKeys(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
sess, err := s.Get(req)
require.NoError(t, err)
s.SetUser(sess, "user-123", "alice")
s.ClearUser(sess)
_, hasUserID := sess.Values[UserIDKey]
assert.False(t, hasUserID, "UserIDKey should be removed")
_, hasUsername := sess.Values[UsernameKey]
assert.False(t, hasUsername, "UsernameKey should be removed")
_, hasAuth := sess.Values[AuthenticatedKey]
assert.False(t, hasAuth, "AuthenticatedKey should be removed")
}
// --- Destroy Tests ---
func TestDestroy_InvalidatesSession(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
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[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.NewRequest(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.NewRequest(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", SessionName)
assert.Equal(t, "user_id", UserIDKey)
assert.Equal(t, "username", UsernameKey)
assert.Equal(t, "authenticated", AuthenticatedKey)
}
// --- Edge Cases ---
func TestSetUser_OverwritesPreviousUser(t *testing.T) {
t.Parallel()
s := testSession(t)
req := httptest.NewRequest(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.NewRequest(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.NewRequest(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 it)
responseCookies := w2.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range responseCookies {
if c.Name == SessionName {
sessionCookie = c
break
}
}
require.NotNil(t, sessionCookie, "should have a session cookie in response")
assert.True(t, sessionCookie.MaxAge < 0, "destroyed session cookie should have negative MaxAge")
}

View File

@@ -1,19 +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.
func NewForTest(store *sessions.CookieStore, cfg *config.Config, log *slog.Logger) *Session {
return &Session{
store: store,
config: cfg,
log: log,
}
}

1
pkg/config/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@

303
pkg/config/README.md Normal file
View File

@@ -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.

180
pkg/config/config.go Normal file
View File

@@ -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 "sneak.berlin/go/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)
}

306
pkg/config/config_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -0,0 +1,146 @@
package config_test
import (
"fmt"
"testing"
"github.com/spf13/afero"
"sneak.berlin/go/webhooker/pkg/config"
)
// 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)
}
}

139
pkg/config/example_test.go Normal file
View File

@@ -0,0 +1,139 @@
package config_test
import (
"fmt"
"log"
"os"
"sneak.berlin/go/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
*/

41
pkg/config/go.mod Normal file
View File

@@ -0,0 +1,41 @@
module sneak.berlin/go/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
)

161
pkg/config/go.sum Normal file
View File

@@ -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=

104
pkg/config/loader.go Normal file
View File

@@ -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
}

373
pkg/config/manager.go Normal file
View File

@@ -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
}

204
pkg/config/resolver.go Normal file
View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -4,29 +4,15 @@
<head> <head>
{{template "htmlheader" .}} {{template "htmlheader" .}}
</head> </head>
<body class="bg-gray-50 min-h-screen flex flex-col"> <body>
<div class="flex-grow"> {{template "navbar" .}}
{{template "navbar" .}}
{{block "content" .}}{{end}} <!-- Main content -->
</div> {{block "content" .}}{{end}}
{{template "footer" .}}
<script defer src="/s/js/alpine.min.js"></script> <script src="/s/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/s/js/app.js"></script> <script src="/s/js/app.js"></script>
{{block "scripts" .}}{{end}} {{block "scripts" .}}{{end}}
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "footer"}}
<footer class="bg-gray-100 border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] mt-8">
<div class="max-w-6xl mx-auto px-8 py-6">
<div class="text-center text-sm text-gray-500 font-mono font-light">
<a href="https://git.eeqj.de/sneak/webhooker" class="hover:text-gray-700">Webhooker</a>
<span class="mx-1">by</span>
<a href="https://sneak.berlin" class="hover:text-gray-700">@sneak</a>
<span class="mx-3">|</span>
<span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span>
</div>
</div>
</footer>
{{end}}

View File

@@ -2,7 +2,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Webhooker{{end}}</title> <title>{{block "title" .}}Webhooker{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css"> <link href="/s/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<style>[x-cloak] { display: none !important; }</style> <link href="/s/css/style.css" rel="stylesheet">
{{block "head" .}}{{end}} {{block "head" .}}{{end}}
{{end}} {{end}}

View File

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

View File

@@ -2,57 +2,85 @@
{{define "title"}}Login - Webhooker{{end}} {{define "title"}}Login - Webhooker{{end}}
{{define "content"}} {{define "head"}}
<div class="min-h-screen flex items-center justify-center py-12 px-4"> <style>
<div class="max-w-md w-full"> body {
<div class="text-center mb-8"> background-color: #f8f9fa;
<h1 class="text-3xl font-medium text-gray-900">Webhooker</h1> }
<p class="mt-2 text-gray-600">Sign in to your account</p> .login-container {
</div> max-width: 400px;
margin: 100px auto;
}
.login-card {
background: white;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
font-size: 2rem;
font-weight: 600;
color: #333;
}
.login-header p {
color: #666;
margin-top: 10px;
}
.form-control:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
.btn-primary {
width: 100%;
padding: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.error-message {
margin-top: 15px;
}
</style>
{{end}}
{{define "content"}}
<div class="container">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>Webhooker</h1>
<p>Sign in to your account</p>
</div>
<div class="card p-8">
{{if .Error}} {{if .Error}}
<div class="alert-error"> <div class="alert alert-danger error-message" role="alert">
<div class="flex items-center"> {{.Error}}
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<span>{{.Error}}</span>
</div>
</div> </div>
{{end}} {{end}}
<form method="POST" action="/pages/login" class="space-y-6"> <form method="POST" action="/pages/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <div class="mb-3">
<div class="form-group"> <label for="username" class="form-label">Username</label>
<label for="username" class="label">Username</label> <input type="text" class="form-control" id="username" name="username"
<input placeholder="Enter your username" required autofocus>
type="text"
id="username"
name="username"
required
autofocus
autocomplete="username"
placeholder="Enter your username"
class="input"
>
</div> </div>
<div class="form-group"> <div class="mb-4">
<label for="password" class="label">Password</label> <label for="password" class="form-label">Password</label>
<input <input type="password" class="form-control" id="password" name="password"
type="password" placeholder="Enter your password" required>
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
class="input"
>
</div> </div>
<button type="submit" class="btn-primary w-full py-3">Sign In</button> <button type="submit" class="btn btn-primary">Sign In</button>
</form> </form>
<div class="mt-4 text-center text-muted">
<small>© 2025 Webhooker. All rights reserved.</small>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,52 +1,46 @@
{{define "navbar"}} {{define "navbar"}}
<nav class="app-bar" x-data="{ open: false }"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="max-w-6xl mx-auto flex justify-between items-center"> <div class="container-fluid">
<div class="flex items-center gap-3"> <a class="navbar-brand" href="/">Webhooker</a>
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
</div> <span class="navbar-toggler-icon"></span>
<!-- Mobile menu button -->
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav">
<!-- Desktop navigation --> <ul class="navbar-nav me-auto">
<div class="hidden md:flex items-center gap-4"> {{if .User}}
{{if .User}} <li class="nav-item">
<a href="/sources" class="btn-text">Sources</a> <a class="nav-link" href="/sources">Sources</a>
<a href="/user/{{.User.Username}}" class="btn-text"> </li>
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16"> {{end}}
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> </ul>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/> <ul class="navbar-nav">
</svg> {{if .User}}
{{.User.Username}} <!-- Logged in state -->
</a> <li class="nav-item dropdown">
<form method="POST" action="/pages/logout" class="inline"> <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-circle me-2" viewBox="0 0 16 16">
<button type="submit" class="btn-text">Logout</button> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
</form> <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
{{else}} </svg>
<a href="/pages/login" class="btn-primary">Login</a> {{.User.Username}}
{{end}} </a>
</div> <ul class="dropdown-menu dropdown-menu-end">
</div> <li><a class="dropdown-item" href="/user/{{.User.Username}}">Profile</a></li>
<li><hr class="dropdown-divider"></li>
<!-- Mobile navigation --> <li>
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200"> <form method="POST" action="/pages/logout" class="m-0">
<div class="flex flex-col gap-2"> <button type="submit" class="dropdown-item">Logout</button>
{{if .User}} </form>
<a href="/sources" class="btn-text w-full text-left">Sources</a> </li>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> </ul>
<form method="POST" action="/pages/logout"> </li>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> {{else}}
<button type="submit" class="btn-text w-full text-left">Logout</button> <!-- Logged out state -->
</form> <li class="nav-item">
{{else}} <a class="nav-link" href="/pages/login">Login</a>
<a href="/pages/login" class="btn-primary w-full">Login</a> </li>
{{end}} {{end}}
</ul>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -3,48 +3,51 @@
{{define "title"}}Profile - Webhooker{{end}} {{define "title"}}Profile - Webhooker{{end}}
{{define "content"}} {{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12"> <div class="container mt-5">
<h1 class="text-2xl font-medium text-gray-900 mb-6">User Profile</h1> <div class="row">
<div class="col-lg-8 mx-auto">
<h1 class="mb-4">User Profile</h1>
<div class="card p-6"> <div class="card shadow-sm">
<div class="flex items-center mb-6"> <div class="card-body">
<div class="mr-4"> <div class="row align-items-center mb-3">
<svg class="w-16 h-16 text-primary-500" fill="currentColor" viewBox="0 0 16 16"> <div class="col-auto">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-person-circle text-primary" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
</svg> <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
</div>
<div class="col">
<h3 class="mb-0">{{.User.Username}}</h3>
<p class="text-muted mb-0">User ID: {{.User.ID}}</p>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-6">
<h5>Account Information</h5>
<dl class="row">
<dt class="col-sm-4">Username</dt>
<dd class="col-sm-8">{{.User.Username}}</dd>
<dt class="col-sm-4">Account Type</dt>
<dd class="col-sm-8">Standard User</dd>
</dl>
</div>
<div class="col-md-6">
<h5>Settings</h5>
<p class="text-muted">Profile settings and preferences will be available here.</p>
</div>
</div>
</div>
</div> </div>
<div>
<h2 class="text-xl font-medium text-gray-900">{{.User.Username}}</h2> <div class="mt-4">
<p class="text-sm text-gray-500">User ID: {{.User.ID}}</p> <a href="/" class="btn btn-secondary">Back to Home</a>
</div> </div>
</div> </div>
<hr class="border-gray-200 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-3">Account Information</h3>
<dl class="space-y-3">
<div class="flex">
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
</div>
<div class="flex">
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
<dd class="text-sm text-gray-900">Standard User</dd>
</div>
</dl>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-3">Settings</h3>
<p class="text-sm text-gray-500">Profile settings and preferences will be available here.</p>
</div>
</div>
</div>
<div class="mt-6">
<a href="/" class="btn-secondary">Back to Home</a>
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@@ -1,178 +0,0 @@
{{template "base" .}}
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="mb-6">
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex justify-between items-center mt-2">
<div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}}
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
{{end}}
</div>
<div class="flex gap-2">
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button>
</form>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Entrypoints -->
<div class="card">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add
</button>
</div>
<!-- Add entrypoint form -->
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
<button type="submit" class="btn-primary text-sm">Add</button>
</form>
</div>
<div class="divide-y divide-gray-100">
{{range .Entrypoints}}
<div class="p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
<div class="flex items-center gap-2">
{{if .Active}}
<span class="badge-success">Active</span>
{{else}}
<span class="badge-error">Inactive</span>
{{end}}
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form>
</div>
</div>
<code class="text-xs text-gray-500 break-all block mt-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
{{end}}
</div>
</div>
<!-- Targets -->
<div class="card">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Add
</button>
</div>
<!-- Add target form -->
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="flex gap-2">
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
<select name="type" x-model="targetType" class="input text-sm w-32">
<option value="http">HTTP</option>
<option value="database">Database</option>
<option value="log">Log</option>
</select>
</div>
<div x-show="targetType === 'http'">
<input type="url" name="url" placeholder="https://example.com/webhook" class="input text-sm">
</div>
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
</div>
<button type="submit" class="btn-primary text-sm">Add Target</button>
</form>
</div>
<div class="divide-y divide-gray-100">
{{range .Targets}}
<div class="p-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
<div class="flex items-center gap-2">
<span class="badge-info">{{.Type}}</span>
{{if .Active}}
<span class="badge-success">Active</span>
{{else}}
<span class="badge-error">Inactive</span>
{{end}}
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
</form>
</div>
</div>
{{if .Config}}
<code class="text-xs text-gray-500 break-all block mt-1">{{.Config}}</code>
{{end}}
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
{{end}}
</div>
</div>
</div>
<!-- Recent Events -->
<div class="card mt-6">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
</div>
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span>
</div>
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
</div>
</div>
{{else}}
<div class="p-8 text-center text-sm text-gray-500">No events received yet.</div>
{{end}}
</div>
</div>
<!-- Info -->
<div class="mt-4 text-sm text-gray-400">
<p>Retention: {{.Webhook.RetentionDays}} days &middot; Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
</div>
</div>
{{end}}

View File

@@ -1,41 +0,0 @@
{{template "base" .}}
{{define "title"}}Edit {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
</div>
<div class="card p-6">
{{if .Error}}
<div class="alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/source/{{.Webhook.ID}}/edit" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="name" class="label">Name</label>
<input type="text" id="name" name="name" value="{{.Webhook.Name}}" required class="input">
</div>
<div class="form-group">
<label for="description" class="label">Description</label>
<textarea id="description" name="description" rows="3" class="input">{{.Webhook.Description}}</textarea>
</div>
<div class="form-group">
<label for="retention_days" class="label">Retention (days)</label>
<input type="number" id="retention_days" name="retention_days" value="{{.Webhook.RetentionDays}}" min="1" max="365" class="input">
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Save Changes</button>
<a href="/source/{{.Webhook.ID}}" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}

View File

@@ -1,61 +0,0 @@
{{template "base" .}}
{{define "title"}}Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/source/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Event Log</h1>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
</div>
</div>
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4" x-data="{ open: false }">
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
<div class="flex items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span>
</div>
<div class="flex items-center gap-4">
{{range .Deliveries}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">
{{.Target.Name}}: {{.Status}}
</span>
{{end}}
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
</div>
</div>
{{else}}
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
{{end}}
</div>
</div>
<!-- Pagination -->
{{if or .HasPrev .HasNext}}
<div class="flex justify-center gap-2 mt-6">
{{if .HasPrev}}
<a href="/source/{{.Webhook.ID}}/logs?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a>
{{end}}
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/source/{{.Webhook.ID}}/logs?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a>
{{end}}
</div>
{{end}}
</div>
{{end}}

View File

@@ -1,49 +0,0 @@
{{template "base" .}}
{{define "title"}}Sources - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>
<a href="/sources/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New Webhook
</a>
</div>
{{if .Webhooks}}
<div class="grid gap-4">
{{range .Webhooks}}
<a href="/source/{{.ID}}" class="card-elevated p-6 block">
<div class="flex justify-between items-start">
<div>
<h2 class="text-lg font-medium text-gray-900">{{.Name}}</h2>
{{if .Description}}
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
{{end}}
</div>
<span class="badge-info">{{.RetentionDays}}d retention</span>
</div>
<div class="flex gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
</div>
</a>
{{end}}
</div>
{{else}}
<div class="card p-12 text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<h2 class="text-lg font-medium text-gray-900 mb-2">No webhooks yet</h2>
<p class="text-gray-500 mb-6">Create your first webhook to start receiving and forwarding events.</p>
<a href="/sources/new" class="btn-primary">Create Webhook</a>
</div>
{{end}}
</div>
{{end}}

View File

@@ -1,42 +0,0 @@
{{template "base" .}}
{{define "title"}}New Webhook - Webhooker{{end}}
{{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
</div>
<div class="card p-6">
{{if .Error}}
<div class="alert-error">{{.Error}}</div>
{{end}}
<form method="POST" action="/sources/new" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="name" class="label">Name</label>
<input type="text" id="name" name="name" required autofocus placeholder="My Webhook" class="input">
</div>
<div class="form-group">
<label for="description" class="label">Description</label>
<textarea id="description" name="description" rows="3" placeholder="Optional description" class="input"></textarea>
</div>
<div class="form-group">
<label for="retention_days" class="label">Retention (days)</label>
<input type="number" id="retention_days" name="retention_days" value="30" min="1" max="365" class="input">
<p class="text-xs text-gray-500 mt-1">How long to keep event data.</p>
</div>
<div class="flex gap-3">
<button type="submit" class="btn-primary">Create Webhook</button>
<a href="/sources" class="btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}