Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
@@ -2,90 +2,54 @@ 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"
|
||||
)
|
||||
|
||||
// replayOutcomeParam is the query parameter the replay POST redirects
|
||||
// with and the event log page reads its banner from.
|
||||
const replayOutcomeParam = "replay"
|
||||
|
||||
// replayOutcomeCode is the outcome of a replay POST. The redirect
|
||||
// carries one of these fixed codes rather than a message, so nothing a
|
||||
// client submits can reach the rendered page through it.
|
||||
type replayOutcomeCode string
|
||||
|
||||
// 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 replayOutcomeCode = "queued"
|
||||
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 replayOutcomeCode = "target-deleted"
|
||||
// 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 replayOutcomeCode = "target-missing"
|
||||
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 replayOutcomeCode = "target-inactive"
|
||||
replayTargetInactive noticeCode = "replay-target-inactive"
|
||||
|
||||
// replayNotTerminal reports a delivery the engine has not finished
|
||||
// with.
|
||||
replayNotTerminal replayOutcomeCode = "not-terminal"
|
||||
replayNotTerminal noticeCode = "replay-not-terminal"
|
||||
|
||||
// replayInFlight reports that an earlier replay of this event to
|
||||
// this target is still running.
|
||||
replayInFlight replayOutcomeCode = "in-flight"
|
||||
replayInFlight noticeCode = "replay-in-flight"
|
||||
)
|
||||
|
||||
// replayOutcome returns the banner the event log page shows for an
|
||||
// outcome code, and whether the replay was queued. An unrecognised
|
||||
// code yields no banner.
|
||||
func replayOutcome(code string) (string, bool) {
|
||||
switch replayOutcomeCode(code) {
|
||||
case replayQueued:
|
||||
return "Replay queued: a new delivery was created against " +
|
||||
"the target's current configuration.", true
|
||||
case replayTargetDeleted:
|
||||
return "Not replayed: the target this delivery was for has " +
|
||||
"been deleted. Recreate the target, then replay.", false
|
||||
case replayTargetMissing:
|
||||
return "Not replayed: the target this delivery was for no " +
|
||||
"longer exists.", false
|
||||
case replayTargetInactive:
|
||||
return "Not replayed: the target this delivery was for is " +
|
||||
"deactivated. Activate it, then replay.", false
|
||||
case replayNotTerminal:
|
||||
return "Not replayed: this delivery has not finished yet.",
|
||||
false
|
||||
case replayInFlight:
|
||||
return "Not replayed: a delivery of this event to this " +
|
||||
"target is already in flight.", false
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -105,9 +69,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
||||
// middleware, which runs before CSRF parses the form.
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(
|
||||
w, "Bad request", http.StatusBadRequest,
|
||||
)
|
||||
h.renderError(w, r, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -124,14 +86,14 @@ func (h *Handlers) replayDelivery(
|
||||
webhook database.Webhook,
|
||||
) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
h.serverError(w, r, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -142,14 +104,14 @@ func (h *Handlers) replayDelivery(
|
||||
}
|
||||
|
||||
if !original.Status.Terminal() {
|
||||
h.finishReplay(w, r, webhook, replayNotTerminal)
|
||||
redirectToEventLog(w, r, webhook, replayNotTerminal)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target, code := h.replayTarget(webhook.ID, original.TargetID)
|
||||
if target == nil {
|
||||
h.finishReplay(w, r, webhook, code)
|
||||
redirectToEventLog(w, r, webhook, code)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -173,7 +135,7 @@ func (h *Handlers) loadReplaySource(
|
||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||
).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
h.renderError(w, r, http.StatusNotFound)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
@@ -181,7 +143,8 @@ func (h *Handlers) loadReplaySource(
|
||||
return &original, true
|
||||
}
|
||||
|
||||
// queueReplay writes the new delivery and hands it to the engine.
|
||||
// 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,
|
||||
@@ -195,52 +158,41 @@ func (h *Handlers) queueReplay(
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to count in-flight deliveries", err,
|
||||
w, r, "failed to count in-flight deliveries", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if inFlight > 0 {
|
||||
h.finishReplay(w, r, webhook, replayInFlight)
|
||||
redirectToEventLog(w, r, webhook, replayInFlight)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var event database.Event
|
||||
|
||||
err = webhookDB.
|
||||
First(&event, "id = ?", original.EventID).Error
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to load event for replay", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
task, err := createReplayDelivery(
|
||||
webhookDB, webhook.ID, &event, target,
|
||||
deliveryID, err := h.engine.CreateReplay(
|
||||
webhook.ID, original.EventID, target,
|
||||
)
|
||||
if err != nil {
|
||||
h.serverError(
|
||||
w, "failed to create replay delivery", err,
|
||||
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,
|
||||
"event_id", original.EventID,
|
||||
"target_id", target.ID,
|
||||
"replayed_delivery_id", original.ID,
|
||||
"delivery_id", task.DeliveryID,
|
||||
"delivery_id", deliveryID,
|
||||
)
|
||||
|
||||
h.finishReplay(w, r, webhook, replayQueued)
|
||||
redirectToEventLog(w, r, webhook, replayQueued)
|
||||
}
|
||||
|
||||
// replayTarget loads the delivery's target as it stands now.
|
||||
@@ -253,7 +205,7 @@ func (h *Handlers) queueReplay(
|
||||
// with the returned code saying why.
|
||||
func (h *Handlers) replayTarget(
|
||||
webhookID, targetID string,
|
||||
) (*database.Target, replayOutcomeCode) {
|
||||
) (*database.Target, noticeCode) {
|
||||
var target database.Target
|
||||
|
||||
err := h.db.DB().Unscoped().Where(
|
||||
@@ -299,80 +251,26 @@ func countInFlightDeliveries(
|
||||
return count, err
|
||||
}
|
||||
|
||||
// createReplayDelivery writes the new pending delivery row 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.Omit(clause.Associations).Create(dlv).Error
|
||||
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
|
||||
}
|
||||
|
||||
// finishReplay redirects back to the event log the replay was
|
||||
// triggered from, carrying the outcome code the page turns into a
|
||||
// banner and the page number the form submitted.
|
||||
func (h *Handlers) finishReplay(
|
||||
// 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 replayOutcomeCode,
|
||||
code noticeCode,
|
||||
) {
|
||||
dest := "/source/" + webhook.ID + "/logs?" +
|
||||
replayOutcomeParam + "=" + string(code)
|
||||
location := 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)
|
||||
show := r.PostFormValue(showParam)
|
||||
if eventLogStatuses(show) != nil {
|
||||
location += "&" + showParam + "=" + show
|
||||
}
|
||||
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
|
||||
w, r, location, http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user