This commit is contained in:
Jeffrey Paul 2026-03-01 22:52:08 +07:00
commit 1244f3e2d5
63 changed files with 6075 additions and 0 deletions

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# Binaries
*.exe
*.dll
*.so
*.dylib
bin/
webhooker
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go vendor directory
vendor/
# IDE specific files
.idea/
*.swp
*.swo
*~
.vscode/
# OS specific files
.DS_Store
Thumbs.db
# Environment files
.env
.env.local
# Database files
*.db
*.sqlite
*.sqlite3
# Log files
*.log
# Temporary files
tmp/
temp/

46
.golangci.yml Normal file
View File

@ -0,0 +1,46 @@
run:
timeout: 5m
tests: true
linters:
enable:
- gofmt
- revive
- govet
- errcheck
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- gosec
- misspell
- unparam
- prealloc
- copyloopvar
- gocritic
- gochecknoinits
- gochecknoglobals
linters-settings:
gofmt:
simplify: true
revive:
confidence: 0.8
govet:
enable:
- shadow
errcheck:
check-type-assertions: true
check-blank: true
issues:
exclude-rules:
# Exclude globals check for version variables in main
- path: cmd/webhooker/main.go
linters:
- gochecknoglobals
# Exclude globals check for version variables in globals package
- path: internal/globals/globals.go
linters:
- gochecknoglobals

58
Dockerfile Normal file
View File

@ -0,0 +1,58 @@
## lint image
FROM golangci/golangci-lint:latest
RUN mkdir -p /build
WORKDIR /build
COPY ./ ./
RUN golangci-lint run
## build image:
FROM golang:1.22-alpine AS builder
# Install build dependencies including gcc for CGO
RUN apk add --no-cache git make gcc musl-dev sqlite-dev
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application with CGO enabled for SQLite
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o webhooker cmd/webhooker/main.go
## output image:
FROM alpine:latest
# Install ca-certificates for HTTPS and sqlite libs
RUN apk --no-cache add ca-certificates sqlite-libs
# Create non-root user
RUN addgroup -g 1000 -S webhooker && \
adduser -u 1000 -S webhooker -G webhooker
# Set working directory
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/webhooker .
# Change ownership
RUN chown -R webhooker:webhooker /app
# Switch to non-root user
USER webhooker
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck.json || exit 1
# Run the application
CMD ["./webhooker"]

39
Makefile Normal file
View File

@ -0,0 +1,39 @@
.PHONY: test fmt lint build run clean
# Default target
test: lint
go test -v ./...
fmt:
go fmt ./...
lint:
golangci-lint run
build: test
go build -o bin/webhooker cmd/webhooker/main.go
run: build
./bin/webhooker
clean:
rm -rf bin/
# Development helpers
.PHONY: dev deps
dev:
go run cmd/webhooker/main.go
deps:
go mod download
go mod tidy
# Docker targets
.PHONY: docker-build docker-run
docker-build:
docker build -t webhooker:latest .
docker-run: docker-build
docker run --rm -p 8080:8080 webhooker:latest

315
README.md Normal file
View File

@ -0,0 +1,315 @@
# webhooker
webhooker is, at its core, a webhook proxy. it receives webhooks and stores
them in a database, then delivers them (either fire-and-forget, or with
retries until successful) to a configured endpoint.
webhook endpoints are inputs to an event processor. there can be multiple
webhook endpoint urls configured as inputs to any given processor. each
processor can have 0 or more targets. all events delivered to the event
processor will be delivered to all targets. such an object is called a
'delivery'. (this is not related to whether or not the target was
successful in delivering the event, just that it was proxied.)
each target type can deliver the webhook in a different way. the first
target type simply POSTs the webhook to a configured URL, doing a direct
fire-and-forget proxy. the duration, result, status code, and any other
metadata is logged in the database associated with the 'delivery' object
(which is the pairing of that event with that target).
the second target type is a 'retry' target. this target will retry the
delivery of the event to the target until it is successful, or until a
per-target maximum retry count is reached. the retry target will
exponentially back off the retry attempts, starting at 1 second and
increasing 10x each time.
the third target type is a database target. this target will store the
event in the database, but will not attempt to deliver it to any endpoint.
the fourth target type is a log target. this target will log the event
to the console, but will not attempt to deliver it to any endpoint.
## features
* store and forward (retries can be set to unlimited)
* multiple webhook endpoints per processor
* metrics availability in prometheus format for observability
* web interface for managing processors, webhooks, and targets
* web interface for viewing deliveries, events, and delivery results
* web interface for manually redelivering events to targets
## database tables
* `users` - these are the users of the webhooker service. each user has a
unique username and password, and can have multiple API keys.
* `processors` - many to one with users.
* each processor has a retention period, which is the number of days
that events will be retained in the database before being deleted.
* `webhooks` - these are the URLs that feed into processors. many to one.
* `targets` - these are the delivery targets for each processor. many to one with processors.
* each target has a type, which can be one of the following:
* `http` - this target will deliver the event to a configured URL via HTTP POST.
* `retry` - this target will retry the delivery of the event until it is successful or a maximum retry count is reached.
* each 'retry' target can have a maximum queue size, which is the maximum number of events that can be queued for retry delivery. when the queue size is reached, the oldest events will be purged from the queue.
* `database` - this target will store the event in a database, but will not attempt to deliver it to any endpoint.
* `log` - this target will log the event to the console, but will not attempt to deliver it to any endpoint.
* `deliveries` - these are the delivery attempts for each target. many to one with targets.
* `events` - these are the events that are delivered to targets. one to 0 or more with deliveries, based on targets
* `delivery_results` - these are the results of each delivery attempt. many to one with deliveries, but 1-to-1 in the usual case.
# Web Application
webhooker is a go application that provides a standard non-SPA web interface
for managing one's processors, webhooks, targets, and API keys. it allows
you to view your deliveries, events, and delivery results, and to optionally
manually redeliver events to targets.
the web application is implemented primarily without javascript, using
bootstrap for styling and layout. it uses go templates for rendering HTML,
and those templates are embedded in the binary using go:embed.
# architecture
webhooker uses Uber's fx dependency injection library for managing
application lifecycle. it uses log/slog for structured logging. it uses
gorm for database access, and initially uses sqlite for the database.
# Development Policies and Practices
## Code Structure and Organization
### Package Layout
- All application code lives under `internal/` to prevent external imports
- Main entry point is in `cmd/webhooker/`
- Package structure:
- `internal/config` - Configuration management using Viper
- `internal/database` - Database connections and migrations
- `internal/globals` - Global application metadata (version, build info)
- `internal/handlers` - HTTP handlers using closure pattern
- `internal/healthcheck` - Health check endpoint logic
- `internal/logger` - Structured logging setup
- `internal/middleware` - HTTP middleware (auth, CORS, logging, metrics)
- `internal/server` - HTTP server setup and routing
- `static/` - Static assets (CSS, JS)
- `templates/` - Go HTML templates
### Dependency Injection
- Use Uber's fx for dependency injection and lifecycle management
- All components should be provided via fx.Provide in main.go
- Use parameter structs with fx.In for dependency injection
- Follow fx lifecycle hooks (OnStart, OnStop) for initialization
### HTTP Routing and Handlers
- Use chi router for HTTP routing
- Handlers are methods on a Handlers struct that return http.HandlerFunc
- Use closure pattern for handlers:
```go
func (h *Handlers) HandleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// handler logic here
}
}
```
- Group related routes using chi's Route method
- Apply middleware in correct order: recovery, request ID, logging, metrics, CORS, timeout
### Configuration
- Use Viper for configuration management
- Support environment variables with automatic binding
- Use `.env` files for local development (via godotenv/autoload)
- Set sensible defaults for all configuration values
- Configuration precedence: env vars > config file > defaults
### Logging
- Use zerolog for structured JSON logging
- Always log in UTC
- Use console writer for TTY, JSON for production
- Include request ID in all request-scoped logs
- Log levels: Debug (development only), Info, Warn, Error, Fatal
- Include relevant context in all log messages
### Error Handling
- Use Sentry for error reporting in production (if SENTRY_DSN is set)
- Implement panic recovery middleware
- Return appropriate HTTP status codes
- Log all errors with context
### Database
- Use GORM for database operations
- Support SQLite for development, PostgreSQL for production
- Run migrations on startup
- Use UTC for all timestamps
- Implement proper connection pooling
### Testing and Quality
- Write tests for all new functionality
- Use table-driven tests where appropriate
- Mock external dependencies in tests
- Achieve reasonable test coverage (not 100% required)
- Use `testify` for assertions
### Development Workflow
- Use Makefile with standard targets:
- `make test` - Run all tests with `go test -v ./...`
- `make fmt` - Format code with `go fmt`
- `make lint` - Run golangci-lint
- `make build` - Build the binary
- Default target is `test`
- Pre-commit checks: `make test && make fmt && make lint`
- Use golangci-lint for linting with appropriate configuration
### Code Style
- Follow standard Go idioms and effective Go guidelines
- Use constants for repeated values (no magic numbers/strings)
- Define constants at package level or top of file
- Keep functions small and focused
- Use meaningful variable names
- Comment exported functions and types
- Use structured types for request/response near usage
### Security
- Implement proper authentication for admin endpoints
- Use basic auth for metrics endpoint
- Sanitize all user input
- Use prepared statements for database queries
- Don't log sensitive information (passwords, API keys)
### Observability
- Expose Prometheus metrics on `/metrics` (behind auth)
- Include standard HTTP metrics (request count, duration, size)
- Add custom metrics for business logic
- Implement health check endpoint at `/.well-known/healthcheck.json`
- Include version info in health check response
### Static Assets
- Embed static files and templates in binary using go:embed
- Serve static files from `/s/` path
- Use Bootstrap for UI without heavy JavaScript
- Prefer server-side rendering over SPA
### Docker
- Multi-stage Dockerfile for minimal image size
- Run as non-root user in container
- Include health check in Dockerfile
- Use Alpine Linux for smaller images
## Current Implementation Status
### What's Working
1. **Core Infrastructure**
- Dependency injection using Uber's fx
- Structured logging with Go's standard log/slog (replaced zerolog)
- Configuration management with Viper
- Environment variable support with godotenv
- Pure Go SQLite database driver (modernc.org/sqlite) - no CGO required
2. **Web Server**
- HTTP server with chi router
- Middleware stack (recovery, request ID, logging, CORS, metrics, timeout)
- Static file serving from embedded filesystem
- JSON API endpoints
- Health check endpoint at `/.well-known/healthcheck.json`
- Metrics endpoint at `/metrics` (with basic auth)
3. **Database Models**
- Full GORM models for all entities (users, processors, webhooks, targets, deliveries, events, delivery_results)
- Argon2id password hashing with secure defaults
- Auto-creation of admin user on first startup (password logged once)
4. **Testing**
- Unit tests for globals, logger, and database packages
- Database connection test that verifies SQLite works without CGO
- All tests pass
5. **Development Tools**
- Makefile with test, fmt, lint, build targets
- golangci-lint configuration (some deprecated linters need updating)
- Docker multi-stage build (updated to support CGO if needed)
- gitignore file
### API Endpoints
- `GET /` - Returns JSON welcome message
- `GET /.well-known/healthcheck.json` - Health check with uptime, version info
- `GET /s/*` - Static file serving (CSS, JS, Bootstrap)
- `GET /metrics` - Prometheus metrics (requires auth)
- `GET /api/v1/*` - API route group (not implemented yet)
### Next Steps
1. **Authentication**
- Implement user login/logout
- API key generation and validation
- Session management
- Protected routes
2. **Core Features**
- Webhook receiver endpoints
- Event processing logic
- Target delivery system (HTTP, retry, database, log)
- Web UI templates for management
3. **API Implementation**
- CRUD endpoints for processors, webhooks, targets
- Event viewing and filtering
- Delivery status and retry management
4. **Cleanup**
- Update golangci-lint config to remove deprecated linters
- Add more comprehensive tests
- Implement proper error handling
### Running the Application
```bash
# Development
make dev
# Build and run
make build
./bin/webhooker
# Run tests
make test
# Docker
make docker-build
make docker-run
```
### Environment Variables
- `DEBUG` - Enable debug logging
- `DEVELOPMENT_MODE` - Enable development features
- `PORT` - Server port (default: 8080)
- `DBURL` - Database connection string
- `METRICS_USERNAME` - Username for metrics endpoint
- `METRICS_PASSWORD` - Password for metrics endpoint
- `SENTRY_DSN` - Sentry error reporting (optional)
# purpose
* prometheus/grafana metric analysis of webhook frequency, size, and
handler performance
* introspection of webhook data
* debugging of webhook delivery issues
* redelivery of webhook events for application testing and development
* storage of webhook events for later analysis
* webhook delivery to multiple targets
* webhook delivery with retries for not-always-on applications
* can serve as an HA endpoint for ingestion to deliver events to less
reliable systems
# future plans
* email source type
* email delivery target
* sns delivery target
* s3 delivery target
* data transformations (e.g. to convert event notification data to slack
messages)
* slack delivery target
* slack source type
* text file or jsonl delivery target, with periodic uploading to s3

