Files
webhooker/internal/handlers/target_edit.go
T
clawbot bccafd485c Empty the add target form on Cancel; encoding failures stay a 500
The form's reason and values now come from the targetForm component,
loaded from the section's data attributes after a refusal and emptied
by Cancel, which also resets the form. Each type's fields used to be
recreated with the refused values written into the markup, so they
came back after Cancel. The browser test checks this after a refusal.

A target configuration that cannot be encoded is again a logged 500
with the generic error page, on the add and the edit path; only
refusals of submitted values come back on the form.

The README paragraph on the browser test is re-wrapped at 80 columns
and names Cancel at the type step.

Model: opus-5-5
2026-10-02 19:51:56 +00:00

285 lines
7.8 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, errMsg, err := h.buildTargetConfig(
r.Context(), target.Type, targetFormInputFrom(r),
)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
http.Error(w, errMsg, http.StatusBadRequest)
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
}