Compare commits

...

7 Commits

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

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

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

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

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

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

closes #12
2026-03-01 15:44:22 -08:00
22 changed files with 252 additions and 192 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeffrey Paul <sneak@sneak.berlin>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -2,7 +2,7 @@
webhooker is a Go web application by [@sneak](https://sneak.berlin) that webhooker is a Go web application by [@sneak](https://sneak.berlin) that
receives, stores, and proxies webhooks to configured targets with retry receives, stores, and proxies webhooks to configured targets with retry
support, observability, and a management web UI. License: pending. support, observability, and a management web UI. License: MIT.
## Getting Started ## Getting Started
@@ -88,15 +88,14 @@ monolithic database:
- **Main application database** — Stores application configuration and - **Main application database** — Stores application configuration and
all standard webapp data: users, sessions, API keys, and global all standard webapp data: users, sessions, API keys, and global
settings. settings.
- **Per-processor databases** — Each processor (working name — a better - **Per-webhook databases** — Each webhook gets its own dedicated SQLite
term is needed) gets its own dedicated SQLite database file containing: database file containing: input logs, webhook logs, and all output
input logs, processor logs, and all output queues for that specific queues for that specific webhook.
processor.
This separation provides several benefits: processor databases can be This separation provides several benefits: webhook databases can be
independently backed up, rotated, or archived; a high-volume processor independently backed up, rotated, or archived; a high-volume webhook
won't cause lock contention or bloat affecting the main application; and won't cause lock contention or bloat affecting the main application; and
individual processor data can be cleanly deleted when a processor is individual webhook data can be cleanly deleted when a webhook is
removed. removed.
### Package Layout ### Package Layout
@@ -120,9 +119,9 @@ The main entry point is `cmd/webhooker/main.go`.
### Data Model ### Data Model
- **Users** — Service users with username/password (Argon2id hashing) - **Users** — Service users with username/password (Argon2id hashing)
- **Processors** — Webhook processing units, many-to-one with users - **Webhooks** — Webhook processing units, many-to-one with users
- **Webhooks** — Inbound URL endpoints feeding into processors - **Entrypoints** — Inbound URL endpoints feeding into webhooks
- **Targets** — Delivery destinations per processor (HTTP, retry, database, log) - **Targets** — Delivery destinations per webhook (HTTP, retry, database, log)
- **Events** — Captured webhook payloads - **Events** — Captured webhook payloads
- **Deliveries** — Pairing of events with targets - **Deliveries** — Pairing of events with targets
- **Delivery Results** — Outcome of each delivery attempt - **Delivery Results** — Outcome of each delivery attempt
@@ -170,7 +169,7 @@ The main entry point is `cmd/webhooker/main.go`.
- [ ] Analytics dashboard (success rates, response times) - [ ] Analytics dashboard (success rates, response times)
### Phase 5: API ### Phase 5: API
- [ ] RESTful CRUD for processors, webhooks, targets - [ ] RESTful CRUD for webhooks, entrypoints, targets
- [ ] Event viewing and filtering endpoints - [ ] Event viewing and filtering endpoints
- [ ] API documentation (OpenAPI) - [ ] API documentation (OpenAPI)
@@ -182,7 +181,7 @@ The main entry point is `cmd/webhooker/main.go`.
## License ## License
Pending — to be determined by the author (MIT, GPL, or WTFPL). MIT License. See [LICENSE](LICENSE) for details.
## Author ## Author

View File

@@ -4,19 +4,16 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strconv"
"strings"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
pkgconfig "sneak.berlin/go/webhooker/pkg/config" pkgconfig "sneak.berlin/go/webhooker/pkg/config"
// spooky action at a distance! // Populates the environment from a ./.env file automatically for
// this populates the environment // development configuration. Kept in one place only (here).
// 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" _ "github.com/joho/godotenv/autoload"
) )
@@ -64,6 +61,40 @@ func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd return c.Environment == EnvironmentProd
} }
// envString returns the env var value if set, otherwise falls back to pkgconfig.
func envString(envKey, configKey string) string {
if v := os.Getenv(envKey); v != "" {
return v
}
return pkgconfig.GetString(configKey)
}
// envSecretString returns the env var value if set, otherwise falls back to pkgconfig secrets.
func envSecretString(envKey, configKey string) string {
if v := os.Getenv(envKey); v != "" {
return v
}
return pkgconfig.GetSecretString(configKey)
}
// envBool returns the env var value parsed as bool, otherwise falls back to pkgconfig.
func envBool(envKey, configKey string) bool {
if v := os.Getenv(envKey); v != "" {
return strings.EqualFold(v, "true") || v == "1"
}
return pkgconfig.GetBool(configKey)
}
// envInt returns the env var value parsed as int, otherwise falls back to pkgconfig.
func envInt(envKey, configKey string, defaultValue ...int) int {
if v := os.Getenv(envKey); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return pkgconfig.GetInt(configKey, defaultValue...)
}
// nolint:revive // lc parameter is required by fx even if unused // nolint:revive // lc parameter is required by fx even if unused
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log := params.Logger.Get() log := params.Logger.Get()
@@ -80,30 +111,30 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
EnvironmentDev, EnvironmentProd, environment) EnvironmentDev, EnvironmentProd, environment)
} }
// Set the environment in the config package // Set the environment in the config package (for fallback resolution)
pkgconfig.SetEnvironment(environment) pkgconfig.SetEnvironment(environment)
// Load configuration values // Load configuration values — env vars take precedence over config.yaml
s := &Config{ s := &Config{
DBURL: pkgconfig.GetString("dburl"), DBURL: envString("DBURL", "dburl"),
Debug: pkgconfig.GetBool("debug"), Debug: envBool("DEBUG", "debug"),
MaintenanceMode: pkgconfig.GetBool("maintenanceMode"), MaintenanceMode: envBool("MAINTENANCE_MODE", "maintenanceMode"),
DevelopmentMode: pkgconfig.GetBool("developmentMode"), DevelopmentMode: envBool("DEVELOPMENT_MODE", "developmentMode"),
DevAdminUsername: pkgconfig.GetString("devAdminUsername"), DevAdminUsername: envString("DEV_ADMIN_USERNAME", "devAdminUsername"),
DevAdminPassword: pkgconfig.GetString("devAdminPassword"), DevAdminPassword: envString("DEV_ADMIN_PASSWORD", "devAdminPassword"),
Environment: pkgconfig.GetString("environment", environment), Environment: environment,
MetricsUsername: pkgconfig.GetString("metricsUsername"), MetricsUsername: envString("METRICS_USERNAME", "metricsUsername"),
MetricsPassword: pkgconfig.GetString("metricsPassword"), MetricsPassword: envString("METRICS_PASSWORD", "metricsPassword"),
Port: pkgconfig.GetInt("port", 8080), Port: envInt("PORT", "port", 8080),
SentryDSN: pkgconfig.GetSecretString("sentryDSN"), SentryDSN: envSecretString("SENTRY_DSN", "sentryDSN"),
SessionKey: pkgconfig.GetSecretString("sessionKey"), SessionKey: envSecretString("SESSION_KEY", "sessionKey"),
log: log, log: log,
params: &params, params: &params,
} }
// Validate database URL // Validate database URL
if s.DBURL == "" { if s.DBURL == "" {
return nil, fmt.Errorf("database URL (dburl) is required") return nil, fmt.Errorf("database URL (DBURL) is required")
} }
// In production, require session key // In production, require session key
@@ -118,8 +149,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
if s.Debug { if s.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
s.log = params.Logger.Get()
log.Debug("Debug mode enabled")
} }
// Log configuration summary (without secrets) // Log configuration summary (without secrets)

