check / check (push) Waiting to run
A refused save on the target edit page now shows the edit form again, with the reason above it and every value submitted, instead of a bare text page; the status codes are unchanged. The webhook edit page keeps the submitted name, description and retention the same way, while the page still reports the stored retention. Target edits are validated by setTargetFromForm, which newTarget now uses too, so the add and edit forms accept and refuse the same things. An empty max_retries keeps the target's own count. The browser test also saves both edit pages with refused values. Model: opus-5-5
279 lines
7.7 KiB
Go
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
|
|
}
|