initial
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
46
.golangci.yml
Normal 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
58
Dockerfile
Normal 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
39
Makefile
Normal 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
315
README.md
Normal 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
102
TODO.md
Normal 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
50
config.yaml
Normal 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
101
go.mod
Normal 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
315
go.sum
Normal 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
138
internal/config/config.go
Normal 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: ¶ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
300
internal/config/config_test.go
Normal file
300
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/database/base_model.go
Normal file
25
internal/database/base_model.go
Normal 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
|
||||||
|
}
|
||||||
144
internal/database/database.go
Normal file
144
internal/database/database.go
Normal 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: ¶ms,
|
||||||
|
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
|
||||||
|
}
|
||||||
81
internal/database/database_test.go
Normal file
81
internal/database/database_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
internal/database/model_apikey.go
Normal file
16
internal/database/model_apikey.go
Normal 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"`
|
||||||
|
}
|
||||||
25
internal/database/model_delivery.go
Normal file
25
internal/database/model_delivery.go
Normal 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"`
|
||||||
|
}
|
||||||
17
internal/database/model_delivery_result.go
Normal file
17
internal/database/model_delivery_result.go
Normal 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"`
|
||||||
|
}
|
||||||
20
internal/database/model_event.go
Normal file
20
internal/database/model_event.go
Normal 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"`
|
||||||
|
}
|
||||||
16
internal/database/model_processor.go
Normal file
16
internal/database/model_processor.go
Normal 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"`
|
||||||
|
}
|
||||||
32
internal/database/model_target.go
Normal file
32
internal/database/model_target.go
Normal 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"`
|
||||||
|
}
|
||||||
13
internal/database/model_user.go
Normal file
13
internal/database/model_user.go
Normal 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"`
|
||||||
|
}
|
||||||
14
internal/database/model_webhook.go
Normal file
14
internal/database/model_webhook.go
Normal 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"`
|
||||||
|
}
|
||||||
15
internal/database/models.go
Normal file
15
internal/database/models.go
Normal 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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
187
internal/database/password.go
Normal file
187
internal/database/password.go
Normal 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())
|
||||||
|
}
|
||||||
126
internal/database/password_test.go
Normal file
126
internal/database/password_test.go
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/globals/globals.go
Normal file
28
internal/globals/globals.go
Normal 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
|
||||||
|
}
|
||||||
30
internal/globals/globals_test.go
Normal file
30
internal/globals/globals_test.go
Normal 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
127
internal/handlers/auth.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
internal/handlers/handlers.go
Normal file
137
internal/handlers/handlers.go
Normal 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 = ¶ms
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/handlers/handlers_test.go
Normal file
130
internal/handlers/handlers_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
internal/handlers/healthcheck.go
Normal file
12
internal/handlers/healthcheck.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/handlers/index.go
Normal file
54
internal/handlers/index.go
Normal 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)
|
||||||
|
}
|
||||||
59
internal/handlers/profile.go
Normal file
59
internal/handlers/profile.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/handlers/source_management.go
Normal file
69
internal/handlers/source_management.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/handlers/webhook.go
Normal file
42
internal/handlers/webhook.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/healthcheck/healthcheck.go
Normal file
73
internal/healthcheck/healthcheck.go
Normal 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 = ¶ms
|
||||||
|
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
112
internal/logger/logger.go
Normal 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
|
||||||
|
}
|
||||||
65
internal/logger/logger_test.go
Normal file
65
internal/logger/logger_test.go
Normal 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)
|
||||||
|
}
|
||||||
149
internal/middleware/middleware.go
Normal file
149
internal/middleware/middleware.go
Normal 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 = ¶ms
|
||||||
|
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
34
internal/server/http.go
Normal 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
112
internal/server/routes.go
Normal 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
162
internal/server/server.go
Normal 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
125
internal/session/session.go
Normal 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
1
pkg/config/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
303
pkg/config/README.md
Normal file
303
pkg/config/README.md
Normal 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
180
pkg/config/config.go
Normal 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
306
pkg/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
pkg/config/example_afero_test.go
Normal file
146
pkg/config/example_afero_test.go
Normal 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
139
pkg/config/example_test.go
Normal 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
41
pkg/config/go.mod
Normal 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
161
pkg/config/go.sum
Normal 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
104
pkg/config/loader.go
Normal 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
373
pkg/config/manager.go
Normal 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
204
pkg/config/resolver.go
Normal 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
59
static/css/style.css
Normal 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
2
static/js/app.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Webhooker client-side JavaScript
|
||||||
|
console.log("Webhooker loaded");
|
||||||
8
static/static.go
Normal file
8
static/static.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed css js vendor
|
||||||
|
var Static embed.FS
|
||||||
18
templates/base.html
Normal file
18
templates/base.html
Normal 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}}
|
||||||
8
templates/htmlheader.html
Normal file
8
templates/htmlheader.html
Normal 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
77
templates/index.html
Normal 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
87
templates/login.html
Normal 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
47
templates/navbar.html
Normal 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
53
templates/profile.html
Normal 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}}
|
||||||
Reference in New Issue
Block a user