package handlers import ( "encoding/json" "io" "net/http" "github.com/go-chi/chi" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/middleware" ) const ( // maxWebhookBodySize is the maximum allowed webhook // request body (1 MB). maxWebhookBodySize = 1 << maxBodyShift ) // HandleWebhook handles incoming webhook requests at entrypoint // URLs. 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 } entrypoint, ok := h.lookupEntrypoint( w, r, entrypointUUID, ) if !ok { return } // Logged only once the lookup has found the entrypoint, so // an invented path writes no INFO line; a miss is logged at // DEBUG. The entrypoint is named by its ID: the UUID is its // credential and is never logged. h.log.Info("webhook request received", "entrypoint_id", entrypoint.ID, "webhook_id", entrypoint.WebhookID, "method", r.Method, "remoteIP", middleware.RemoteIP(r), "clientIP", middleware.ClientIP(r), ) if !entrypoint.Active { http.Error(w, "Gone", http.StatusGone) return } h.processWebhookRequest(w, r, entrypoint) } } // processWebhookRequest reads the request into an event and has the // delivery engine store it, with a delivery for each of the webhook's // active targets, and answers the sender. // // Nothing about the request itself is authenticated: the entrypoint // UUID in the path is the credential, and reaching here means it // matched an active entrypoint. func (h *Handlers) processWebhookRequest( w http.ResponseWriter, r *http.Request, entrypoint database.Entrypoint, ) { body, ok := h.readWebhookBody(w, r) if !ok { return } headersJSON, err := json.Marshal(r.Header) if err != nil { h.receiverError(w, "failed to serialize headers", err) return } event := &database.Event{ WebhookID: entrypoint.WebhookID, EntrypointID: entrypoint.ID, Method: r.Method, RawQuery: r.URL.RawQuery, Headers: string(headersJSON), ContentType: r.Header.Get("Content-Type"), Body: string(body), } deliveries, err := h.engine.StoreEvent(event) if err != nil { h.receiverError(w, "failed to store webhook event", err) return } h.finishWebhookResponse(w, event, entrypoint, deliveries) } // lookupEntrypoint finds an entrypoint by UUID path. func (h *Handlers) lookupEntrypoint( w http.ResponseWriter, r *http.Request, entrypointUUID string, ) (database.Entrypoint, bool) { var entrypoint database.Entrypoint result := h.db.DB().Where( "path = ?", entrypointUUID, ).First(&entrypoint) if result.Error != nil { // Not even an unknown UUID is logged. The receiver's log // lines never carry the path segment, which is a credential // whenever it names an entrypoint. h.log.Debug("entrypoint not found") http.NotFound(w, r) return entrypoint, false } return entrypoint, true } // readWebhookBody reads and validates the request body size. This is // the receiver's only body cap: /h/{uuid} has no MaxBodySize // middleware (see Server.setupWebhookRoutes). func (h *Handlers) readWebhookBody( w http.ResponseWriter, r *http.Request, ) ([]byte, bool) { 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 nil, false } if len(body) > maxWebhookBodySize { http.Error( w, "Request body too large", http.StatusRequestEntityTooLarge, ) return nil, false } return body, true } // receiverError logs an error and answers the sender with a plain-text // 500. The receiver's answers are for programs, so it never sends the // error page the web UI uses. func (h *Handlers) receiverError( w http.ResponseWriter, msg string, err error, ) { h.log.Error(msg, "error", err) http.Error( w, "Internal server error", http.StatusInternalServerError, ) } // finishWebhookResponse logs the stored event, with how many // deliveries it has, and writes the HTTP response. func (h *Handlers) finishWebhookResponse( w http.ResponseWriter, event *database.Event, entrypoint database.Entrypoint, deliveries int, ) { h.log.Info("webhook event created", "event_id", event.ID, "webhook_id", entrypoint.WebhookID, "entrypoint_id", entrypoint.ID, "target_count", deliveries, ) w.WriteHeader(http.StatusOK) _, err := w.Write([]byte(`{"status":"ok"}`)) if err != nil { h.log.Error( "failed to write response", "error", err, ) } }