102
TODO.md Normal file
View File

@ -0,0 +1,102 @@
# Webhooker TODO List
## Phase 1: Security & Infrastructure Hardening
- [ ] Implement proper security headers (HSTS, CSP, X-Frame-Options, etc.)
- [ ] Add request timeouts and context handling
- [ ] Set maximum request/response body sizes
- [ ] Implement rate limiting middleware
- [ ] Add CSRF protection for forms
- [ ] Set up proper CORS handling
- [ ] Implement request ID tracking through entire request lifecycle
- [ ] Add panic recovery with proper error reporting
## Phase 2: Authentication & Authorization
- [ ] Create authentication middleware that checks session
- [ ] Implement proper session expiration
- [ ] Add "Remember me" functionality
- [ ] Implement password reset flow
- [ ] Add user registration (if needed)
- [ ] Create authorization middleware for protected routes
- [ ] Add API key authentication for programmatic access
## Phase 3: Database Models & Migrations
- [ ] Create webhook source model (id, user_id, name, target_url, secret, created_at, etc.)
- [ ] Create webhook request log model (id, source_id, request_headers, request_body, response_status, etc.)
- [ ] Create webhook retry model for failed deliveries
- [ ] Add database indexes for performance
- [ ] Create migration system for schema updates
## Phase 4: Webhook Source Management UI
- [ ] Implement webhook source list page (/sources)
- [ ] Create webhook source creation form (/sources/new)
- [ ] Build webhook source detail page (/source/{id})
- [ ] Add webhook source edit functionality (/source/{id}/edit)
- [ ] Implement webhook source deletion with confirmation
- [ ] Add webhook URL generation and display
- [ ] Create secret key generation and management
- [ ] Add webhook testing functionality
## Phase 5: Webhook Processing Engine
- [ ] Implement actual webhook reception at /webhook/{uuid}
- [ ] Validate incoming webhook requests (headers, body size, etc.)
- [ ] Create webhook forwarding logic to target URLs
- [ ] Implement request/response logging
- [ ] Add webhook signature verification (GitHub, Stripe, etc. formats)
- [ ] Create webhook transformation capabilities (headers, body)
- [ ] Implement timeout handling for outbound requests
- [ ] Add retry logic with exponential backoff
## Phase 6: Webhook Logs & Analytics
- [ ] Create webhook request log viewer (/source/{id}/logs)
- [ ] Add filtering and search capabilities for logs
- [ ] Implement request/response body viewer
- [ ] Create analytics dashboard (success rates, response times)
- [ ] Add webhook health monitoring
- [ ] Implement alerting for failed webhooks
- [ ] Create log retention policies
## Phase 7: Advanced Features
- [ ] Add webhook request replay functionality
- [ ] Implement webhook request batching
- [ ] Create webhook request queuing system
- [ ] Add support for multiple target URLs per source
- [ ] Implement conditional forwarding based on payload
- [ ] Add webhook transformation templates
- [ ] Create webhook debugging tools
- [ ] Implement webhook scheduling/delayed delivery
## Phase 8: API Development
- [ ] Create RESTful API for webhook source management
- [ ] Implement API authentication and rate limiting
- [ ] Add API documentation (OpenAPI/Swagger)
- [ ] Create API client libraries
- [ ] Implement webhooks-as-a-service API
## Phase 9: Performance & Scalability
- [ ] Implement caching layer (Redis)
- [ ] Add background job processing (for retries, etc.)
- [ ] Create horizontal scaling capabilities
- [ ] Implement webhook delivery parallelization
- [ ] Add metrics collection (Prometheus)
- [ ] Create performance monitoring dashboard
## Phase 10: Operations & Maintenance
- [ ] Add comprehensive logging throughout application
- [ ] Create admin dashboard for user management
- [ ] Implement backup and restore procedures
- [ ] Add system health checks and monitoring
- [ ] Create deployment automation (Docker, K8s)
- [ ] Implement zero-downtime deployments
- [ ] Add feature flags for gradual rollouts
## Nice-to-Have Features
- [ ] Webhook marketplace/templates
- [ ] Team collaboration features
- [ ] Webhook versioning
- [ ] A/B testing for webhooks
- [ ] Webhook analytics export
- [ ] Mobile app for monitoring
- [ ] Slack/Discord/Email notifications
- [ ] Webhook documentation generator
- [ ] GraphQL subscription support
- [ ] WebSocket support for real-time updates

50
config.yaml Normal file
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: ""

101
go.mod Normal file
View File

@ -0,0 +1,101 @@
module git.eeqj.de/sneak/webhooker
go 1.23.0
toolchain go1.24.1
require (
git.eeqj.de/sneak/webhooker/pkg/config v0.0.0-00010101000000-000000000000
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.18.0
github.com/slok/go-http-metrics v0.11.0
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.8.4
go.uber.org/fx v1.20.1
golang.org/x/crypto v0.38.0
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5
modernc.org/sqlite v1.28.0
)
require (
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.1.5 // indirect
cloud.google.com/go/secretmanager v1.11.4 // indirect
github.com/aws/aws-sdk-go v1.50.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/oauth2 v0.15.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/api v0.153.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.29.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)
replace git.eeqj.de/sneak/webhooker/pkg/config => ./pkg/config

315
go.sum Normal file
View File

@ -0,0 +1,315 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k=
cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI=
github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI=
github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.153.0 h1:N1AwGhielyKFaUqH07/ZSIQR3uNPcV7NVw0vj+j4iR4=
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.29.0 h1:tTFRFq69YKCF2QyGNuRUQxKBm1uZZLubf6Cjh/pVHXs=
modernc.org/libc v1.29.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=

138
internal/config/config.go Normal file
View File

