check / check (push) Successful in 3m20s
Saving, deleting, activating or deactivating a webhook, entrypoint or target, and signing out, now land on their page with a one-line notice such as "Webhook deleted." or "Signed out.". The redirect carries a fixed code that maps to fixed text; an unknown code shows nothing, so nothing from the URL is ever echoed. One partial in the page layout shows the notice on every page, and replay and resubmit now use the same codes and partial. Error pages show no notice. Model: opus-5-5
351 lines
9.5 KiB
Go
351 lines
9.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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.
|
|
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,
|
|
}
|
|
|
|
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 and
|
|
// the page number the form submitted.
|
|
func redirectToEventLog(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
code noticeCode,
|
|
) {
|
|
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
|
|
|
// The page is read from the form rather than the query string:
|
|
// this is a POST, and its query string is what logs and Referer
|
|
// headers record.
|
|
if page := pageOrFirst(
|
|
r.PostFormValue("page"),
|
|
); page > 1 {
|
|
dest += "&page=" + strconv.Itoa(page)
|
|
}
|
|
|
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
|
}
|