Files
webhooker/internal/handlers/target_edit.go
T
clawbot f282c6363d
check / check (push) Successful in 3m17s
Keep what was typed when a target or webhook edit is refused (closes #381)
A refused save on the target edit page answered with a bare text page, losing the form and everything typed, and the webhook edit page came back with the stored values instead of the submitted ones. A refused target edit now shows the edit form again with the reason above it and every value submitted, with the same status codes as before; a refused webhook edit keeps the submitted name, description and retention. Target edits use the same validation as new targets, with no second copy; an encoding or database failure stays a logged 500. The browser test covers a refused save on both pages, and its main function is now a plain list of checks.

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

279 lines
7.7 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"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, tmplKeyTargetForm for the values its form shows, and
// tmplKeyMaxTimeout for the timeout ceiling the form tells the user
// about. The add target form on the webhook page takes its values
// under the same key as the edit form.
const (
tmplKeyTarget = "Target"
tmplKeyTargetForm = "TargetForm"
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: the
// target's row fields as stored. The values the form shows, the
// UNMASKED configuration among them, come separately, as a
// targetFormInput.
//
// It 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
}
// 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
}
form := targetFormInput{
Name: target.Name,
URL: cfg.URL,
Headers: cfg.Headers,
Timeout: cfg.Timeout,
MaxRetries: strconv.Itoa(target.MaxRetries),
Expiry: cfg.Expiry,
}
h.renderTargetEdit(
w, r, webhook, target, form, msg, http.StatusOK,
)
}
}
// 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. A refused save
// shows the edit form again with the values submitted and the reason.
//
// The submission goes through setTargetFromForm, as a new target
// does, 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,
) {
in := targetFormInputFrom(r)
// edited is the target as the submission leaves it; target stays
// as stored, for the page shown again when the save is refused.
edited := *target
errMsg, err := h.setTargetFromForm(r.Context(), &edited, in)
if err != nil {
h.serverError(w, r, "failed to encode target config", err)
return
}
if errMsg != "" {
h.renderTargetEdit(
w, r, webhook, target, in, errMsg, http.StatusBadRequest,
)
return
}
// 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, target.Name, edited.Name,
)
if err == nil {
err = h.db.DB().Save(&edited).Error
}
if err != nil {
restoreErr := h.renameTargetArchive(
target, webhook.Name, edited.Name, target.Name,
)
if restoreErr != nil {
h.log.Error(
"failed to rename archive back",
"target_id", target.ID,
"error", restoreErr,
)
}
if errors.Is(err, delivery.ErrArchiveNameTaken) {
h.renderTargetEdit(
w, r, webhook, target, in,
"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 for the target as
// stored, its form showing form's values, with an optional error
// message above it.
func (h *Handlers) renderTargetEdit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
target *database.Target,
form targetFormInput,
errMsg string,
status int,
) {
// 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,
},
tmplKeyTargetForm: form,
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
tmplKeyError: errMsg,
}
h.renderTemplateStatus(w, r, targetEditTemplate, data, status)
}
// 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
}