@ -0,0 +1,138 @@
package config
import (
"fmt"
"log/slog"
"os"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
pkgconfig "git.eeqj.de/sneak/webhooker/pkg/config"
"go.uber.org/fx"
// spooky action at a distance!
// this populates the environment
// from a ./.env file automatically
// for development configuration.
// .env contents should be things like
// `DBURL=postgres://user:pass@.../`
// (without the backticks, of course)
_ "github.com/joho/godotenv/autoload"
)
const (
// EnvironmentDev represents development environment
EnvironmentDev = "dev"
// EnvironmentProd represents production environment
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
type ConfigParams struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
type Config struct {
DBURL string
Debug bool
MaintenanceMode bool
DevelopmentMode bool
DevAdminUsername string
DevAdminPassword string
Environment string
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
SessionKey string
params *ConfigParams
log *slog.Logger
}
// IsDev returns true if running in development environment
func (c *Config) IsDev() bool {
return c.Environment == EnvironmentDev
}
// IsProd returns true if running in production environment
func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd
}
// nolint:revive // lc parameter is required by fx even if unused
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log := params.Logger.Get()
// Determine environment from WEBHOOKER_ENVIRONMENT env var, default to dev
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentDev
}
// Validate environment
if environment != EnvironmentDev && environment != EnvironmentProd {
return nil, fmt.Errorf("WEBHOOKER_ENVIRONMENT must be either '%s' or '%s', got '%s'",
EnvironmentDev, EnvironmentProd, environment)
}
// Set the environment in the config package
pkgconfig.SetEnvironment(environment)
// Load configuration values
s := &Config{
DBURL: pkgconfig.GetString("dburl"),
Debug: pkgconfig.GetBool("debug"),
MaintenanceMode: pkgconfig.GetBool("maintenanceMode"),
DevelopmentMode: pkgconfig.GetBool("developmentMode"),
DevAdminUsername: pkgconfig.GetString("devAdminUsername"),
DevAdminPassword: pkgconfig.GetString("devAdminPassword"),
Environment: pkgconfig.GetString("environment", environment),
MetricsUsername: pkgconfig.GetString("metricsUsername"),
MetricsPassword: pkgconfig.GetString("metricsPassword"),
Port: pkgconfig.GetInt("port", 8080),
SentryDSN: pkgconfig.GetSecretString("sentryDSN"),
SessionKey: pkgconfig.GetSecretString("sessionKey"),
log: log,
params: &params,
}
// Validate database URL
if s.DBURL == "" {
return nil, fmt.Errorf("database URL (dburl) is required")
}
// In production, require session key
if s.IsProd() && s.SessionKey == "" {
return nil, fmt.Errorf("SESSION_KEY is required in production environment")
}
// In development mode, warn if using default session key
if s.IsDev() && s.SessionKey == DevSessionKey {
log.Warn("Using insecure default session key for development mode")
}
if s.Debug {
params.Logger.EnableDebugLogging()
s.log = params.Logger.Get()
log.Debug("Debug mode enabled")
}
// Log configuration summary (without secrets)
log.Info("Configuration loaded",
"environment", s.Environment,
"port", s.Port,
"debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"developmentMode", s.DevelopmentMode,
"hasSessionKey", s.SessionKey != "",
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth", s.MetricsUsername != "" && s.MetricsPassword != "",
)
return s, nil
}

View File

@ -0,0 +1,300 @@
package config
import (
"os"
"testing"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
pkgconfig "git.eeqj.de/sneak/webhooker/pkg/config"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
// createTestConfig creates a test configuration file in memory
func createTestConfig(fs afero.Fs) error {
configYAML := `
environments:
dev:
config:
port: 8080
debug: true
maintenanceMode: false
developmentMode: true
environment: dev
dburl: postgres://test:test@localhost:5432/test_dev?sslmode=disable
metricsUsername: testuser
metricsPassword: testpass
devAdminUsername: devadmin
devAdminPassword: devpass
secrets:
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=
sentryDSN: ""
prod:
config:
port: $ENV:PORT
debug: $ENV:DEBUG
maintenanceMode: $ENV:MAINTENANCE_MODE
developmentMode: false
environment: prod
dburl: $ENV:DBURL
metricsUsername: $ENV:METRICS_USERNAME
metricsPassword: $ENV:METRICS_PASSWORD
devAdminUsername: ""
devAdminPassword: ""
secrets:
sessionKey: $ENV:SESSION_KEY
sentryDSN: $ENV:SENTRY_DSN
configDefaults:
port: 8080
debug: false
maintenanceMode: false
developmentMode: false
environment: dev
metricsUsername: ""
metricsPassword: ""
devAdminUsername: ""
devAdminPassword: ""
`
return afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644)
}
func TestEnvironmentConfig(t *testing.T) {
tests := []struct {
name string
envValue string
envVars map[string]string
expectError bool
isDev bool
isProd bool
}{
{
name: "default is dev",
envValue: "",
expectError: false,
isDev: true,
isProd: false,
},
{
name: "explicit dev",
envValue: "dev",
expectError: false,
isDev: true,
isProd: false,
},
{
name: "explicit prod with session key",
envValue: "prod",
envVars: map[string]string{
"SESSION_KEY": "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
"DBURL": "postgres://prod:prod@localhost:5432/prod?sslmode=require",
},
expectError: false,
isDev: false,
isProd: true,
},
{
name: "invalid environment",
envValue: "staging",
expectError: true,
},
}
for _, tt := range tests {
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
if tt.envValue != "" {
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.envValue)
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
}
// Set additional environment variables
for k, v := range tt.envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
if tt.expectError {
// Use regular fx.New for error cases since fxtest doesn't expose errors the same way
var cfg *Config
app := fx.New(
fx.NopLogger, // Suppress fx logs in tests
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
} 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()
assert.Equal(t, tt.isDev, cfg.IsDev())
assert.Equal(t, tt.isProd, cfg.IsProd())
}
})
}
}
func TestSessionKeyDefaults(t *testing.T) {
tests := []struct {
name string
environment string
sessionKey string
dburl string
expectError bool
expectedKey string
}{
{
name: "dev mode with default session key",
environment: "dev",
sessionKey: "",
expectError: false,
expectedKey: DevSessionKey,
},
{
name: "dev mode with custom session key",
environment: "dev",
sessionKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==",
expectError: false,
expectedKey: "Y3VzdG9tLXNlc3Npb24ta2V5LTMyLWJ5dGVzLWxvbmchIQ==",
},
{
name: "prod mode with no session key fails",
environment: "prod",
sessionKey: "",
dburl: "postgres://prod:prod@localhost:5432/prod",
expectError: true,
},
{
name: "prod mode with session key succeeds",
environment: "prod",
sessionKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
dburl: "postgres://prod:prod@localhost:5432/prod",
expectError: false,
expectedKey: "cHJvZC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create in-memory filesystem with test config
fs := afero.NewMemMapFs()
// Create custom config for session key tests
configYAML := `
environments:
dev:
config:
environment: dev
developmentMode: true
dburl: postgres://test:test@localhost:5432/test_dev
secrets:`
// Only add sessionKey line if it's not empty
if tt.sessionKey != "" {
configYAML += `
sessionKey: ` + tt.sessionKey
} else if tt.environment == "dev" {
// For dev mode with no session key, use the default
configYAML += `
sessionKey: d2ViaG9va2VyLWRldi1zZXNzaW9uLWtleS1pbnNlY3VyZSE=`
}
// Add prod config if testing prod
if tt.environment == "prod" {
configYAML += `
prod:
config:
environment: prod
developmentMode: false
dburl: $ENV:DBURL
secrets:
sessionKey: $ENV:SESSION_KEY`
}
require.NoError(t, afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644))
pkgconfig.SetFs(fs)
// Clean up any existing env vars
os.Unsetenv("WEBHOOKER_ENVIRONMENT")
os.Unsetenv("SESSION_KEY")
os.Unsetenv("DBURL")
// Set environment variables
os.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
defer os.Unsetenv("WEBHOOKER_ENVIRONMENT")
if tt.sessionKey != "" && tt.environment == "prod" {
os.Setenv("SESSION_KEY", tt.sessionKey)
defer os.Unsetenv("SESSION_KEY")
}
if tt.dburl != "" {
os.Setenv("DBURL", tt.dburl)
defer os.Unsetenv("DBURL")
}
if tt.expectError {
// Use regular fx.New for error cases
var cfg *Config
app := fx.New(
fx.NopLogger, // Suppress fx logs in tests
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
assert.Error(t, app.Err())
} else {
// Use fxtest for success cases
var cfg *Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
if tt.environment == "dev" && tt.sessionKey == "" {
// Dev mode with no session key uses default
assert.Equal(t, DevSessionKey, cfg.SessionKey)
} else {
assert.Equal(t, tt.expectedKey, cfg.SessionKey)
}
}
})
}
}

View File

