Files
webhooker/internal/handlers/target_edit.go
T
clawbot e512137f1e
check / check (push) Waiting to run
Store a target's settings as columns (closes #543)
A target's settings move from the per-type JSON string in
targets.config to columns: url, headers, timeout_seconds,
forward_query, archive_expiry and archive_rotation. A column a type
does not use stays empty. headers holds the header map as JSON text
through GORM's JSON serializer, and archive_expiry holds a
time.Duration, 0 meaning never, so nothing that reads a target parses
it. The forms check each setting once, with the same refusals and
messages, and the edit form is filled from the columns, so the
per-type config structs, their parsers and the "could not be read"
branches are gone. A delivery task carries the six settings in place
of the JSON string.

Model: opus-5-5
2026-10-15 20:01:06 +00:00

261 lines
7.5 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/archive"
"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"
)
// targetEditView is the display model for the target edit page: the
// target's row fields as stored. The values the form shows, the
// UNMASKED URL and headers among them, come separately, as a
// targetFormInput.
//
// It deliberately omits database.Target's settings: the form renders
// them from the targetFormInput, and giving the template the target's
// URL and headers 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, filled with the
// target's settings as stored.
//
// This page is the one place the full destination URL and header
// values are shown: an operator cannot correct a value they cannot
// see. 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. Do not fill a
// form like this one on any other page.
func (h *Handlers) HandleTargetEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, target, ok := h.ownedTarget(w, r)
if !ok {
return
}
form := targetFormInput{
Name: target.Name,
URL: target.URL,
Headers: delivery.FormatTargetHeaders(target.Headers),
Timeout: delivery.FormatTargetTimeout(target.TimeoutSeconds),
ForwardQuery: target.ForwardQuery,
MaxAttempts: strconv.Itoa(target.MaxAttempts),
Expiry: formatArchiveExpiry(target.ArchiveExpiry),
Rotation: target.ArchiveRotation,
}
h.renderTargetEdit(
w, r, webhook, target, form, "", http.StatusOK,
)
}
}
// HandleTargetEditSubmit handles the target edit form submission.
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.archives.RenameLock().Lock()
defer h.archives.RenameLock().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 uses different
// settings 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 settings are set.
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 := h.setTargetFromForm(r.Context(), &edited, in)
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
// archive.Writers.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, archive.ErrNameTaken) {
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,
tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry),
tmplKeyArchiveRotationChoices: archiveRotationOptions(
form.Rotation,
),
}
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
}