check / check (push) Waiting to run
Each database target now has its own archive file, archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, instead of one archive-WEBHOOKID.db per webhook. delivery.ArchiveFileName builds the name. A change of webhook or target name renames its archive files under the archive writer's lock, before the new name is saved, and back again if the save fails. Webhook edits, target edits and target creation run one at a time, so no two of them interleave. A rename never replaces a file, and one that fails part way moves back what it moved. Deleting a target evicts only that target's writer. Archive files are never deleted, and nothing looks for files under the old name. Model: opus-5-5
280 lines
7.7 KiB
Go
280 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
)
|
|
|
|
// targetEditTemplate is the page the target edit form renders.
|
|
const targetEditTemplate = "target_edit.html"
|
|
|
|
// tmplKeyTarget is the template data key for the target being
|
|
// edited, and tmplKeyMaxTimeout for the timeout ceiling the form
|
|
// tells the user about.
|
|
const (
|
|
tmplKeyTarget = "Target"
|
|
tmplKeyMaxTimeout = "MaxTimeout"
|
|
)
|
|
|
|
// configUnreadableMessage is shown when a target's stored
|
|
// configuration does not parse. It says plainly that saving replaces
|
|
// the stored value rather than preserving it, because the form
|
|
// cannot pre-fill what it could not read.
|
|
const configUnreadableMessage = "The stored configuration for this " +
|
|
"target could not be read. Enter the values below; saving " +
|
|
"replaces the stored configuration."
|
|
|
|
// targetEditView is the display model for the target edit page.
|
|
//
|
|
// It carries the target's row fields alongside its UNMASKED
|
|
// configuration, and deliberately omits database.Target's raw
|
|
// Config blob: the form renders named fields, and giving the
|
|
// template the blob as well would put an unreviewed second path to
|
|
// the credential on the page.
|
|
type targetEditView struct {
|
|
ID string
|
|
Name string
|
|
Type database.TargetType
|
|
Active bool
|
|
MaxRetries int
|
|
Config delivery.TargetConfigForm
|
|
}
|
|
|
|
// HandleTargetEdit shows the form to edit a target.
|
|
//
|
|
// This page is the one place the full destination URL and header
|
|
// values are shown. It is reachable only through the
|
|
// /hook/{sourceID} route group, which supplies RequireAuth and
|
|
// NoCache, and only for a target of a webhook the session's user
|
|
// owns; masking (delivery.TargetView) is unchanged everywhere else.
|
|
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
webhook, target, ok := h.ownedTarget(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
cfg, err := delivery.NewTargetConfigForm(target)
|
|
msg := ""
|
|
|
|
if err != nil {
|
|
// The error carries the parse failure, never the
|
|
// blob, so it is safe to log against the target id.
|
|
h.log.Warn(
|
|
"stored target config could not be read for editing",
|
|
"target_id", target.ID,
|
|
"error", err,
|
|
)
|
|
|
|
msg = configUnreadableMessage
|
|
}
|
|
|
|
h.renderTargetEdit(w, r, webhook, target, cfg, msg)
|
|
}
|
|
}
|
|
|
|
// HandleTargetEditSubmit handles the target edit form submission.
|
|
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
h.renameMu.Lock()
|
|
defer h.renameMu.Unlock()
|
|
|
|
webhook, target, ok := h.ownedTarget(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.applyTargetEdit(w, r, webhook, target)
|
|
}
|
|
}
|
|
|
|
// applyTargetEdit validates and saves target edits.
|
|
//
|
|
// The submitted configuration goes through buildTargetConfig, the
|
|
// same builder the create path uses, so an edited destination is
|
|
// SSRF-validated exactly as a new one is.
|
|
//
|
|
// The target's type is not editable. Each type stores a different
|
|
// configuration shape and its delivery history is recorded against
|
|
// the target row, so changing the type of an existing target is
|
|
// really the creation of a different one. The stored type decides
|
|
// which fields the form offers and which builder runs.
|
|
func (h *Handlers) applyTargetEdit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
target *database.Target,
|
|
) {
|
|
name := r.PostFormValue("name")
|
|
if name == "" {
|
|
http.Error(
|
|
w, "Name is required", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
configJSON, err := h.buildTargetConfig(
|
|
w, r, target.Type, targetFormInputFrom(r),
|
|
)
|
|
if err != nil {
|
|
// buildTargetConfig has already written the response.
|
|
return
|
|
}
|
|
|
|
// Retries are offered only by the forms for target types that
|
|
// retry, so an absent field means "this form does not edit
|
|
// retries" rather than "set them to zero". Reading it
|
|
// unconditionally would silently disable retries on any target
|
|
// saved from a form that does not render the input.
|
|
//
|
|
// A field that IS submitted but does not parse is a 400, through
|
|
// the same validator the create path uses. It is rejected before
|
|
// anything is written, so a typo cannot destroy the retry count
|
|
// the target is already delivering with.
|
|
if r.PostForm.Has("max_retries") {
|
|
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
target.MaxRetries = retries
|
|
}
|
|
|
|
oldName := target.Name
|
|
target.Name = name
|
|
target.Config = configJSON
|
|
|
|
// A new name renames the archive file before it is saved (see
|
|
// delivery.Engine.Rename). If either step fails, it goes back to
|
|
// the name that is still stored.
|
|
err = h.renameTargetArchive(target, webhook.Name, oldName, name)
|
|
if err == nil {
|
|
err = h.db.DB().Save(target).Error
|
|
}
|
|
|
|
if err != nil {
|
|
restoreErr := h.renameTargetArchive(
|
|
target, webhook.Name, name, oldName,
|
|
)
|
|
if restoreErr != nil {
|
|
h.log.Error(
|
|
"failed to rename archive back",
|
|
"target_id", target.ID,
|
|
"error", restoreErr,
|
|
)
|
|
}
|
|
|
|
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
|
http.Error(
|
|
w,
|
|
"Not saved: "+err.Error()+
|
|
". Move that archive out of the data directory, "+
|
|
"its .db together with any -wal and -shm beside "+
|
|
"it, then save again.",
|
|
http.StatusConflict,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.serverError(w, r, "failed to update target", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// renameTargetArchive renames a database target's archive file from
|
|
// the target name oldName to newName. It does nothing when the name
|
|
// is unchanged; other target types have no archive.
|
|
func (h *Handlers) renameTargetArchive(
|
|
target *database.Target,
|
|
webhookName, oldName, newName string,
|
|
) error {
|
|
if h.archives == nil || oldName == newName ||
|
|
target.Type != database.TargetTypeDatabase {
|
|
return nil
|
|
}
|
|
|
|
return h.archives.Rename(target.ID, webhookName, newName)
|
|
}
|
|
|
|
// renderTargetEdit renders the target edit page with an optional
|
|
// error message.
|
|
func (h *Handlers) renderTargetEdit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
target *database.Target,
|
|
cfg delivery.TargetConfigForm,
|
|
errMsg string,
|
|
) {
|
|
// The template calls Webhook methods, which take pointer
|
|
// receivers; html/template cannot address a value stored in a
|
|
// map.
|
|
data := map[string]any{
|
|
tmplKeyWebhook: &webhook,
|
|
tmplKeyTarget: targetEditView{
|
|
ID: target.ID,
|
|
Name: target.Name,
|
|
Type: target.Type,
|
|
Active: target.Active,
|
|
MaxRetries: target.MaxRetries,
|
|
Config: cfg,
|
|
},
|
|
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
|
tmplKeyError: errMsg,
|
|
}
|
|
|
|
h.renderTemplate(w, r, targetEditTemplate, data)
|
|
}
|
|
|
|
// ownedTarget resolves the request's sourceID and targetID
|
|
// parameters to a target of a webhook the session's user owns.
|
|
//
|
|
// Ownership is decided by the webhook, and the target is then
|
|
// scoped to that webhook, so a target id belonging to someone
|
|
// else's webhook is a 404 rather than an edit of their target. It
|
|
// reports false once it has written the response.
|
|
func (h *Handlers) ownedTarget(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) (database.Webhook, *database.Target, bool) {
|
|
webhook, ok := h.ownedWebhook(w, r)
|
|
if !ok {
|
|
return database.Webhook{}, nil, false
|
|
}
|
|
|
|
var target database.Target
|
|
|
|
err := h.db.DB().Where(
|
|
"id = ? AND webhook_id = ?",
|
|
chi.URLParam(r, "targetID"), webhook.ID,
|
|
).First(&target).Error
|
|
if err != nil {
|
|
h.renderError(w, r, http.StatusNotFound)
|
|
|
|
return database.Webhook{}, nil, false
|
|
}
|
|
|
|
return webhook, &target, true
|
|
}
|