@ -0,0 +1,25 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// BaseModel contains common fields for all models
// This replaces gorm.Model but uses UUID instead of uint for ID
type BaseModel struct {
ID string `gorm:"type:uuid;primary_key" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
// BeforeCreate hook to set UUID before creating a record
func (b *BaseModel) BeforeCreate(tx *gorm.DB) error {
if b.ID == "" {
b.ID = uuid.New().String()
}
return nil
}

View File

@ -0,0 +1,144 @@
package database
import (
"context"
"database/sql"
"log/slog"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/logger"
"go.uber.org/fx"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite" // Pure Go SQLite driver
)
// nolint:revive // DatabaseParams is a standard fx naming convention
type DatabaseParams struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
type Database struct {
db *gorm.DB
log *slog.Logger
params *DatabaseParams
}
func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) {
d := &Database{
params: &params,
log: params.Logger.Get(),
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
return d.connect()
},
OnStop: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
return d.close()
},
})
return d, nil
}
func (d *Database) connect() error {
dbURL := d.params.Config.DBURL
if dbURL == "" {
// Default to SQLite for development
dbURL = "file:webhooker.db?cache=shared&mode=rwc"
}
// First, open the database with the pure Go driver
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
d.log.Error("failed to open database", "error", err)
return err
}
// Then use it with GORM
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{})
if err != nil {
d.log.Error("failed to connect to database", "error", err)
return err
}
d.db = db
d.log.Info("connected to database", "database", dbURL)
// Run migrations
return d.migrate()
}
func (d *Database) migrate() error {
// Run GORM auto-migrations
if err := d.Migrate(); err != nil {
d.log.Error("failed to run database migrations", "error", err)
return err
}
d.log.Info("database migrations completed")
// Check if admin user exists
var userCount int64
if err := d.db.Model(&User{}).Count(&userCount).Error; err != nil {
d.log.Error("failed to count users", "error", err)
return err
}
if userCount == 0 {
// Create admin user
d.log.Info("no users found, creating admin user")
// Generate random password
password, err := GenerateRandomPassword(16)
if err != nil {
d.log.Error("failed to generate random password", "error", err)
return err
}
// Hash the password
hashedPassword, err := HashPassword(password)
if err != nil {
d.log.Error("failed to hash password", "error", err)
return err
}
// Create admin user
adminUser := &User{
Username: "admin",
Password: hashedPassword,
}
if err := d.db.Create(adminUser).Error; err != nil {
d.log.Error("failed to create admin user", "error", err)
return err
}
// Log the password - this will only happen once on first startup
d.log.Info("admin user created",
"username", "admin",
"password", password,
"message", "SAVE THIS PASSWORD - it will not be shown again!")
}
return nil
}
func (d *Database) close() error {
if d.db != nil {
sqlDB, err := d.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
func (d *Database) DB() *gorm.DB {
return d.db
}

View File

@ -0,0 +1,81 @@
package database
import (
"context"
"testing"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
"go.uber.org/fx/fxtest"
)
func TestDatabaseConnection(t *testing.T) {
// Set up test dependencies
lc := fxtest.NewLifecycle(t)
// Create globals
globals.Appname = "webhooker-test"
globals.Version = "test"
globals.Buildarch = "test"
g, err := globals.New(lc)
if err != nil {
t.Fatalf("Failed to create globals: %v", err)
}
// Create logger
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}
// Create config
c, err := config.New(lc, config.ConfigParams{
Globals: g,
Logger: l,
})
if err != nil {
t.Fatalf("Failed to create config: %v", err)
}
// Set test database URL
c.DBURL = "file:test.db?cache=shared&mode=rwc"
// Create database
db, err := New(lc, DatabaseParams{
Config: c,
Logger: l,
})
if err != nil {
t.Fatalf("Failed to create database: %v", err)
}
// Start lifecycle (this will trigger the connection)
ctx := context.Background()
err = lc.Start(ctx)
if err != nil {
t.Fatalf("Failed to connect to database: %v", err)
}
defer func() {
if stopErr := lc.Stop(ctx); stopErr != nil {
t.Errorf("Failed to stop lifecycle: %v", stopErr)
}
}()
// Verify we can get the DB instance
if db.DB() == nil {
t.Error("Expected non-nil database connection")
}
// Test that we can perform a simple query
var result int
err = db.DB().Raw("SELECT 1").Scan(&result).Error
if err != nil {
t.Fatalf("Failed to execute test query: %v", err)
}
if result != 1 {
t.Errorf("Expected query result to be 1, got %d", result)
}
}

View File

@ -0,0 +1,16 @@
package database
import "time"
// APIKey represents an API key for a user
type APIKey struct {
BaseModel
UserID string `gorm:"type:uuid;not null" json:"user_id"`
Key string `gorm:"uniqueIndex;not null" json:"key"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// Relations
User User `json:"user,omitempty"`
}

View File

@ -0,0 +1,25 @@
package database
// DeliveryStatus represents the status of a delivery
type DeliveryStatus string
const (
DeliveryStatusPending DeliveryStatus = "pending"
DeliveryStatusDelivered DeliveryStatus = "delivered"
DeliveryStatusFailed DeliveryStatus = "failed"
DeliveryStatusRetrying DeliveryStatus = "retrying"
)
// Delivery represents a delivery attempt for an event to a target
type Delivery struct {
BaseModel
EventID string `gorm:"type:uuid;not null" json:"event_id"`
TargetID string `gorm:"type:uuid;not null" json:"target_id"`
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
// Relations
Event Event `json:"event,omitempty"`
Target Target `json:"target,omitempty"`
DeliveryResults []DeliveryResult `json:"delivery_results,omitempty"`
}

View File

@ -0,0 +1,17 @@
package database
// DeliveryResult represents the result of a delivery attempt
type DeliveryResult struct {
BaseModel
DeliveryID string `gorm:"type:uuid;not null" json:"delivery_id"`
AttemptNum int `gorm:"not null" json:"attempt_num"`
Success bool `json:"success"`
StatusCode int `json:"status_code,omitempty"`
ResponseBody string `gorm:"type:text" json:"response_body,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"` // Duration in milliseconds
// Relations
Delivery Delivery `json:"delivery,omitempty"`
}

View File

@ -0,0 +1,20 @@
package database
// Event represents a webhook event
type Event struct {
BaseModel
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
// Request data
Method string `gorm:"not null" json:"method"`
Headers string `gorm:"type:text" json:"headers"` // JSON
Body string `gorm:"type:text" json:"body"`
ContentType string `json:"content_type"`
// Relations
Processor Processor `json:"processor,omitempty"`
Webhook Webhook `json:"webhook,omitempty"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}

View File

@ -0,0 +1,16 @@
package database
// Processor represents an event processor
type Processor struct {
BaseModel
UserID string `gorm:"type:uuid;not null" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events
// Relations
User User `json:"user,omitempty"`
Webhooks []Webhook `json:"webhooks,omitempty"`
Targets []Target `json:"targets,omitempty"`
}

View File

@ -0,0 +1,32 @@
package database
// TargetType represents the type of delivery target
type TargetType string
const (
TargetTypeHTTP TargetType = "http"
TargetTypeRetry TargetType = "retry"
TargetTypeDatabase TargetType = "database"
TargetTypeLog TargetType = "log"
)
// Target represents a delivery target for a processor
type Target struct {
BaseModel
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
Name string `gorm:"not null" json:"name"`
Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"`
// Configuration fields (JSON stored based on type)
Config string `gorm:"type:text" json:"config"` // JSON configuration
// For retry targets
MaxRetries int `json:"max_retries,omitempty"`
MaxQueueSize int `json:"max_queue_size,omitempty"`
// Relations
Processor Processor `json:"processor,omitempty"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}

View File

@ -0,0 +1,13 @@
package database
// User represents a user of the webhooker service
type User struct {
BaseModel
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations
Processors []Processor `json:"processors,omitempty"`
APIKeys []APIKey `json:"api_keys,omitempty"`
}

View File

@ -0,0 +1,14 @@
package database
// Webhook represents a webhook endpoint that feeds into a processor
type Webhook struct {
BaseModel
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"`
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this webhook
Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"`
// Relations
Processor Processor `json:"processor,omitempty"`
}

View File

@ -0,0 +1,15 @@
package database
// Migrate runs database migrations for all models
func (d *Database) Migrate() error {
return d.db.AutoMigrate(
&User{},
&APIKey{},
&Processor{},
&Webhook{},
&Target{},
&Event{},
&Delivery{},
&DeliveryResult{},
)
}

View File

@ -0,0 +1,187 @@
package database
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"math/big"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2 parameters - these are up-to-date secure defaults
const (
argon2Time = 1
argon2Memory = 64 * 1024 // 64 MB
argon2Threads = 4
argon2KeyLen = 32
argon2SaltLen = 16
)
// PasswordConfig holds Argon2 configuration
type PasswordConfig struct {
Time uint32
Memory uint32
Threads uint8
KeyLen uint32
SaltLen uint32
}
// DefaultPasswordConfig returns secure default Argon2 parameters
func DefaultPasswordConfig() *PasswordConfig {
return &PasswordConfig{
Time: argon2Time,
Memory: argon2Memory,
Threads: argon2Threads,
KeyLen: argon2KeyLen,
SaltLen: argon2SaltLen,
}
}
// HashPassword generates an Argon2id hash of the password
func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig()
// Generate a salt
salt := make([]byte, config.SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
// Generate the hash
hash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
// Encode the hash and parameters
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
// Format: $argon2id$v=19$m=65536,t=1,p=4$salt$hash
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, config.Memory, config.Time, config.Threads, b64Salt, b64Hash)
return encoded, nil
}
// VerifyPassword checks if the provided password matches the hash
func VerifyPassword(password, encodedHash string) (bool, error) {
// Extract parameters and hash from encoded string
config, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
// Generate hash of the provided password
otherHash := argon2.IDKey([]byte(password), salt, config.Time, config.Memory, config.Threads, config.KeyLen)
// Compare hashes using constant time comparison
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
}
// decodeHash extracts parameters, salt, and hash from an encoded hash string
func decodeHash(encodedHash string) (*PasswordConfig, []byte, []byte, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, fmt.Errorf("invalid hash format")
}
if parts[1] != "argon2id" {
return nil, nil, nil, fmt.Errorf("invalid algorithm")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, fmt.Errorf("incompatible argon2 version")
}
config := &PasswordConfig{}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &config.Memory, &config.Time, &config.Threads); err != nil {
return nil, nil, nil, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return nil, nil, nil, err
}
saltLen := len(salt)
if saltLen < 0 || saltLen > int(^uint32(0)) {
return nil, nil, nil, fmt.Errorf("salt length out of range")
}
config.SaltLen = uint32(saltLen) // nolint:gosec // checked above
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return nil, nil, nil, err
}
hashLen := len(hash)
if hashLen < 0 || hashLen > int(^uint32(0)) {
return nil, nil, nil, fmt.Errorf("hash length out of range")
}
config.KeyLen = uint32(hashLen) // nolint:gosec // checked above
return config, salt, hash, nil
}
// GenerateRandomPassword generates a cryptographically secure random password
func GenerateRandomPassword(length int) (string, error) {
const (
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lowercase = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
)
// Combine all character sets
allChars := uppercase + lowercase + digits + special
// Create password slice
password := make([]byte, length)
// Ensure at least one character from each set for password complexity
if length >= 4 {
// Get one character from each set
password[0] = uppercase[cryptoRandInt(len(uppercase))]
password[1] = lowercase[cryptoRandInt(len(lowercase))]
password[2] = digits[cryptoRandInt(len(digits))]
password[3] = special[cryptoRandInt(len(special))]
// Fill the rest randomly from all characters
for i := 4; i < length; i++ {
password[i] = allChars[cryptoRandInt(len(allChars))]
}
// Shuffle the password to avoid predictable pattern
for i := len(password) - 1; i > 0; i-- {
j := cryptoRandInt(i + 1)
password[i], password[j] = password[j], password[i]
}
} else {
// For very short passwords, just use all characters
for i := 0; i < length; i++ {
password[i] = allChars[cryptoRandInt(len(allChars))]
}
}
return string(password), nil
}
// cryptoRandInt generates a cryptographically secure random integer in [0, max)
func cryptoRandInt(max int) int {
if max <= 0 {
panic("max must be positive")
}
// Calculate the maximum valid value to avoid modulo bias
// For example, if max=200 and we have 256 possible values,
// we only accept values 0-199 (reject 200-255)
nBig, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
if err != nil {
panic(fmt.Sprintf("crypto/rand error: %v", err))
}
return int(nBig.Int64())
}

