package handlers import ( "encoding/json" "io" "net/http" "github.com/go-chi/chi" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) const ( // maxWebhookBodySize is the maximum allowed webhook request body (1 MB). maxWebhookBodySize = 1 << 20 ) // HandleWebhook handles incoming webhook requests at entrypoint URLs. // Only POST requests are accepted; all other methods return 405 Method Not Allowed. // Events and deliveries are stored in the per-webhook database. func (h *Handlers) HandleWebhook() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } entrypointUUID := chi.URLParam(r, "uuid") if entrypointUUID == "" { http.NotFound(w, r) return } h.log.Info("webhook request received", "entrypoint_uuid", entrypointUUID, "method", r.Method, "remote_addr", r.RemoteAddr, ) // Look up entrypoint by path (from main application DB) var entrypoint database.Entrypoint result := h.db.DB().Where("path = ?", entrypointUUID).First(&entrypoint) if result.Error != nil { h.log.Debug("entrypoint not found", "path", entrypointUUID) http.NotFound(w, r) return } // Check if active if !entrypoint.Active { http.Error(w, "Gone", http.StatusGone) return } // Read body with size limit body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) if err != nil { h.log.Error("failed to read request body", "error", err) http.Error(w, "Bad request", http.StatusBadRequest) return } if len(body) > maxWebhookBodySize { http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) return } // Serialize headers as JSON headersJSON, err := json.Marshal(r.Header) if err != nil { h.log.Error("failed to serialize headers", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Find all active targets for this webhook (from main application DB) var targets []database.Target if targetErr := h.db.DB().Where("webhook_id = ? AND active = ?", entrypoint.WebhookID, true).Find(&targets).Error; targetErr != nil { h.log.Error("failed to query targets", "error", targetErr) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Get the per-webhook database for event storage webhookDB, err := h.dbMgr.GetDB(entrypoint.WebhookID) if err != nil { h.log.Error("failed to get webhook database", "webhook_id", entrypoint.WebhookID, "error", err, ) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Create the event and deliveries in a transaction on the per-webhook DB tx := webhookDB.Begin() if tx.Error != nil { h.log.Error("failed to begin transaction", "error", tx.Error) http.Error(w, "Internal server error", http.StatusInternalServerError) return } event := &database.Event{ WebhookID: entrypoint.WebhookID, EntrypointID: entrypoint.ID, Method: r.Method, Headers: string(headersJSON), Body: string(body), ContentType: r.Header.Get("Content-Type"), } if err := tx.Create(event).Error; err != nil { tx.Rollback() h.log.Error("failed to create event", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Create delivery records for each active target for i := range targets { dlv := &database.Delivery{ EventID: event.ID, TargetID: targets[i].ID, Status: database.DeliveryStatusPending, } if err := tx.Create(dlv).Error; err != nil { tx.Rollback() h.log.Error("failed to create delivery", "target_id", targets[i].ID, "error", err, ) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } if err := tx.Commit().Error; err != nil { h.log.Error("failed to commit transaction", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Notify the delivery engine with inline event data so it can // process deliveries immediately without a DB round-trip. // Large bodies (>= 16KB) are left nil to keep channel memory // bounded; the engine fetches them from DB on demand. n := delivery.Notification{ WebhookID: entrypoint.WebhookID, EventID: event.ID, Method: event.Method, Headers: event.Headers, ContentType: event.ContentType, } bodyStr := string(body) if len(body) < delivery.MaxInlineBodySize { n.Body = &bodyStr } h.notifier.Notify(n) h.log.Info("webhook event created", "event_id", event.ID, "webhook_id", entrypoint.WebhookID, "entrypoint_id", entrypoint.ID, "target_count", len(targets), ) w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil { h.log.Error("failed to write response", "error", err) } } }