Files
webhooker/internal/handlers/delivery_replay.go
T
clawbot d8c60c9b67
check / check (push) Successful in 3m21s
Event log: show attempt and delivery times, label replays, zone event times (closes #386)
In the event log and on an event's page, attempts and deliveries showed no time, event times had no zone, and a replay looked like the original it repeated. Each attempt now shows when its result was recorded and each delivery when it was created, as how long ago with the full UTC time on hover, and the event log's event times read the same way. A delivery created by Replay records it in a new `replay` column and is labelled a replay in the event's summary line and its delivery list; replays made before this change are not labelled. A delivery's row is now drawn by one template, `delivery_row`, that both pages share.

Model: opus-5-5
2026-10-03 04:12:27 +02:00

344 lines
9.3 KiB
Go

package handlers
import (
"net/http"
"github.com/go-chi/chi"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// 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. It creates a NEW
// pending delivery row for the same event and target and hands it to
// the delivery engine through the same Notifier the receiver uses, 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 writes the new delivery and hands it to the engine.
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
}
var event database.Event
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, r, "failed to load event for replay", err)
return
}
task, err := createReplayDelivery(
webhookDB, webhook.ID, &event, target,
)
if err != nil {
h.serverError(
w, r, "failed to create replay delivery", err,
)
return
}
h.mtr.DeliveryReplayed(target.Type)
h.notifier.Notify([]delivery.Task{task})
h.log.Info(
"delivery replay queued",
"webhook_id", webhook.ID,
"event_id", event.ID,
"target_id", target.ID,
"replayed_delivery_id", original.ID,
"delivery_id", task.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
}
// createReplayDelivery writes the new pending delivery row, adds it to
// its target's totals in the same transaction, and returns the task
// that carries it to the delivery engine.
//
// The row is written with associations omitted, and neither Event nor
// Target is populated on it: GORM's SaveBeforeAssociations would
// otherwise upsert the whole target row — plaintext config, which for a
// Slack target is the credential — into the per-webhook event database.
// See https://git.eeqj.de/sneak/webhooker/issues/206.
func createReplayDelivery(
webhookDB *gorm.DB,
webhookID string,
event *database.Event,
target *database.Target,
) (delivery.Task, error) {
dlv := &database.Delivery{
EventID: event.ID,
TargetID: target.ID,
Status: database.DeliveryStatusPending,
Replay: true,
}
err := webhookDB.Transaction(func(tx *gorm.DB) error {
err := tx.Omit(clause.Associations).Create(dlv).Error
if err != nil {
return err
}
return database.AddTargetTotals(tx, database.TargetTotals{
TargetID: dlv.TargetID, Deliveries: 1,
})
})
if err != nil {
return delivery.Task{}, err
}
return delivery.Task{
DeliveryID: dlv.ID,
EventID: event.ID,
WebhookID: webhookID,
EntrypointID: event.EntrypointID,
TargetID: target.ID,
TargetName: target.Name,
TargetType: target.Type,
TargetConfig: target.Config,
MaxRetries: target.MaxRetries,
Method: event.Method,
Headers: event.Headers,
ContentType: event.ContentType,
Body: replayBody(event.Body),
AttemptNum: 1,
}, nil
}
// replayBody returns the stored event body for a replay task to carry
// inline, or nil when it is large enough that the engine should fetch
// it from the per-webhook database instead.
func replayBody(body string) *string {
if len(body) >= delivery.MaxInlineBodySize {
return nil
}
return &body
}
// redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice.
func redirectToEventLog(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code noticeCode,
) {
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
http.StatusSeeOther,
)
}