View File

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

View File

@ -0,0 +1,28 @@
package globals
import (
"go.uber.org/fx"
)
// these get populated from main() and copied into the Globals object.
var (
Appname string
Version string
Buildarch string
)
type Globals struct {
Appname string
Version string
Buildarch string
}
// nolint:revive // lc parameter is required by fx even if unused
func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{
Appname: Appname,
Buildarch: Buildarch,
Version: Version,
}
return n, nil
}

View File

@ -0,0 +1,30 @@
package globals
import (
"testing"
"go.uber.org/fx/fxtest"
)
func TestNew(t *testing.T) {
// Set test values
Appname = "test-app"
Version = "1.0.0"
Buildarch = "test-arch"
lc := fxtest.NewLifecycle(t)
globals, err := New(lc)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if globals.Appname != "test-app" {
t.Errorf("Appname = %v, want %v", globals.Appname, "test-app")
}
if globals.Version != "1.0.0" {
t.Errorf("Version = %v, want %v", globals.Version, "1.0.0")
}
if globals.Buildarch != "test-arch" {
t.Errorf("Buildarch = %v, want %v", globals.Buildarch, "test-arch")
}
}

127
internal/handlers/auth.go Normal file
View File

@ -0,0 +1,127 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/webhooker/internal/database"
)
// HandleLoginPage returns a handler for the login page (GET)
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if already logged in
sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Render login page
data := map[string]interface{}{
"Error": "",
}
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
}
}
// HandleLoginSubmit handles the login form submission (POST)
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse form data
if err := r.ParseForm(); err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
// Validate input
if username == "" || password == "" {
data := map[string]interface{}{
"Error": "Username and password are required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
return
}
// Find user in database
var user database.User
if err := h.db.DB().Where("username = ?", username).First(&user).Error; err != nil {
h.log.Debug("user not found", "username", username)
data := map[string]interface{}{
"Error": "Invalid username or password",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
return
}
// Verify password
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !valid {
h.log.Debug("invalid password", "username", username)
data := map[string]interface{}{
"Error": "Invalid username or password",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
return
}
// Create session
sess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Set user in session
h.session.SetUser(sess, user.ID, user.Username)
// Save session
if err := h.session.Save(r, w, sess); err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.log.Info("user logged in", "username", username, "user_id", user.ID)
// Redirect to home page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// HandleLogout handles user logout
func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
// Destroy session
h.session.Destroy(sess)
// Save the destroyed session
if err := h.session.Save(r, w, sess); err != nil {
h.log.Error("failed to save destroyed session", "error", err)
}
// Redirect to login page
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
}
}

View File

@ -0,0 +1,137 @@
package handlers
import (
"context"
"encoding/json"
"html/template"
"log/slog"
"net/http"
"git.eeqj.de/sneak/webhooker/internal/database"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/healthcheck"
"git.eeqj.de/sneak/webhooker/internal/logger"
"git.eeqj.de/sneak/webhooker/internal/session"
"go.uber.org/fx"
)
// nolint:revive // HandlersParams is a standard fx naming convention
type HandlersParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Database *database.Database
Healthcheck *healthcheck.Healthcheck
Session *session.Session
}
type Handlers struct {
params *HandlersParams
log *slog.Logger
hc *healthcheck.Healthcheck
db *database.Database
session *session.Session
}
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s := new(Handlers)
s.params = &params
s.log = params.Logger.Get()
s.hc = params.Healthcheck
s.db = params.Database
s.session = params.Session
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
// FIXME compile some templates here or something
return nil
},
})
return s, nil
}
//nolint:unparam // r parameter will be used in the future for request context
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
err := json.NewEncoder(w).Encode(data)
if err != nil {
s.log.Error("json encode error", "error", err)
}
}
}
//nolint:unparam,unused // will be used for handling JSON requests
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}
// TemplateData represents the common data passed to templates
type TemplateData struct {
User *UserInfo
Version string
UserCount int64
Uptime string
}
// UserInfo represents user information for templates
type UserInfo struct {
ID string
Username string
}
// renderTemplate renders a template with common data
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) {
// Always include the common templates
allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"}
allTemplates = append(allTemplates, templateFiles...)
// Parse templates
tmpl, err := template.ParseFiles(allTemplates...)
if err != nil {
s.log.Error("failed to parse template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get user from session if available
var userInfo *UserInfo
sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) {
if username, ok := s.session.GetUsername(sess); ok {
if userID, ok := s.session.GetUserID(sess); ok {
userInfo = &UserInfo{
ID: userID,
Username: username,
}
}
}
}
// Wrap data with base template data
type templateDataWrapper struct {
User *UserInfo
Data interface{}
}
wrapper := templateDataWrapper{
User: userInfo,
Data: data,
}
// If data is a map, merge user info into it
if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo
if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Otherwise use wrapper
if err := tmpl.Execute(w, wrapper); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

View File

@ -0,0 +1,130 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/database"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/healthcheck"
"git.eeqj.de/sneak/webhooker/internal/logger"
"git.eeqj.de/sneak/webhooker/internal/session"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
func TestHandleIndex(t *testing.T) {
var h *Handlers
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{
// This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!"
SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
}
},
func() *database.Database {
// Mock database with a mock DB method
db := &database.Database{}
return db
},
healthcheck.New,
session.New,
New,
),
fx.Populate(&h),
)
app.RequireStart()
defer app.RequireStop()
// Since we can't test actual template rendering without templates,
// let's test that the handler is created and doesn't panic
handler := h.HandleIndex()
assert.NotNil(t, handler)
}
func TestRenderTemplate(t *testing.T) {
var h *Handlers
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{
// This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!"
SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
}
},
func() *database.Database {
// Mock database
return &database.Database{}
},
healthcheck.New,
session.New,
New,
),
fx.Populate(&h),
)
app.RequireStart()
defer app.RequireStop()
t.Run("handles missing templates gracefully", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
data := map[string]interface{}{
"Version": "1.0.0",
}
// When templates don't exist, renderTemplate should return an error
h.renderTemplate(w, req, []string{"nonexistent.html"}, data)
// Should return internal server error when template parsing fails
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
}
func TestFormatUptime(t *testing.T) {
tests := []struct {
name string
duration string
expected string
}{
{
name: "minutes only",
duration: "45m",
expected: "45m",
},
{
name: "hours and minutes",
duration: "2h30m",
expected: "2h 30m",
},
{
name: "days, hours and minutes",
duration: "25h45m",
expected: "1d 1h 45m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, err := time.ParseDuration(tt.duration)
require.NoError(t, err)
result := formatUptime(d)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@ -0,0 +1,12 @@
package handlers
import (
"net/http"
)
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
resp := s.hc.Healthcheck()
s.respondJSON(w, req, resp, 200)
}
}

View File

@ -0,0 +1,54 @@
package handlers
import (
"fmt"
"net/http"
"time"
"git.eeqj.de/sneak/webhooker/internal/database"
)
type IndexResponse struct {
Message string `json:"message"`
Version string `json:"version"`
}
func (s *Handlers) HandleIndex() http.HandlerFunc {
// Calculate server start time
startTime := time.Now()
return func(w http.ResponseWriter, req *http.Request) {
// Calculate uptime
uptime := time.Since(startTime)
uptimeStr := formatUptime(uptime)
// Get user count from database
var userCount int64
s.db.DB().Model(&database.User{}).Count(&userCount)
// Prepare template data
data := map[string]interface{}{
"Version": s.params.Globals.Version,
"Uptime": uptimeStr,
"UserCount": userCount,
}
// Render the template
s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data)
}
}
// formatUptime formats a duration into a human-readable string
func formatUptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}

View File

@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
)
// HandleProfile returns a handler for the user profile page
func (h *Handlers) HandleProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get username from URL
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
http.NotFound(w, r)
return
}
// Get session
sess, err := h.session.Get(r)
if err != nil || !h.session.IsAuthenticated(sess) {
// Redirect to login if not authenticated
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
// Get user info from session
sessionUsername, ok := h.session.GetUsername(sess)
if !ok {
h.log.Error("authenticated session missing username")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
sessionUserID, ok := h.session.GetUserID(sess)
if !ok {
h.log.Error("authenticated session missing user ID")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// For now, only allow users to view their own profile
if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Prepare data for template
data := map[string]interface{}{
"User": &UserInfo{
ID: sessionUserID,
Username: sessionUsername,
},
}
// Render the profile page
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data)
}
}

View File

@ -0,0 +1,69 @@
package handlers
import (
"net/http"
)
// HandleSourceList shows a list of user's webhook sources
func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source list page
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceCreate shows the form to create a new webhook source
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source creation form
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceCreateSubmit handles the source creation form submission
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source creation logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceDetail shows details for a specific webhook source
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source detail page
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceEdit shows the form to edit a webhook source
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source edit form
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceEditSubmit handles the source edit form submission
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source update logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceDelete handles webhook source deletion
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source deletion logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceLogs shows the request/response logs for a webhook source
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source logs page
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}

View File

@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
)
// HandleWebhook handles incoming webhook requests
func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get webhook UUID from URL
webhookUUID := chi.URLParam(r, "uuid")
if webhookUUID == "" {
http.NotFound(w, r)
return
}
// Log the incoming webhook request
h.log.Info("webhook request received",
"uuid", webhookUUID,
"method", r.Method,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
// Only POST methods are allowed for webhooks
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Implement webhook handling logic
// For now, return "unimplemented" for all webhook POST requests
w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte("unimplemented"))
if err != nil {
h.log.Error("failed to write response", "error", err)
}
}
}