View File

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

View File

@@ -1,11 +1,11 @@
package database package database
// Event represents a webhook event // Event represents a captured webhook event
type Event struct { type Event struct {
BaseModel BaseModel
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"` WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"` EntrypointID string `gorm:"type:uuid;not null" json:"entrypoint_id"`
// Request data // Request data
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
@@ -14,7 +14,7 @@ type Event struct {
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
// Relations // Relations
Processor Processor `json:"processor,omitempty"`
Webhook Webhook `json:"webhook,omitempty"` Webhook Webhook `json:"webhook,omitempty"`
Entrypoint Entrypoint `json:"entrypoint,omitempty"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }

View File

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

View File

@@ -10,14 +10,14 @@ const (
TargetTypeLog TargetType = "log" TargetTypeLog TargetType = "log"
) )
// Target represents a delivery target for a processor // Target represents a delivery target for a webhook
type Target struct { type Target struct {
BaseModel BaseModel
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"` WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Type TargetType `gorm:"not null" json:"type"` Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Configuration fields (JSON stored based on type) // Configuration fields (JSON stored based on type)
Config string `gorm:"type:text" json:"config"` // JSON configuration Config string `gorm:"type:text" json:"config"` // JSON configuration
@@ -27,6 +27,6 @@ type Target struct {
MaxQueueSize int `json:"max_queue_size,omitempty"` MaxQueueSize int `json:"max_queue_size,omitempty"`
// Relations // Relations
Processor Processor `json:"processor,omitempty"` Webhook Webhook `json:"webhook,omitempty"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }

View File

@@ -8,6 +8,6 @@ type User struct {
Password string `gorm:"not null" json:"-"` // Argon2 hashed Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations // Relations
Processors []Processor `json:"processors,omitempty"` Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"api_keys,omitempty"` APIKeys []APIKey `json:"api_keys,omitempty"`
} }

View File

@@ -1,14 +1,16 @@
package database package database
// Webhook represents a webhook endpoint that feeds into a processor // Webhook represents a webhook processing unit that groups entrypoints and targets
type Webhook struct { type Webhook struct {
BaseModel BaseModel
ProcessorID string `gorm:"type:uuid;not null" json:"processor_id"` UserID string `gorm:"type:uuid;not null" json:"user_id"`
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this webhook Name string `gorm:"not null" json:"name"`
Description string `json:"description"` Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"` RetentionDays int `gorm:"default:30" json:"retention_days"` // Days to retain events
// Relations // Relations
Processor Processor `json:"processor,omitempty"` User User `json:"user,omitempty"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"`
} }

View File

@@ -5,8 +5,8 @@ func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
&User{}, &User{},
&APIKey{}, &APIKey{},
&Processor{},
&Webhook{}, &Webhook{},
&Entrypoint{},
&Target{}, &Target{},
&Event{}, &Event{},
&Delivery{}, &Delivery{},

View File

@@ -21,7 +21,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
"Error": "", "Error": "",
} }
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
} }
} }
@@ -44,7 +44,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"Error": "Username and password are required", "Error": "Username and password are required",
} }
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
return return
} }
@@ -56,7 +56,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"Error": "Invalid username or password", "Error": "Invalid username or password",
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
return return
} }
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
"Error": "Invalid username or password", "Error": "Invalid username or password",
} }
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data) h.renderTemplate(w, r, "login.html", data)
return return
} }

