package handlers import ( "net/http" "github.com/go-chi/chi" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" ) // The outcomes of a replay POST, as the notice codes its redirect // carries. noticeFor holds the line each one shows. const ( // replayQueued reports that a new delivery was created and handed // to the delivery engine. replayQueued noticeCode = "replay-queued" // replayTargetDeleted reports a target that once existed and has // since been deleted. Deletes are soft and deliveries carry no // foreign key to the target row, so the history survives its // target and this is the ordinary case for an old event. The // event log shows no Replay button for such a delivery, so only // a page loaded before the delete reaches this. replayTargetDeleted noticeCode = "replay-target-deleted" // replayTargetMissing reports a target id that names no row at // all, deleted or otherwise. replayTargetMissing noticeCode = "replay-target-missing" // replayTargetInactive reports a target the operator has // deactivated. A deactivated target receives no new deliveries, so // a replay to it would be a delivery they switched off. replayTargetInactive noticeCode = "replay-target-inactive" // replayNotTerminal reports a delivery the engine has not finished // with. replayNotTerminal noticeCode = "replay-not-terminal" // replayInFlight reports that an earlier replay of this event to // this target is still running. replayInFlight noticeCode = "replay-in-flight" ) // HandleDeliveryReplay re-sends a finished delivery's event to its // target. // // A replay never touches the delivery it repeats. The delivery engine // creates a NEW pending delivery row for the same event and target and // queues it as it queues the receiver's, so the original's status, // attempts and timestamps stand as the record of what actually // happened, and the replay is retried, SSRF-guarded and circuit-broken // exactly as a first attempt is. // // What is re-sent is the stored EVENT body, never the response the // original delivery received. // // The target's configuration is read now rather than as it stood when // the original ran: a replay exists to deliver where the operator // currently wants the event to go. That is also why a deleted target // is refused rather than delivered to from stale configuration. func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { webhook, ok := h.ownedWebhook(w, r) if !ok { return } // The body size cap is enforced by the MaxBodySize // middleware, which runs before CSRF parses the form. err := r.ParseForm() if err != nil { h.renderError(w, r, http.StatusBadRequest) return } h.replayDelivery(w, r, webhook) } } // replayDelivery performs the replay for a webhook the caller has // already established the session's user owns. func (h *Handlers) replayDelivery( w http.ResponseWriter, r *http.Request, webhook database.Webhook, ) { if !h.dbMgr.DBExists(webhook.ID) { h.renderError(w, r, http.StatusNotFound) return } webhookDB, err := h.dbMgr.GetDB(webhook.ID) if err != nil { h.serverError(w, r, "failed to get webhook database", err) return } original, ok := h.loadReplaySource(w, r, webhookDB) if !ok { return } if !original.Status.Terminal() { redirectToEventLog(w, r, webhook, replayNotTerminal) return } target, code := h.replayTarget(webhook.ID, original.TargetID) if target == nil { redirectToEventLog(w, r, webhook, code) return } h.queueReplay(w, r, webhookDB, webhook, original, target) } // loadReplaySource loads the delivery to be replayed, selecting only // the columns the replay needs so no association is populated. A // delivery id that names no row in this webhook's database is a 404. func (h *Handlers) loadReplaySource( w http.ResponseWriter, r *http.Request, webhookDB *gorm.DB, ) (*database.Delivery, bool) { var original database.Delivery err := webhookDB. Select("id", "event_id", "target_id", "status"). First( &original, "id = ?", chi.URLParam(r, "deliveryID"), ).Error if err != nil { h.renderError(w, r, http.StatusNotFound) return nil, false } return &original, true } // queueReplay has the delivery engine create and queue the new // delivery, unless an earlier replay is still in flight. func (h *Handlers) queueReplay( w http.ResponseWriter, r *http.Request, webhookDB *gorm.DB, webhook database.Webhook, original *database.Delivery, target *database.Target, ) { inFlight, err := countInFlightDeliveries( webhookDB, original.EventID, target.ID, ) if err != nil { h.serverError( w, r, "failed to count in-flight deliveries", err, ) return } if inFlight > 0 { redirectToEventLog(w, r, webhook, replayInFlight) return } deliveryID, err := h.engine.CreateReplay( webhook.ID, original.EventID, target, ) if err != nil { h.serverError( w, r, "failed to create replay delivery", err, ) return } h.mtr.DeliveryReplayed(target.Type) h.log.Info( "delivery replay queued", "webhook_id", webhook.ID, "event_id", original.EventID, "target_id", target.ID, "replayed_delivery_id", original.ID, "delivery_id", deliveryID, ) redirectToEventLog(w, r, webhook, replayQueued) } // replayTarget loads the delivery's target as it stands now. // // The load is Unscoped so that a soft-deleted row is still found: // deletes are soft and a delivery carries no foreign key to its // target, so a target's history outlives it, and without the deleted // row there is no way to tell "you deleted this target" from "this id // never named anything". A nil target means the replay is refused, // with the returned code saying why. func (h *Handlers) replayTarget( webhookID, targetID string, ) (*database.Target, noticeCode) { var target database.Target err := h.db.DB().Unscoped().Where( "id = ? AND webhook_id = ?", targetID, webhookID, ).First(&target).Error if err != nil { return nil, replayTargetMissing } if target.DeletedAt.Valid { return nil, replayTargetDeleted } if !target.Active { return nil, replayTargetInactive } return &target, replayQueued } // countInFlightDeliveries reports how many deliveries of this event to // this target the engine has not finished. // // It is the replay-storm guard: a replay is refused while an earlier // one is still pending or retrying, so a held-down button or a scripted // loop cannot stack copies of work already queued. It is a check and // not a lock, so two simultaneous POSTs can still both pass it; the // per-client rate limit on the route is what bounds that. func countInFlightDeliveries( webhookDB *gorm.DB, eventID, targetID string, ) (int64, error) { var count int64 err := webhookDB.Model(&database.Delivery{}).Where( "event_id = ? AND target_id = ? AND status IN ?", eventID, targetID, []database.DeliveryStatus{ database.DeliveryStatusPending, database.DeliveryStatusRetrying, }, ).Count(&count).Error return count, err } // redirectToEventLog redirects a replay or resubmit back to the event // log it was triggered from, carrying the outcome as its notice. A // Replay form carries the list it was pressed in as show, so a replay // returns to the Failed or Pending list; a Resubmit form carries none, // so a resubmit returns to the full log, where its new event is the // newest. func redirectToEventLog( w http.ResponseWriter, r *http.Request, webhook database.Webhook, code noticeCode, ) { location := withNotice("/hook/"+webhook.ID+"/events", code) show := r.PostFormValue(showParam) if eventLogStatuses(show) != nil { location += "&" + showParam + "=" + show } http.Redirect( //nolint:gosec // show is checked by eventLogStatuses w, r, location, http.StatusSeeOther, ) }