View File

@ -0,0 +1,73 @@
package healthcheck
import (
"context"
"log/slog"
"time"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/database"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
"go.uber.org/fx"
)
// nolint:revive // HealthcheckParams is a standard fx naming convention
type HealthcheckParams struct {
fx.In
Globals *globals.Globals
Config *config.Config
Logger *logger.Logger
Database *database.Database
}
type Healthcheck struct {
StartupTime time.Time
log *slog.Logger
params *HealthcheckParams
}
func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) {
s := new(Healthcheck)
s.params = &params
s.log = params.Logger.Get()
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { // nolint:revive // ctx unused but required by fx
s.StartupTime = time.Now()
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return s, nil
}
// nolint:revive // HealthcheckResponse is a clear, descriptive name
type HealthcheckResponse struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptime_seconds"`
UptimeHuman string `json:"uptime_human"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenance_mode"`
}
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
resp := &HealthcheckResponse{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(s.uptime().Seconds()),
UptimeHuman: s.uptime().String(),
Appname: s.params.Globals.Appname,
Version: s.params.Globals.Version,
Maintenance: s.params.Config.MaintenanceMode,
}
return resp
}

112
internal/logger/logger.go Normal file
View File

@ -0,0 +1,112 @@
package logger
import (
"io"
"log/slog"
"os"
"time"
"git.eeqj.de/sneak/webhooker/internal/globals"
"go.uber.org/fx"
)
// nolint:revive // LoggerParams is a standard fx naming convention
type LoggerParams struct {
fx.In
Globals *globals.Globals
}
type Logger struct {
logger *slog.Logger
params LoggerParams
}
// nolint:revive // lc parameter is required by fx even if unused
func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
l := new(Logger)
l.params = params
// Determine if we're running in a terminal
tty := false
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true
}
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused
// Always use UTC for timestamps
if a.Key == slog.TimeKey {
if t, ok := a.Value.Any().(time.Time); ok {
return slog.Time(slog.TimeKey, t.UTC())
}
}
return a
},
}
if tty {
// Use text handler for terminal output (human-readable)
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
// Use JSON handler for production (machine-readable)
handler = slog.NewJSONHandler(os.Stdout, opts)
}
l.logger = slog.New(handler)
// Set as default logger
slog.SetDefault(l.logger)
return l, nil
}
func (l *Logger) EnableDebugLogging() {
// Recreate logger with debug level
tty := false
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true
}
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused
// Always use UTC for timestamps
if a.Key == slog.TimeKey {
if t, ok := a.Value.Any().(time.Time); ok {
return slog.Time(slog.TimeKey, t.UTC())
}
}
return a
},
}
if tty {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
l.logger = slog.New(handler)
slog.SetDefault(l.logger)
l.logger.Debug("debug logging enabled", "debug", true)
}
func (l *Logger) Get() *slog.Logger {
return l.logger
}
func (l *Logger) Identify() {
l.logger.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch,
)
}
// Helper methods to maintain compatibility with existing code
func (l *Logger) Writer() io.Writer {
return os.Stdout
}

View File

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

View File

@ -0,0 +1,149 @@
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/logger"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware"
"github.com/slok/go-http-metrics/middleware/std"
"go.uber.org/fx"
)
// nolint:revive // MiddlewareParams is a standard fx naming convention
type MiddlewareParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
}
type Middleware struct {
log *slog.Logger
params *MiddlewareParams
}
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
s := new(Middleware)
s.params = &params
s.log = params.Logger.Get()
return s, nil
}
// the following is from
// https://learning-cloud-native-go.github.io/docs/a6.adding_zerolog_logger/
func ipFromHostPort(hp string) string {
h, _, err := net.SplitHostPort(hp)
if err != nil {
return ""
}
if len(h) > 0 && h[0] == '[' {
return h[1 : len(h)-1]
}
return h
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
// nolint:revive // unexported type is only used internally
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
return &loggingResponseWriter{w, http.StatusOK}
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
// type Middleware func(http.Handler) http.Handler
// this returns a Middleware that is designed to do every request through the
// mux, note the signature:
func (s *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := NewLoggingResponseWriter(w)
ctx := r.Context()
defer func() {
latency := time.Since(start)
requestID := ""
if reqID := ctx.Value(middleware.RequestIDKey); reqID != nil {
if id, ok := reqID.(string); ok {
requestID = id
}
}
s.log.Info("http request",
"request_start", start,
"method", r.Method,
"url", r.URL.String(),
"useragent", r.UserAgent(),
"request_id", requestID,
"referer", r.Referer(),
"proto", r.Proto,
"remoteIP", ipFromHostPort(r.RemoteAddr),
"status", lrw.statusCode,
"latency_ms", latency.Milliseconds(),
)
}()
next.ServeHTTP(lrw, r)
})
}
}
func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
// CHANGEME! these are defaults, change them to suit your needs or
// read from environment/viper.
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
AllowedOrigins: []string{"*"},
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300, // Maximum value not ignored by any of major browsers
})
}
func (s *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: implement proper authentication
s.log.Debug("AUTH: before request")
next.ServeHTTP(w, r)
})
}
}
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
mdlw := ghmm.New(ghmm.Config{
Recorder: metrics.NewRecorder(metrics.Config{}),
})
return func(next http.Handler) http.Handler {
return std.Handler("", mdlw, next)
}
}
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
return basicauth.New(
"metrics",
map[string][]string{
s.params.Config.MetricsUsername: {
s.params.Config.MetricsPassword,
},
},
)
}

34
internal/server/http.go Normal file
View File

@ -0,0 +1,34 @@
package server
import (
"fmt"
"net/http"
"time"
)
func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{
Addr: listenAddr,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
Handler: s,
}
// add routes
// this does any necessary setup in each handler
s.SetupRoutes()
s.log.Info("http begin listen", "listenaddr", listenAddr)
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.log.Error("listen error", "error", err)
if s.cancelFunc != nil {
s.cancelFunc()
}
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}

112
internal/server/routes.go Normal file
View File

@ -0,0 +1,112 @@
package server
import (
"net/http"
"time"
"git.eeqj.de/sneak/webhooker/static"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
// the mux .Use() takes a http.Handler wrapper func, like most
// things that deal with "middlewares" like alice et c, and will
// call ServeHTTP on it. These middlewares applied by the mux (you
// can .Use() more than one) will be applied to every request into
// the service.
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging())
// add metrics middleware only if we can serve them behind auth
if s.params.Config.MetricsUsername != "" {
s.router.Use(s.mw.Metrics())
}
// set up CORS headers
s.router.Use(s.mw.CORS())
// timeout for request context; your handlers must finish within
// this window:
s.router.Use(middleware.Timeout(60 * time.Second))
// this adds a sentry reporting middleware if and only if sentry is
// enabled via setting of SENTRY_DSN in env.
if s.sentryEnabled {
// Options docs at
// https://docs.sentry.io/platforms/go/guides/http/
// we set sentry to repanic so that all panics bubble up to the
// Recoverer chi middleware above.
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
s.router.Use(sentryHandler.Handle)
}
////////////////////////////////////////////////////////////////////////
// ROUTES
// complete docs: https://github.com/go-chi/chi
////////////////////////////////////////////////////////////////////////
s.router.Get("/", s.h.HandleIndex())
s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static))))
s.router.Route("/api/v1", func(_ chi.Router) {
// TODO: Add API routes here
})
s.router.Get(
"/.well-known/healthcheck.json",
s.h.HandleHealthCheck(),
)
// set up authenticated /metrics route:
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP))
})
}
// pages that are rendered server-side
s.router.Route("/pages", func(r chi.Router) {
// Login page (no auth required)
r.Get("/login", s.h.HandleLoginPage())
r.Post("/login", s.h.HandleLoginSubmit())
// Logout (auth required)
r.Post("/logout", s.h.HandleLogout())
})
// User profile routes
s.router.Route("/user/{username}", func(r chi.Router) {
r.Get("/", s.h.HandleProfile())
})
// Webhook source management routes (require authentication)
s.router.Route("/sources", func(r chi.Router) {
// TODO: Add authentication middleware here
r.Get("/", s.h.HandleSourceList()) // List all sources
r.Get("/new", s.h.HandleSourceCreate()) // Show create form
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
})
s.router.Route("/source/{sourceID}", func(r chi.Router) {
// TODO: Add authentication middleware here
r.Get("/", s.h.HandleSourceDetail()) // View source details
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
r.Post("/delete", s.h.HandleSourceDelete()) // Delete source
r.Get("/logs", s.h.HandleSourceLogs()) // View source logs
})
// Webhook endpoint - accepts all HTTP methods
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
}

162
internal/server/server.go Normal file
View File