View File

@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
"sneak.berlin/go/webhooker/templates"
) )
// nolint:revive // HandlersParams is a standard fx naming convention // nolint:revive // HandlersParams is a standard fx naming convention
@@ -26,11 +27,20 @@ type HandlersParams struct {
} }
type Handlers struct { type Handlers struct {
params *HandlersParams params *HandlersParams
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
db *database.Database db *database.Database
session *session.Session session *session.Session
templates map[string]*template.Template
}
// parsePageTemplate parses a page-specific template set from the embedded FS.
// Each page template is combined with the shared base, htmlheader, and navbar templates.
func parsePageTemplate(pageFile string) *template.Template {
return template.Must(
template.ParseFS(templates.Templates, "htmlheader.html", "navbar.html", "base.html", pageFile),
)
} }
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
@@ -40,9 +50,16 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s.hc = params.Healthcheck s.hc = params.Healthcheck
s.db = params.Database s.db = params.Database
s.session = params.Session s.session = params.Session
// Parse all page templates once at startup
s.templates = map[string]*template.Template{
"index.html": parsePageTemplate("index.html"),
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
}
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(ctx context.Context) error {
// FIXME compile some templates here or something
return nil return nil
}, },
}) })
@@ -80,16 +97,11 @@ type UserInfo struct {
Username string Username string
} }
// renderTemplate renders a template with common data // renderTemplate renders a pre-parsed template with common data
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) { func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
// Always include the common templates tmpl, ok := s.templates[pageTemplate]
allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"} if !ok {
allTemplates = append(allTemplates, templateFiles...) s.log.Error("template not found", "template", pageTemplate)
// 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) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -108,6 +120,16 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa
} }
} }
// 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
}
// Wrap data with base template data // Wrap data with base template data
type templateDataWrapper struct { type templateDataWrapper struct {
User *UserInfo User *UserInfo
@@ -119,17 +141,6 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa
Data: data, 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 { if err := tmpl.Execute(w, wrapper); err != nil {
s.log.Error("failed to execute template", "error", err) s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)

View File

@@ -87,10 +87,11 @@ func TestRenderTemplate(t *testing.T) {
"Version": "1.0.0", "Version": "1.0.0",
} }
// When templates don't exist, renderTemplate should return an error // When a non-existent template name is requested, renderTemplate
h.renderTemplate(w, req, []string{"nonexistent.html"}, data) // should return an internal server error
h.renderTemplate(w, req, "nonexistent.html", data)
// Should return internal server error when template parsing fails // Should return internal server error when template is not found
assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Equal(t, http.StatusInternalServerError, w.Code)
}) })
} }

