Files
webhooker/internal/handlers/event_resubmit.go
sneak cf48b465a6
All checks were successful
check / check (push) Successful in 3m10s
Remove inbound request signature verification (closes #279)
The receiver verified an optional per-entrypoint HMAC or shared token
before accepting a request. That is removed outright: the entrypoint
UUID in the URL is the authentication secret, and possession of it
authorises submission. This reverses the feature added in fcead5d.

Deletes the internal/signature package, the signature_scheme and
signature_secret columns from Entrypoint along with their accessors,
the per-entrypoint secret form and its POST route, and the scheme
labelling in EntrypointView. Pre-1.0 with no installed base, so the
columns simply stop being written; there is no migration and no
compatibility path.

Header sanitisation goes with it. SanitizeHeaders existed to strip a
scheme's own credential header before the header map was stored and
forwarded; with no configured credential there is nothing to strip, so
the receiver marshals the headers as received.

The receiver's other protections are untouched: the 1 MB body cap, the
per-IP rate limiter, the 410 for a deactivated entrypoint and the 404
for an unknown UUID.
2026-08-24 01:12:34 +00:00

274 lines
7.9 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/google/uuid"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// resubmitOutcomeParam is the query parameter the resubmit POST
// redirects with and the event log page reads its banner from.
const resubmitOutcomeParam = "resubmit"
// resubmitOutcomeCode is the outcome of a resubmit 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 resubmitOutcomeCode string
const (
// resubmitQueued reports that a new event was stored and its
// deliveries handed to the delivery engine.
resubmitQueued resubmitOutcomeCode = "queued"
// resubmitNoTargets reports a source with no active targets. The
// new event is stored either way, exactly as a received event
// with no targets is.
resubmitNoTargets resubmitOutcomeCode = "no-targets"
)
// resubmitOutcome returns the banner the event log page shows for an
// outcome code, and whether the resubmit was queued. An unrecognised
// code yields no banner.
func resubmitOutcome(code string) (string, bool) {
switch resubmitOutcomeCode(code) {
case resubmitQueued:
return "Resubmitted: a new event was created from the stored " +
"one and queued to every active target.", true
case resubmitNoTargets:
return "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
true
default:
return "", false
}
}
// resubmitSource is the stored event a resubmit copies. Its body is
// read as bytes rather than as a string so the copy is byte-identical
// to what was received, whatever the payload's encoding.
type resubmitSource struct {
ID string
EntrypointID string
Method string
Headers string
ContentType string
Body []byte
}
// resubmitColumns is the projection resubmitSource is loaded through.
// The cast to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, the same reason eventBodyQuery
// casts.
const resubmitColumns = "id, entrypoint_id, method, headers, " +
"content_type, cast(body as blob) AS body"
// HandleEventResubmit re-injects a stored event as a new undelivered
// event.
//
// This is the testing counterpart to per-delivery replay, and the two
// select targets differently on purpose. A replay re-sends ONE
// finished delivery to ITS OWN target, which is recovery. A resubmit
// stores a NEW event copied from the stored one and fans it out to the
// webhook's currently ACTIVE targets, resolved fresh by the query the
// receiver uses — so a target created after the original event arrived
// receives it, which is what makes capturing real traffic and firing
// it at a backend under development possible. The original event's
// deliveries have no bearing on where the copy goes.
//
// Nothing about the original delivery is re-sent: what is re-injected
// is the stored EVENT. The response bodies and headers the original
// deliveries received stay where they are.
//
// Resubmitting the same event repeatedly is supported and is the point
// of the feature, so replay's in-flight refusal is deliberately not
// applied here. The route's rate limit is what bounds a held-down
// button.
func (h *Handlers) HandleEventResubmit() 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 {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
h.resubmitEvent(w, r, webhook)
}
}
// resubmitEvent performs the resubmit for a webhook the caller has
// already established the session's user owns.
func (h *Handlers) resubmitEvent(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
) {
// Parsing the id before use keeps a malformed id out of the SQL
// and makes the value the query sees come from uuid's own fixed
// alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
return
}
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
return
}
// Read before the write transaction is opened. The body can be up
// to the 1 MB ingest cap, and holding a read of it inside the
// transaction would extend how long the per-webhook database is
// locked against the receiver, which runs these files in
// SQLite's default journal mode rather than WAL.
src, found, err := loadResubmitSource(
webhookDB, webhook.ID, eventID.String(),
)
if err != nil {
h.serverError(w, "failed to load event to resubmit", err)
return
}
// A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed.
if !found {
http.NotFound(w, r)
return
}
h.queueResubmit(w, r, webhook, src)
}
// loadResubmitSource reads the stored event a resubmit copies, and
// whether it exists within the webhook.
//
// The webhook_id predicate is currently redundant against the
// per-webhook database files — a sibling webhook's event is not in the
// database being queried at all — and is there so the scoping survives
// any future change that puts more than one webhook's events in one
// file. Going through Model applies GORM's soft-delete scope, which is
// what stops a reaped event being resubmitted.
func loadResubmitSource(
webhookDB *gorm.DB,
webhookID, eventID string,
) (resubmitSource, bool, error) {
var src resubmitSource
err := webhookDB.Model(&database.Event{}).
Select(resubmitColumns).
Where("id = ? AND webhook_id = ?", eventID, webhookID).
First(&src).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return src, false, nil
}
if err != nil {
return src, false, err
}
return src, true, nil
}
// queueResubmit stores the copy and fans it out to the webhook's
// active targets.
func (h *Handlers) queueResubmit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
src resubmitSource,
) {
// The receiver's own query, run now: an active target created
// after the original event arrived is included, and an
// inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID)
if err != nil {
h.serverError(w, "failed to query targets", err)
return
}
event, tasks, err := h.createAndFanOut(
eventSource{
WebhookID: webhook.ID,
EntrypointID: src.EntrypointID,
Method: src.Method,
HeadersJSON: src.Headers,
ContentType: src.ContentType,
Body: src.Body,
ResubmittedFromID: &src.ID,
},
targets,
)
if err != nil {
h.serverError(w, "failed to store resubmitted event", err)
return
}
h.mtr.EventResubmitted()
h.log.Info(
"event resubmitted",
"webhook_id", webhook.ID,
"event_id", event.ID,
"resubmitted_from_id", src.ID,
"target_count", len(tasks),
)
code := resubmitQueued
if len(tasks) == 0 {
code = resubmitNoTargets
}
h.finishResubmit(w, r, webhook, code)
}
// finishResubmit redirects back to the event log the resubmit was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishResubmit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code resubmitOutcomeCode,
) {
dest := "/source/" + webhook.ID + "/logs?" +
resubmitOutcomeParam + "=" + string(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)
}