@ -0,0 +1,162 @@
package server
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/handlers"
"git.eeqj.de/sneak/webhooker/internal/logger"
"git.eeqj.de/sneak/webhooker/internal/middleware"
"go.uber.org/fx"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
// spooky action at a distance!
// this populates the environment
// from a ./.env file automatically
// for development configuration.
// .env contents should be things like
// `DBURL=postgres://user:pass@.../`
// (without the backticks, of course)
_ "github.com/joho/godotenv/autoload"
)
// ServerParams is a standard fx naming convention for dependency injection
// nolint:golint
type ServerParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
}
type Server struct {
startupTime time.Time
exitCode int
sentryEnabled bool
log *slog.Logger
ctx context.Context
cancelFunc context.CancelFunc
httpServer *http.Server
router *chi.Mux
params ServerParams
mw *middleware.Middleware
h *handlers.Handlers
}
func New(lc fx.Lifecycle, params ServerParams) (*Server, error) {
s := new(Server)
s.params = params
s.mw = params.Middleware
s.h = params.Handlers
s.log = params.Logger.Get()
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s.startupTime = time.Now()
go s.Run()
return nil
},
OnStop: func(ctx context.Context) error {
s.cleanShutdown()
return nil
},
})
return s, nil
}
func (s *Server) Run() {
s.configure()
// logging before sentry, because sentry logs
s.enableSentry()
s.serve()
}
func (s *Server) enableSentry() {
s.sentryEnabled = false
if s.params.Config.SentryDSN == "" {
return
}
err := sentry.Init(sentry.ClientOptions{
Dsn: s.params.Config.SentryDSN,
Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version),
})
if err != nil {
s.log.Error("sentry init failure", "error", err)
// Don't use fatal since we still want the service to run
return
}
s.log.Info("sentry error reporting activated")
s.sentryEnabled = true
}
func (s *Server) serve() int {
s.ctx, s.cancelFunc = context.WithCancel(context.Background())
// signal watcher
go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
// block and wait for signal
sig := <-c
s.log.Info("signal received", "signal", sig.String())
if s.cancelFunc != nil {
// cancelling the main context will trigger a clean
// shutdown.
s.cancelFunc()
}
}()
go s.serveUntilShutdown()
<-s.ctx.Done()
s.cleanShutdown()
return s.exitCode
}
func (s *Server) cleanupForExit() {
s.log.Info("cleaning up")
// TODO: close database connections, flush buffers, etc.
}
func (s *Server) cleanShutdown() {
// initiate clean shutdown
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := s.httpServer.Shutdown(ctxShutdown); err != nil {
s.log.Error("server clean shutdown failed", "error", err)
}
s.cleanupForExit()
if s.sentryEnabled {
sentry.Flush(2 * time.Second)
}
}
func (s *Server) MaintenanceMode() bool {
return s.params.Config.MaintenanceMode
}
func (s *Server) configure() {
// identify ourselves in the logs
s.params.Logger.Identify()
}

125
internal/session/session.go Normal file
View File

@ -0,0 +1,125 @@
package session
import (
"encoding/base64"
"fmt"
"log/slog"
"net/http"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/logger"
"github.com/gorilla/sessions"
"go.uber.org/fx"
)
const (
// SessionName is the name of the session cookie
SessionName = "webhooker_session"
// UserIDKey is the session key for user ID
UserIDKey = "user_id"
// UsernameKey is the session key for username
UsernameKey = "username"
// AuthenticatedKey is the session key for authentication status
AuthenticatedKey = "authenticated"
)
// nolint:revive // SessionParams is a standard fx naming convention
type SessionParams struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
// Session manages encrypted session storage
type Session struct {
store *sessions.CookieStore
log *slog.Logger
config *config.Config
}
// New creates a new session manager
func New(lc fx.Lifecycle, params SessionParams) (*Session, error) {
if params.Config.SessionKey == "" {
return nil, fmt.Errorf("SESSION_KEY environment variable is required")
}
// 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{
store: store,
log: params.Logger.Get(),
config: params.Config,
}
return s, nil
}
// Get retrieves a session for the request
func (s *Session) Get(r *http.Request) (*sessions.Session, error) {
return s.store.Get(r, SessionName)
}
// Save saves the session
func (s *Session) Save(r *http.Request, w http.ResponseWriter, sess *sessions.Session) error {
return sess.Save(r, w)
}
// SetUser sets the user information in the session
func (s *Session) SetUser(sess *sessions.Session, userID, username string) {
sess.Values[UserIDKey] = userID
sess.Values[UsernameKey] = username
sess.Values[AuthenticatedKey] = true
}
// ClearUser removes user information from the session
func (s *Session) ClearUser(sess *sessions.Session) {
delete(sess.Values, UserIDKey)
delete(sess.Values, UsernameKey)
delete(sess.Values, AuthenticatedKey)
}
// IsAuthenticated checks if the session has an authenticated user
func (s *Session) IsAuthenticated(sess *sessions.Session) bool {
auth, ok := sess.Values[AuthenticatedKey].(bool)
return ok && auth
}
// GetUserID retrieves the user ID from the session
func (s *Session) GetUserID(sess *sessions.Session) (string, bool) {
userID, ok := sess.Values[UserIDKey].(string)
return userID, ok
}
// GetUsername retrieves the username from the session
func (s *Session) GetUsername(sess *sessions.Session) (string, bool) {
username, ok := sess.Values[UsernameKey].(string)
return username, ok
}
// Destroy invalidates the session
func (s *Session) Destroy(sess *sessions.Session) {
sess.Options.MaxAge = -1
s.ClearUser(sess)
}

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 "git.eeqj.de/sneak/webhooker/pkg/config"
//
// // Set the environment explicitly
// config.SetEnvironment("prod")
//
// // Get configuration values
// baseURL := config.Get("baseURL")
// apiTimeout := config.GetInt("timeout", 30)
//
// // Get secret values
// apiKey := config.GetSecret("api_key")
// dbPassword := config.GetSecret("db_password", "default")
package config
import (
"sync"
"github.com/spf13/afero"
)
// Global configuration manager instance
var (
globalManager *Manager
mu sync.Mutex // Protect global manager updates
)
// getManager returns the global configuration manager, creating it if necessary
func getManager() *Manager {
mu.Lock()
defer mu.Unlock()
if globalManager == nil {
globalManager = NewManager()
}
return globalManager
}
// SetEnvironment sets the active environment.
func SetEnvironment(environment string) {
getManager().SetEnvironment(environment)
}
// SetFs sets the filesystem to use for all file operations.
// This is primarily useful for testing with an in-memory filesystem.
func SetFs(fs afero.Fs) {
mu.Lock()
defer mu.Unlock()
// Create a new manager with the specified filesystem
newManager := NewManager()
newManager.SetFs(fs)
// Replace the global manager
globalManager = newManager
}
// Get retrieves a configuration value.
//
// This looks for values in the following order:
// 1. Environment-specific config (environments.<env>.config.<key>)
// 2. Config defaults (configDefaults.<key>)
//
// Values are resolved if they contain special prefixes:
// - $ENV:VARIABLE_NAME - reads from environment variable
// - $GSM:secret-name - reads from Google Secret Manager
// - $ASM:secret-name - reads from AWS Secrets Manager
// - $FILE:/path/to/file - reads from file
func Get(key string, defaultValue ...interface{}) interface{} {
var def interface{}
if len(defaultValue) > 0 {
def = defaultValue[0]
}
return getManager().Get(key, def)
}
// GetString retrieves a configuration value as a string.
func GetString(key string, defaultValue ...string) string {
var def string
if len(defaultValue) > 0 {
def = defaultValue[0]
}
val := Get(key, def)
if s, ok := val.(string); ok {
return s
}
return def
}
// GetInt retrieves a configuration value as an integer.
func GetInt(key string, defaultValue ...int) int {
var def int
if len(defaultValue) > 0 {
def = defaultValue[0]
}
val := Get(key, def)
switch v := val.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
default:
return def
}
}
// GetBool retrieves a configuration value as a boolean.
func GetBool(key string, defaultValue ...bool) bool {
var def bool
if len(defaultValue) > 0 {
def = defaultValue[0]
}
val := Get(key, def)
if b, ok := val.(bool); ok {
return b
}
return def
}
// GetSecret retrieves a secret value.
//
// This looks for secrets defined in environments.<env>.secrets.<key>
func GetSecret(key string, defaultValue ...interface{}) interface{} {
var def interface{}
if len(defaultValue) > 0 {
def = defaultValue[0]
}
return getManager().GetSecret(key, def)
}
// GetSecretString retrieves a secret value as a string.
func GetSecretString(key string, defaultValue ...string) string {
var def string
if len(defaultValue) > 0 {
def = defaultValue[0]
}
val := GetSecret(key, def)
if s, ok := val.(string); ok {
return s
}
return def
}
// Reload reloads the configuration from file.
func Reload() error {
return getManager().Reload()
}
// GetAllConfig returns all configuration values for the current environment.
func GetAllConfig() map[string]interface{} {
return getManager().GetAllConfig()
}
// GetAllSecrets returns all secrets for the current environment.
func GetAllSecrets() map[string]interface{} {
return getManager().GetAllSecrets()
}
// LoadFile loads configuration from a specific file.
func LoadFile(configFile string) error {
return getManager().LoadFile(configFile)
}

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"
"git.eeqj.de/sneak/webhooker/pkg/config"
"github.com/spf13/afero"
)
// ExampleSetFs demonstrates how to use an in-memory filesystem for testing
func ExampleSetFs() {
// Create an in-memory filesystem
fs := afero.NewMemMapFs()
// Create a test configuration file
configYAML := `
environments:
test:
config:
baseURL: https://test.example.com
debugMode: true
secrets:
apiKey: test-key-12345
production:
config:
baseURL: https://api.example.com
debugMode: false
configDefaults:
appName: Test Application
timeout: 30
`
// Write the config to the in-memory filesystem
if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil {
panic(err)
}
// Use the in-memory filesystem
config.SetFs(fs)
config.SetEnvironment("test")
// Now all config operations use the in-memory filesystem
fmt.Printf("Base URL: %s\n", config.GetString("baseURL"))
fmt.Printf("Debug Mode: %v\n", config.GetBool("debugMode"))
fmt.Printf("App Name: %s\n", config.GetString("appName"))
// Output:
// Base URL: https://test.example.com
// Debug Mode: true
// App Name: Test Application
}
// TestWithAferoFilesystem shows how to test with different filesystem implementations
func TestWithAferoFilesystem(t *testing.T) {
tests := []struct {
name string
setupFs func() afero.Fs
environment string
key string
expected string
}{
{
name: "in-memory filesystem",
setupFs: func() afero.Fs {
fs := afero.NewMemMapFs()
config := `
environments:
dev:
config:
apiURL: http://localhost:8080
`
afero.WriteFile(fs, "config.yaml", []byte(config), 0644)
return fs
},
environment: "dev",
key: "apiURL",
expected: "http://localhost:8080",
},
{
name: "readonly filesystem",
setupFs: func() afero.Fs {
memFs := afero.NewMemMapFs()
config := `
environments:
staging:
config:
apiURL: https://staging.example.com
`
afero.WriteFile(memFs, "config.yaml", []byte(config), 0644)
// Wrap in a read-only filesystem
return afero.NewReadOnlyFs(memFs)
},
environment: "staging",
key: "apiURL",
expected: "https://staging.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a new manager for each test to ensure isolation
manager := config.NewManager()
manager.SetFs(tt.setupFs())
manager.SetEnvironment(tt.environment)
result := manager.Get(tt.key, "")
if result != tt.expected {
t.Errorf("Expected %s, got %v", tt.expected, result)
}
})
}
}
// TestFileResolution shows how $FILE: resolution works with afero
func TestFileResolution(t *testing.T) {
// Create an in-memory filesystem
fs := afero.NewMemMapFs()
// Create a secret file
secretContent := "super-secret-api-key"
if err := afero.WriteFile(fs, "/secrets/api-key.txt", []byte(secretContent), 0600); err != nil {
t.Fatal(err)
}
// Create a config that references the file
configYAML := `
environments:
prod:
secrets:
apiKey: $FILE:/secrets/api-key.txt
`
if err := afero.WriteFile(fs, "config.yaml", []byte(configYAML), 0644); err != nil {
t.Fatal(err)
}
// Use the filesystem
config.SetFs(fs)
config.SetEnvironment("prod")
// Get the secret - it should resolve from the file
apiKey := config.GetSecretString("apiKey")
if apiKey != secretContent {
t.Errorf("Expected %s, got %s", secretContent, apiKey)
}
}

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