View File

@@ -34,7 +34,7 @@ func (s *Handlers) HandleIndex() http.HandlerFunc {
} }
// Render the template // Render the template
s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data) s.renderTemplate(w, req, "index.html", data)
} }
} }

View File

@@ -54,6 +54,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
} }
// Render the profile page // Render the profile page
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data) h.renderTemplate(w, r, "profile.html", data)
} }
} }

View File

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

View File

@@ -6,19 +6,19 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
) )
// HandleWebhook handles incoming webhook requests // HandleWebhook handles incoming webhook requests at entrypoint URLs
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Get webhook UUID from URL // Get entrypoint UUID from URL
webhookUUID := chi.URLParam(r, "uuid") entrypointUUID := chi.URLParam(r, "uuid")
if webhookUUID == "" { if entrypointUUID == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
// Log the incoming webhook request // Log the incoming webhook request
h.log.Info("webhook request received", h.log.Info("webhook request received",
"uuid", webhookUUID, "entrypoint_uuid", entrypointUUID,
"method", r.Method, "method", r.Method,
"remote_addr", r.RemoteAddr, "remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(), "user_agent", r.UserAgent(),
@@ -32,7 +32,7 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
} }
// TODO: Implement webhook handling logic // TODO: Implement webhook handling logic
// For now, return "unimplemented" for all webhook POST requests // Look up entrypoint by UUID, find parent webhook, fan out to targets
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte("unimplemented")) _, err := w.Write([]byte("unimplemented"))
if err != nil { if err != nil {

View File

@@ -17,8 +17,9 @@ type LoggerParams struct {
} }
type Logger struct { type Logger struct {
logger *slog.Logger logger *slog.Logger
params LoggerParams levelVar *slog.LevelVar
params LoggerParams
} }
// nolint:revive // lc parameter is required by fx even if unused // nolint:revive // lc parameter is required by fx even if unused
@@ -26,24 +27,30 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
l := new(Logger) l := new(Logger)
l.params = params l.params = params
// Use slog.LevelVar for dynamic log level changes
l.levelVar = new(slog.LevelVar)
l.levelVar.Set(slog.LevelInfo)
// Determine if we're running in a terminal // Determine if we're running in a terminal
tty := false tty := false
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true tty = true
} }
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
}
var handler slog.Handler var handler slog.Handler
opts := &slog.HandlerOptions{ opts := &slog.HandlerOptions{
Level: slog.LevelInfo, Level: l.levelVar,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { // nolint:revive // groups unused ReplaceAttr: replaceAttr,
// 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 { if tty {
@@ -63,34 +70,7 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
} }
func (l *Logger) EnableDebugLogging() { func (l *Logger) EnableDebugLogging() {
// Recreate logger with debug level l.levelVar.Set(slog.LevelDebug)
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) l.logger.Debug("debug logging enabled", "debug", true)
} }

