Resubmit a stored event as a new undelivered event (closes #250)
All checks were successful
check / check (push) Successful in 3m3s

Capturing real webhook traffic and firing it repeatedly at a backend
under development is a primary function of this service, and
per-delivery replay cannot do it: it only ever resolves the delivery's
own original target, so a target created for a dev backend has no
prior delivery and nothing can be replayed to it.

The event log now offers a per-event Resubmit action. It stores a NEW
event copying the stored one's method, headers, body and content type
verbatim, and fans it out to the webhook's currently ACTIVE targets,
resolved fresh by the query the receiver uses -- so a target created
long after the original event arrived receives it. The original
event's deliveries have no bearing on where the copy goes, inactive
targets are skipped as the receiver skips them, and the action is
repeatable: replay's in-flight refusal is deliberately not ported,
because firing one captured event over and over is the point.

The receiver and the resubmit path share one construction and one
fan-out site. An eventSource value carries where the fields came from,
live request or stored event, and createAndFanOut writes the event and
its pending deliveries in one transaction and hands the tasks to the
same Notifier, so a resubmitted delivery is retried, SSRF-guarded and
circuit-broken exactly as a first one is. buildDeliveryTasks returns
an error instead of writing a response, which is what lets both
callers share it.

The stored event is read once, before the write transaction, with a
cast to blob, so a body over delivery.MaxInlineBodySize is copied byte
for byte and the engine loads it from the new event row.

A nullable resubmitted_from_id records provenance -- empty for an
event that arrived on the receiver -- and the event log reports the
relationship in both directions, without which the log is unreadable
after a few resubmits of one event. The route sits in the owned-source
group, so auth, CSRF and the body cap apply, with its own rate limit
bucket and an events_resubmitted_total counter.

Inbound signature verification is not re-run: there is no inbound
signature to check on a copy an authenticated, CSRF-protected operator
action submits.

Per-delivery replay is unchanged; it serves recovery, which resubmit
does not replace. The README claimed in four places that replay was
unimplemented, one of them telling the operator that a delivery
stranded by a target type change was lost; all four are corrected and
resubmit is documented beside replay.
This commit is contained in:
2026-08-23 22:38:10 +00:00
parent a83e8fe654
commit f3cb56345f
11 changed files with 1279 additions and 148 deletions

View File

@@ -821,25 +821,31 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
totalPages++
}
// The banner a replay POST redirected back with. The
// message comes from a fixed set keyed by the outcome
// code, never from the query string itself.
// The banner a replay or resubmit POST redirected back
// with. The message comes from a fixed set keyed by the
// outcome code, never from the query string itself.
replayMsg, replayOK := replayOutcome(
r.URL.Query().Get(replayOutcomeParam),
)
resubmitMsg, resubmitOK := resubmitOutcome(
r.URL.Query().Get(resubmitOutcomeParam),
)
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"ReplayMessage": replayMsg,
"ReplayQueued": replayOK,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
tmplKeyWebhook: &webhook,
"Events": evts,
"ReplayMessage": replayMsg,
"ReplayQueued": replayOK,
"ResubmitMessage": resubmitMsg,
"ResubmitQueued": resubmitOK,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.renderTemplate(w, r, "source_logs.html", data)
@@ -925,12 +931,10 @@ func (h *Handlers) loadEventsWithDeliveries(
targetMap map[string]eventLogTarget,
page int,
) ([]EventLogView, int64, bool) {
var totalEvents int64
var result []EventLogView
if !h.dbMgr.DBExists(webhook.ID) {
return result, totalEvents, true
return result, 0, true
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
@@ -942,29 +946,20 @@ func (h *Handlers) loadEventsWithDeliveries(
return nil, 0, false
}
webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhook.ID,
).Count(&totalEvents)
offset := (page - 1) * paginationPerPage
var rows []eventLogRow
webhookDB.Model(&database.Event{}).Select(
eventLogColumns, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhook.ID,
).Order("created_at DESC").Offset(offset).Limit(
paginationPerPage,
).Find(&rows)
rows, totalEvents := loadEventLogRows(
webhookDB, webhook.ID, page,
)
result = make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
var deliveryIDs []string
eventIDs := make([]string, len(rows))
for i := range rows {
result[i] = rows[i].view()
eventIDs[i] = rows[i].ID
webhookDB.Where(
"event_id = ?", rows[i].ID,
@@ -988,15 +983,86 @@ func (h *Handlers) loadEventsWithDeliveries(
return nil, 0, false
}
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, "failed to count event resubmissions", err,
)
return nil, 0, false
}
for i := range rows {
result[i].Deliveries = newDeliveryViews(
eventDeliveries[i], targetMap, attempts,
)
result[i].ResubmitCount = resubmits[rows[i].ID]
}
return result, totalEvents, true
}
// loadEventLogRows reads one page of the event log projection, newest
// first, and the total number of events the pager counts against.
func loadEventLogRows(
webhookDB *gorm.DB, webhookID string, page int,
) ([]eventLogRow, int64) {
var totalEvents int64
webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&totalEvents)
var rows []eventLogRow
webhookDB.Model(&database.Event{}).Select(
eventLogColumns, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhookID,
).Order("created_at DESC").Offset(
(page - 1) * paginationPerPage,
).Limit(paginationPerPage).Find(&rows)
return rows, totalEvents
}
// resubmitCounts reports, for each of the page's events, how many
// events have been resubmitted from it.
//
// One grouped query covers the page rather than one query per event.
// A page holds paginationPerPage ids, far below SQLite's bound
// parameter ceiling, so it needs no chunking as the delivery result
// load does.
func resubmitCounts(
webhookDB *gorm.DB, eventIDs []string,
) (map[string]int, error) {
counts := make(map[string]int, len(eventIDs))
if len(eventIDs) == 0 {
return counts, nil
}
var rows []struct {
ResubmittedFromID string
Total int
}
err := webhookDB.Model(&database.Event{}).
Select("resubmitted_from_id, count(*) AS total").
Where("resubmitted_from_id IN ?", eventIDs).
Group("resubmitted_from_id").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
counts[row.ResubmittedFromID] = row.Total
}
return counts, nil
}
// deliveryIDChunkSize bounds how many delivery IDs go into one
// IN clause. SQLite refuses a statement carrying more than
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a