@ -0,0 +1,139 @@
package config_test
import (
"fmt"
"log"
"os"
"git.eeqj.de/sneak/webhooker/pkg/config"
)
func Example() {
// Set the environment explicitly
config.SetEnvironment("dev")
// Get configuration values
baseURL := config.GetString("baseURL")
timeout := config.GetInt("timeout", 30)
debugMode := config.GetBool("debugMode", false)
fmt.Printf("Base URL: %s\n", baseURL)
fmt.Printf("Timeout: %d\n", timeout)
fmt.Printf("Debug Mode: %v\n", debugMode)
// Get secret values
apiKey := config.GetSecretString("api_key")
if apiKey != "" {
fmt.Printf("API Key: %s...\n", apiKey[:8])
}
}
func ExampleSetEnvironment() {
// Your application determines which environment to use
// This could come from command line args, env vars, etc.
environment := os.Getenv("APP_ENV")
if environment == "" {
environment = "development"
}
// Set the environment explicitly
config.SetEnvironment(environment)
// Now use configuration throughout your application
fmt.Printf("Environment: %s\n", environment)
fmt.Printf("App Name: %s\n", config.GetString("app_name"))
}
func ExampleGetString() {
config.SetEnvironment("prod")
// Get a string configuration value with a default
baseURL := config.GetString("baseURL", "http://localhost:8080")
fmt.Printf("Base URL: %s\n", baseURL)
}
func ExampleGetInt() {
config.SetEnvironment("prod")
// Get an integer configuration value with a default
port := config.GetInt("port", 8080)
fmt.Printf("Port: %d\n", port)
}
func ExampleGetBool() {
config.SetEnvironment("dev")
// Get a boolean configuration value with a default
debugMode := config.GetBool("debugMode", false)
fmt.Printf("Debug Mode: %v\n", debugMode)
}
func ExampleGetSecretString() {
config.SetEnvironment("prod")
// Get a secret string value
apiKey := config.GetSecretString("api_key")
if apiKey != "" {
// Be careful not to log the full secret!
fmt.Printf("API Key configured: yes\n")
}
}
func ExampleLoadFile() {
// Load configuration from a specific file
if err := config.LoadFile("/path/to/config.yaml"); err != nil {
log.Printf("Failed to load config: %v", err)
return
}
config.SetEnvironment("staging")
fmt.Printf("Loaded configuration from custom file\n")
}
func ExampleReload() {
config.SetEnvironment("dev")
// Get initial value
oldValue := config.GetString("some_key")
// ... config file might have been updated ...
// Reload configuration from file
if err := config.Reload(); err != nil {
log.Printf("Failed to reload config: %v", err)
return
}
// Get potentially updated value
newValue := config.GetString("some_key")
fmt.Printf("Value changed: %v\n", oldValue != newValue)
}
// Example config.yaml structure:
/*
environments:
development:
config:
baseURL: http://localhost:8000
debugMode: true
port: 8000
secrets:
api_key: dev-key-12345
production:
config:
baseURL: https://api.example.com
debugMode: false
port: 443
GCPProject: my-project-123
AWSRegion: us-west-2
secrets:
api_key: $GSM:prod-api-key
db_password: $ASM:prod/db/password
configDefaults:
app_name: My Application
timeout: 30
log_level: INFO
port: 8080
*/

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

@ -0,0 +1,41 @@
module git.eeqj.de/sneak/webhooker/pkg/config
go 1.23.0
toolchain go1.24.1
require (
github.com/aws/aws-sdk-go v1.50.0
github.com/spf13/afero v1.14.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go/compute v1.23.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.1.3 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/oauth2 v0.13.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/api v0.149.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
require (
cloud.google.com/go/secretmanager v1.11.4
github.com/jmespath/go-jmespath v0.4.0 // indirect
)

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
}

59
static/css/style.css Normal file
View File

@ -0,0 +1,59 @@
/* 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;
}
}

2
static/js/app.js Normal file
View File

@ -0,0 +1,2 @@
// Webhooker client-side JavaScript
console.log("Webhooker loaded");

8
static/static.go Normal file
View File

@ -0,0 +1,8 @@
package static
import (
"embed"
)
//go:embed css js vendor
var Static embed.FS

18
templates/base.html Normal file
View File

@ -0,0 +1,18 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
{{template "htmlheader" .}}
</head>
<body>
{{template "navbar" .}}
<!-- Main content -->
{{block "content" .}}{{end}}
<script src="/s/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/s/js/app.js"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,8 @@
{{define "htmlheader"}}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Webhooker{{end}}</title>
<link href="/s/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/s/css/style.css" rel="stylesheet">
{{block "head" .}}{{end}}
{{end}}

77
templates/index.html Normal file
View File

@ -0,0 +1,77 @@
{{template "base" .}}
{{define "title"}}Home - Webhooker{{end}}
{{define "content"}}
<div class="container mt-5">
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="text-center mb-5">
<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="row g-4">
<!-- Server Status Card -->
<div class="col-md-6">
<div class="card h-100 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
<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="M1.333 2.667C1.333 1.194 4.318 0 8 0s6.667 1.194 6.667 2.667V4c0 1.473-2.985 2.667-6.667 2.667S1.333 5.473 1.333 4V2.667z"/>
<path d="M1.333 6.334v3C1.333 10.805 4.318 12 8 12s6.667-1.194 6.667-2.667V6.334a6.51 6.51 0 0 1-1.458.79C11.81 7.684 9.967 8 8 8c-1.966 0-3.809-.317-5.208-.876a6.508 6.508 0 0 1-1.458-.79z"/>
<path d="M14.667 11.668a6.51 6.51 0 0 1-1.458.789c-1.4.56-3.242.876-5.21.876-1.966 0-3.809-.316-5.208-.876a6.51 6.51 0 0 1-1.458-.79v1.666C1.333 14.806 4.318 16 8 16s6.667-1.194 6.667-2.667v-1.665z"/>
</svg>
</div>
<div>
<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>
<!-- Users Card -->
<div class="col-md-6">
<div class="card h-100 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle bg-primary bg-opacity-10 p-3 me-3">
<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">
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
</div>
<div>
<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>
{{if not .User}}
<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>
{{end}}
</div>
</div>
</div>
{{end}}

87
templates/login.html Normal file
View File

@ -0,0 +1,87 @@
{{template "base" .}}
{{define "title"}}Login - Webhooker{{end}}
{{define "head"}}
<style>
body {
background-color: #f8f9fa;
}
.login-container {
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>
{{if .Error}}
<div class="alert alert-danger error-message" role="alert">
{{.Error}}
</div>
{{end}}
<form method="POST" action="/pages/login">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
placeholder="Enter your username" required autofocus>
</div>
<div class="mb-4">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Enter your password" required>
</div>
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
<div class="mt-4 text-center text-muted">
<small>© 2025 Webhooker. All rights reserved.</small>
</div>
</div>
</div>
</div>
{{end}}

47
templates/navbar.html Normal file
View File

@ -0,0 +1,47 @@
{{define "navbar"}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">Webhooker</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{{if .User}}
<li class="nav-item">
<a class="nav-link" href="/sources">Sources</a>
</li>
{{end}}
</ul>
<ul class="navbar-nav">
{{if .User}}
<!-- Logged in state -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<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">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<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>
{{.User.Username}}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/user/{{.User.Username}}">Profile</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="POST" action="/pages/logout" class="m-0">
<button type="submit" class="dropdown-item">Logout</button>
</form>
</li>
</ul>
</li>
{{else}}
<!-- Logged out state -->
<li class="nav-item">
<a class="nav-link" href="/pages/login">Login</a>
</li>
{{end}}
</ul>
</div>
</div>
</nav>
{{end}}

53
templates/profile.html Normal file
View File

@ -0,0 +1,53 @@
{{template "base" .}}
{{define "title"}}Profile - Webhooker{{end}}
{{define "content"}}
<div class="container mt-5">
<div class="row">
<div class="col-lg-8 mx-auto">
<h1 class="mb-4">User Profile</h1>
<div class="card shadow-sm">
<div class="card-body">
<div class="row align-items-center mb-3">
<div class="col-auto">
<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 d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<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 class="mt-4">
<a href="/" class="btn btn-secondary">Back to Home</a>
</div>
</div>
</div>
</div>
{{end}}