View File

@@ -16,6 +16,7 @@ import (
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
) )
// nolint:revive // MiddlewareParams is a standard fx naming convention // nolint:revive // MiddlewareParams is a standard fx naming convention
@@ -24,17 +25,20 @@ type MiddlewareParams struct {
Logger *logger.Logger Logger *logger.Logger
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Session *session.Session
} }
type Middleware struct { type Middleware struct {
log *slog.Logger log *slog.Logger
params *MiddlewareParams params *MiddlewareParams
session *session.Session
} }
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) { func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
s := new(Middleware) s := new(Middleware)
s.params = &params s.params = &params
s.log = params.Logger.Get() s.log = params.Logger.Get()
s.session = params.Session
return s, nil return s, nil
} }
@@ -118,11 +122,27 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
}) })
} }
func (s *Middleware) Auth() func(http.Handler) http.Handler { // RequireAuth returns middleware that checks for a valid session.
// Unauthenticated users are redirected to the login page.
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: implement proper authentication sess, err := s.session.Get(r)
s.log.Debug("AUTH: before request") if err != nil {
s.log.Debug("auth middleware: failed to get session", "error", err)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
if !s.session.IsAuthenticated(sess) {
s.log.Debug("auth middleware: unauthenticated request",
"path", r.URL.Path,
"method", r.Method,
)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }

View File

@@ -90,23 +90,23 @@ func (s *Server) SetupRoutes() {
r.Get("/", s.h.HandleProfile()) r.Get("/", s.h.HandleProfile())
}) })
// Webhook source management routes (require authentication) // Webhook management routes (require authentication)
s.router.Route("/sources", func(r chi.Router) { s.router.Route("/sources", func(r chi.Router) {
// TODO: Add authentication middleware here r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList()) // List all sources r.Get("/", s.h.HandleSourceList()) // List all webhooks
r.Get("/new", s.h.HandleSourceCreate()) // Show create form r.Get("/new", s.h.HandleSourceCreate()) // Show create form
r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission r.Post("/new", s.h.HandleSourceCreateSubmit()) // Handle create submission
}) })
s.router.Route("/source/{sourceID}", func(r chi.Router) { s.router.Route("/source/{sourceID}", func(r chi.Router) {
// TODO: Add authentication middleware here r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail()) // View source details r.Get("/", s.h.HandleSourceDetail()) // View webhook details
r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form
r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission
r.Post("/delete", s.h.HandleSourceDelete()) // Delete source r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
r.Get("/logs", s.h.HandleSourceLogs()) // View source logs r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs
}) })
// Webhook endpoint - accepts all HTTP methods // Entrypoint endpoint - accepts incoming webhook POST requests
s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook()) s.router.HandleFunc("/webhook/{uuid}", s.h.HandleWebhook())
} }

View File

@@ -19,15 +19,6 @@ import (
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi" "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 // ServerParams is a standard fx naming convention for dependency injection

8
templates/templates.go Normal file
View File

@@ -0,0 +1,8 @@
package templates
import (
"embed"
)
//go:embed *.html
var Templates embed.FS