check / check (push) Waiting to run
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
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
|
|
}
|