Compare commits
7 Commits
main
...
b437955378
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b437955378 | ||
|
|
5462db565a | ||
|
|
6ff2bc7647 | ||
|
|
291e60adb2 | ||
|
|
fd3ca22012 | ||
|
|
68c2a4df36 | ||
|
|
6031167c78 |
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
25
README.md
25
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
webhooker is a Go web application by [@sneak](https://sneak.berlin) that
|
||||
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
|
||||
|
||||
@@ -88,15 +88,14 @@ monolithic database:
|
||||
- **Main application database** — Stores application configuration and
|
||||
all standard webapp data: users, sessions, API keys, and global
|
||||
settings.
|
||||
- **Per-processor databases** — Each processor (working name — a better
|
||||
term is needed) gets its own dedicated SQLite database file containing:
|
||||
input logs, processor logs, and all output queues for that specific
|
||||
processor.
|
||||
- **Per-webhook databases** — Each webhook gets its own dedicated SQLite
|
||||
database file containing: input logs, webhook logs, and all output
|
||||
queues for that specific webhook.
|
||||
|
||||
This separation provides several benefits: processor databases can be
|
||||
independently backed up, rotated, or archived; a high-volume processor
|
||||
This separation provides several benefits: webhook databases can be
|
||||
independently backed up, rotated, or archived; a high-volume webhook
|
||||
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.
|
||||
|
||||
### Package Layout
|
||||
@@ -120,9 +119,9 @@ The main entry point is `cmd/webhooker/main.go`.
|
||||
### Data Model
|
||||
|
||||
- **Users** — Service users with username/password (Argon2id hashing)
|
||||
- **Processors** — Webhook processing units, many-to-one with users
|
||||
- **Webhooks** — Inbound URL endpoints feeding into processors
|
||||
- **Targets** — Delivery destinations per processor (HTTP, retry, database, log)
|
||||
- **Webhooks** — Webhook processing units, many-to-one with users
|
||||
- **Entrypoints** — Inbound URL endpoints feeding into webhooks
|
||||
- **Targets** — Delivery destinations per webhook (HTTP, retry, database, log)
|
||||
- **Events** — Captured webhook payloads
|
||||
- **Deliveries** — Pairing of events with targets
|
||||
- **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)
|
||||
|
||||
### Phase 5: API
|
||||
- [ ] RESTful CRUD for processors, webhooks, targets
|
||||
- [ ] RESTful CRUD for webhooks, entrypoints, targets
|
||||
- [ ] Event viewing and filtering endpoints
|
||||
- [ ] API documentation (OpenAPI)
|
||||
|
||||
@@ -182,7 +181,7 @@ The main entry point is `cmd/webhooker/main.go`.
|
||||
|
||||
## License
|
||||
|
||||
Pending — to be determined by the author (MIT, GPL, or WTFPL).
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
@@ -4,19 +4,16 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
pkgconfig "sneak.berlin/go/webhooker/pkg/config"
|
||||
|
||||
// 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)
|
||||
// Populates the environment from a ./.env file automatically for
|
||||
// development configuration. Kept in one place only (here).
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
@@ -64,6 +61,40 @@ func (c *Config) IsProd() bool {
|
||||
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
|
||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
log := params.Logger.Get()
|
||||
@@ -80,30 +111,30 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
EnvironmentDev, EnvironmentProd, environment)
|
||||
}
|
||||
|
||||
// Set the environment in the config package
|
||||
// Set the environment in the config package (for fallback resolution)
|
||||
pkgconfig.SetEnvironment(environment)
|
||||
|
||||
// Load configuration values
|
||||
// Load configuration values — env vars take precedence over config.yaml
|
||||
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"),
|
||||
DBURL: envString("DBURL", "dburl"),
|
||||
Debug: envBool("DEBUG", "debug"),
|
||||
MaintenanceMode: envBool("MAINTENANCE_MODE", "maintenanceMode"),
|
||||
DevelopmentMode: envBool("DEVELOPMENT_MODE", "developmentMode"),
|
||||
DevAdminUsername: envString("DEV_ADMIN_USERNAME", "devAdminUsername"),
|
||||
DevAdminPassword: envString("DEV_ADMIN_PASSWORD", "devAdminPassword"),
|
||||
Environment: environment,
|
||||
MetricsUsername: envString("METRICS_USERNAME", "metricsUsername"),
|
||||
MetricsPassword: envString("METRICS_PASSWORD", "metricsPassword"),
|
||||
Port: envInt("PORT", "port", 8080),
|
||||
SentryDSN: envSecretString("SENTRY_DSN", "sentryDSN"),
|
||||
SessionKey: envSecretString("SESSION_KEY", "sessionKey"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
// Validate database URL
|
||||
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
|
||||
@@ -118,8 +149,6 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
|
||||
if s.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
s.log = params.Logger.Get()
|
||||
log.Debug("Debug mode enabled")
|
||||
}
|
||||
|
||||
// Log configuration summary (without secrets)
|
||||
|
||||
14
internal/database/model_entrypoint.go
Normal file
14
internal/database/model_entrypoint.go
Normal 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"`
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package database
|
||||
|
||||
// Event represents a webhook event
|
||||
// Event represents a captured 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"`
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhook_id"`
|
||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypoint_id"`
|
||||
|
||||
// Request data
|
||||
Method string `gorm:"not null" json:"method"`
|
||||
@@ -14,7 +14,7 @@ type Event struct {
|
||||
ContentType string `json:"content_type"`
|
||||
|
||||
// Relations
|
||||
Processor Processor `json:"processor,omitempty"`
|
||||
Webhook Webhook `json:"webhook,omitempty"`
|
||||
Entrypoint Entrypoint `json:"entrypoint,omitempty"`
|
||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -10,14 +10,14 @@ const (
|
||||
TargetTypeLog TargetType = "log"
|
||||
)
|
||||
|
||||
// Target represents a delivery target for a processor
|
||||
// Target represents a delivery target for a webhook
|
||||
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"`
|
||||
WebhookID string `gorm:"type:uuid;not null" json:"webhook_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
|
||||
@@ -27,6 +27,6 @@ type Target struct {
|
||||
MaxQueueSize int `json:"max_queue_size,omitempty"`
|
||||
|
||||
// Relations
|
||||
Processor Processor `json:"processor,omitempty"`
|
||||
Webhook Webhook `json:"webhook,omitempty"`
|
||||
Deliveries []Delivery `json:"deliveries,omitempty"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ type User struct {
|
||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||
|
||||
// Relations
|
||||
Processors []Processor `json:"processors,omitempty"`
|
||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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 {
|
||||
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"`
|
||||
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
|
||||
Processor Processor `json:"processor,omitempty"`
|
||||
User User `json:"user,omitempty"`
|
||||
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ func (d *Database) Migrate() error {
|
||||
return d.db.AutoMigrate(
|
||||
&User{},
|
||||
&APIKey{},
|
||||
&Processor{},
|
||||
&Webhook{},
|
||||
&Entrypoint{},
|
||||
&Target{},
|
||||
&Event{},
|
||||
&Delivery{},
|
||||
|
||||
@@ -21,7 +21,7 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||
"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",
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
"Error": "Invalid username or password",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
"Error": "Invalid username or password",
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
"sneak.berlin/go/webhooker/templates"
|
||||
)
|
||||
|
||||
// nolint:revive // HandlersParams is a standard fx naming convention
|
||||
@@ -26,11 +27,20 @@ type HandlersParams struct {
|
||||
}
|
||||
|
||||
type Handlers struct {
|
||||
params *HandlersParams
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
db *database.Database
|
||||
session *session.Session
|
||||
params *HandlersParams
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
db *database.Database
|
||||
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) {
|
||||
@@ -40,9 +50,16 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
|
||||
s.hc = params.Healthcheck
|
||||
s.db = params.Database
|
||||
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{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
// FIXME compile some templates here or something
|
||||
return nil
|
||||
},
|
||||
})
|
||||
@@ -80,16 +97,11 @@ type UserInfo struct {
|
||||
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)
|
||||
// renderTemplate renders a pre-parsed template with common data
|
||||
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, pageTemplate string, data interface{}) {
|
||||
tmpl, ok := s.templates[pageTemplate]
|
||||
if !ok {
|
||||
s.log.Error("template not found", "template", pageTemplate)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
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
|
||||
type templateDataWrapper struct {
|
||||
User *UserInfo
|
||||
@@ -119,17 +141,6 @@ func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templa
|
||||
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)
|
||||
|
||||
@@ -87,10 +87,11 @@ func TestRenderTemplate(t *testing.T) {
|
||||
"Version": "1.0.0",
|
||||
}
|
||||
|
||||
// When templates don't exist, renderTemplate should return an error
|
||||
h.renderTemplate(w, req, []string{"nonexistent.html"}, data)
|
||||
// When a non-existent template name is requested, renderTemplate
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Handlers) HandleIndex() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Render the template
|
||||
s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data)
|
||||
s.renderTemplate(w, req, "index.html", data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,6 @@ func (h *Handlers) HandleProfile() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Render the profile page
|
||||
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data)
|
||||
h.renderTemplate(w, r, "profile.html", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,66 +4,66 @@ import (
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSourceCreateSubmit handles the source creation form submission
|
||||
// HandleSourceCreateSubmit handles the webhook creation form submission
|
||||
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSourceDetail shows details for a specific webhook source
|
||||
// HandleSourceDetail shows details for a specific webhook
|
||||
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSourceEdit shows the form to edit a webhook source
|
||||
// HandleSourceEdit shows the form to edit a webhook
|
||||
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSourceEditSubmit handles the source edit form submission
|
||||
// HandleSourceEditSubmit handles the webhook edit form submission
|
||||
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSourceDelete handles webhook source deletion
|
||||
// HandleSourceDelete handles webhook deletion
|
||||
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,19 @@ import (
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
// HandleWebhook handles incoming webhook requests
|
||||
// HandleWebhook handles incoming webhook requests at entrypoint URLs
|
||||
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 == "" {
|
||||
// Get entrypoint UUID from URL
|
||||
entrypointUUID := chi.URLParam(r, "uuid")
|
||||
if entrypointUUID == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the incoming webhook request
|
||||
h.log.Info("webhook request received",
|
||||
"uuid", webhookUUID,
|
||||
"entrypoint_uuid", entrypointUUID,
|
||||
"method", r.Method,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
@@ -32,7 +32,7 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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)
|
||||
_, err := w.Write([]byte("unimplemented"))
|
||||
if err != nil {
|
||||
|
||||
@@ -17,8 +17,9 @@ type LoggerParams struct {
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
logger *slog.Logger
|
||||
params LoggerParams
|
||||
logger *slog.Logger
|
||||
levelVar *slog.LevelVar
|
||||
params LoggerParams
|
||||
}
|
||||
|
||||
// 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.params = params
|
||||
|
||||
// Use slog.LevelVar for dynamic log level changes
|
||||
l.levelVar = new(slog.LevelVar)
|
||||
l.levelVar.Set(slog.LevelInfo)
|
||||
|
||||
// Determine if we're running in a terminal
|
||||
tty := false
|
||||
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
|
||||
tty = true
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
},
|
||||
Level: l.levelVar,
|
||||
ReplaceAttr: replaceAttr,
|
||||
}
|
||||
|
||||
if tty {
|
||||
@@ -63,34 +70,7 @@ func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) {
|
||||
}
|
||||
|
||||
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.levelVar.Set(slog.LevelDebug)
|
||||
l.logger.Debug("debug logging enabled", "debug", true)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
"sneak.berlin/go/webhooker/internal/session"
|
||||
)
|
||||
|
||||
// nolint:revive // MiddlewareParams is a standard fx naming convention
|
||||
@@ -24,17 +25,20 @@ type MiddlewareParams struct {
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Session *session.Session
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
log *slog.Logger
|
||||
params *MiddlewareParams
|
||||
log *slog.Logger
|
||||
params *MiddlewareParams
|
||||
session *session.Session
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) {
|
||||
s := new(Middleware)
|
||||
s.params = ¶ms
|
||||
s.log = params.Logger.Get()
|
||||
s.session = params.Session
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: implement proper authentication
|
||||
s.log.Debug("AUTH: before request")
|
||||
sess, err := s.session.Get(r)
|
||||
if err != nil {
|
||||
s.log.Debug("auth middleware: failed to get session", "error", err)
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.session.IsAuthenticated(sess) {
|
||||
s.log.Debug("auth middleware: unauthenticated request",
|
||||
"path", r.URL.Path,
|
||||
"method", r.Method,
|
||||
)
|
||||
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,23 +90,23 @@ func (s *Server) SetupRoutes() {
|
||||
r.Get("/", s.h.HandleProfile())
|
||||
})
|
||||
|
||||
// Webhook source management routes (require authentication)
|
||||
// Webhook 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.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceList()) // List all webhooks
|
||||
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.Use(s.mw.RequireAuth())
|
||||
r.Get("/", s.h.HandleSourceDetail()) // View webhook 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
|
||||
r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -19,15 +19,6 @@ import (
|
||||
|
||||
"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
|
||||
|
||||
8
templates/templates.go
Normal file
8
templates/templates.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed *.html
|
||||
var Templates embed.FS
|
||||
Reference in New Issue
Block a user