Keep what was typed when a target or webhook edit is refused (closes #381)
check / check (push) Successful in 3m17s
check / check (push) Successful in 3m17s
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
This commit was merged in pull request #474.
This commit is contained in:
@@ -616,13 +616,13 @@ func (h *Handlers) renderSourceDetail(
|
||||
// Targets are projected to a display-safe view: a
|
||||
// target's stored config blob holds a credential, and it
|
||||
// must never reach a template.
|
||||
"Entrypoints": entrypointViews,
|
||||
"Targets": h.targetRows(&webhook, targets),
|
||||
"Events": events,
|
||||
"BaseURL": baseURL,
|
||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||
"TargetForm": targetForm,
|
||||
"TargetError": targetErr,
|
||||
"Entrypoints": entrypointViews,
|
||||
"Targets": h.targetRows(&webhook, targets),
|
||||
"Events": events,
|
||||
"BaseURL": baseURL,
|
||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||
tmplKeyTargetForm: targetForm,
|
||||
"TargetError": targetErr,
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
@@ -658,12 +658,12 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
tmplKeyError: "",
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_edit.html", data)
|
||||
h.renderWebhookEdit(
|
||||
w, r, &webhook,
|
||||
webhook.Name, webhook.Description,
|
||||
strconv.Itoa(webhook.RetentionDays),
|
||||
"", http.StatusOK,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,7 +709,8 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// applyWebhookEdit validates and saves webhook edits.
|
||||
// applyWebhookEdit validates and saves webhook edits. A refused save
|
||||
// shows the edit form again with the values submitted and the reason.
|
||||
func (h *Handlers) applyWebhookEdit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
@@ -718,52 +719,52 @@ func (h *Handlers) applyWebhookEdit(
|
||||
// The body size cap is enforced by the MaxBodySize middleware,
|
||||
// which runs before CSRF parses the form.
|
||||
name := r.PostFormValue("name")
|
||||
if name == "" {
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: "Name is required",
|
||||
}
|
||||
description := r.PostFormValue("description")
|
||||
retention := r.PostFormValue("retention_days")
|
||||
|
||||
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
|
||||
if name == "" {
|
||||
h.renderWebhookEdit(
|
||||
w, r, webhook, name, description, retention,
|
||||
"Name is required", http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
oldName := webhook.Name
|
||||
webhook.Name = name
|
||||
webhook.Description = r.PostFormValue("description")
|
||||
|
||||
// An empty field falls back to the stored value, so submitting the
|
||||
// form without touching retention leaves the policy alone.
|
||||
retentionDays, errMsg := parseRetentionDays(
|
||||
r.PostFormValue("retention_days"), webhook.RetentionDays,
|
||||
retention, webhook.RetentionDays,
|
||||
)
|
||||
if errMsg != "" {
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: errMsg,
|
||||
}
|
||||
|
||||
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusBadRequest)
|
||||
h.renderWebhookEdit(
|
||||
w, r, webhook, name, description, retention,
|
||||
errMsg, http.StatusBadRequest,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhook.RetentionDays = retentionDays
|
||||
// edited is the webhook as the submission leaves it; webhook stays
|
||||
// as stored, for the page shown again when the save is refused.
|
||||
edited := *webhook
|
||||
edited.Name = name
|
||||
edited.Description = description
|
||||
edited.RetentionDays = retentionDays
|
||||
|
||||
// A new name renames the archive files before it is saved (see
|
||||
// delivery.Engine.Rename). If either step fails, the same targets'
|
||||
// archives go back to the name that is still stored, without
|
||||
// reading the main database again.
|
||||
targets, err := h.renameWebhookArchives(
|
||||
webhook.ID, oldName, webhook.Name,
|
||||
webhook.ID, webhook.Name, edited.Name,
|
||||
)
|
||||
if err == nil {
|
||||
err = h.db.DB().Save(webhook).Error
|
||||
err = h.db.DB().Save(&edited).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
restoreErr := h.renameArchives(targets, oldName)
|
||||
restoreErr := h.renameArchives(targets, webhook.Name)
|
||||
if restoreErr != nil {
|
||||
h.log.Error(
|
||||
"failed to rename archives back",
|
||||
@@ -773,15 +774,14 @@ func (h *Handlers) applyWebhookEdit(
|
||||
}
|
||||
|
||||
if errors.Is(err, delivery.ErrArchiveNameTaken) {
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: "Not saved: " + err.Error() +
|
||||
". Move that archive out of the data directory, " +
|
||||
"its .db together with any -wal and -shm beside " +
|
||||
h.renderWebhookEdit(
|
||||
w, r, webhook, name, description, retention,
|
||||
"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.",
|
||||
}
|
||||
|
||||
h.renderTemplateStatus(w, r, "source_edit.html", data, http.StatusConflict)
|
||||
http.StatusConflict,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -797,6 +797,27 @@ func (h *Handlers) applyWebhookEdit(
|
||||
)
|
||||
}
|
||||
|
||||
// renderWebhookEdit renders the webhook edit page for the webhook as
|
||||
// stored, its form showing name, description and retentionDays, with
|
||||
// an optional error message above it.
|
||||
func (h *Handlers) renderWebhookEdit(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook *database.Webhook,
|
||||
name, description, retentionDays, errMsg string,
|
||||
status int,
|
||||
) {
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: webhook,
|
||||
tmplKeyError: errMsg,
|
||||
"Name": name,
|
||||
"Description": description,
|
||||
"RetentionDays": retentionDays,
|
||||
}
|
||||
|
||||
h.renderTemplateStatus(w, r, "source_edit.html", data, status)
|
||||
}
|
||||
|
||||
// HandleSourceDelete handles webhook deletion.
|
||||
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1668,49 +1689,57 @@ func (h *Handlers) newTarget(
|
||||
webhookID string,
|
||||
in targetFormInput,
|
||||
) (*database.Target, string, error) {
|
||||
if in.Name == "" {
|
||||
return nil, "Name is required", nil
|
||||
target := &database.Target{
|
||||
WebhookID: webhookID,
|
||||
Type: in.Type,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if !isValidTargetType(in.Type) {
|
||||
return nil, "Invalid target type", nil
|
||||
}
|
||||
|
||||
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
|
||||
errMsg, err := h.setTargetFromForm(ctx, target, in)
|
||||
if err != nil || errMsg != "" {
|
||||
return nil, errMsg, err
|
||||
}
|
||||
|
||||
// A new target has no stored retry count, so an absent field
|
||||
// takes the fire-and-forget default. A field the operator filled
|
||||
// in with something invalid is refused rather than becoming
|
||||
// that default.
|
||||
maxRetries, err := parseMaxRetries(in.MaxRetries, 0)
|
||||
if err != nil {
|
||||
return nil, "Invalid max retries: " + retriesErrorMessage(err), nil
|
||||
}
|
||||
|
||||
return &database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: in.Name,
|
||||
Type: in.Type,
|
||||
Active: true,
|
||||
Config: configJSON,
|
||||
MaxRetries: maxRetries,
|
||||
}, "", nil
|
||||
return target, "", nil
|
||||
}
|
||||
|
||||
// isValidTargetType checks whether the target type is supported.
|
||||
func isValidTargetType(tt database.TargetType) bool {
|
||||
switch tt {
|
||||
case database.TargetTypeHTTP,
|
||||
database.TargetTypeDatabase,
|
||||
database.TargetTypeLog,
|
||||
database.TargetTypeSlack:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
// setTargetFromForm validates a target form against the target's type
|
||||
// and, when it accepts it, sets the target's name, configuration and
|
||||
// retry count from it. It returns the message the form shows for
|
||||
// anything it refuses, an unknown type among them, and then leaves the
|
||||
// target unchanged; an error is the server's fault, as for newTarget.
|
||||
// The add target form and the target edit form both go through here,
|
||||
// so the two cannot come to disagree about what a target may be.
|
||||
func (h *Handlers) setTargetFromForm(
|
||||
ctx context.Context,
|
||||
target *database.Target,
|
||||
in targetFormInput,
|
||||
) (string, error) {
|
||||
if in.Name == "" {
|
||||
return "Name is required", nil
|
||||
}
|
||||
|
||||
configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in)
|
||||
if err != nil || errMsg != "" {
|
||||
return errMsg, err
|
||||
}
|
||||
|
||||
// An empty max_retries keeps the target's count: the
|
||||
// fire-and-forget default of 0 for a new target, and the stored
|
||||
// count for an edited one, since the forms for target types that
|
||||
// do not retry have no such field. A value that is filled in but
|
||||
// invalid is refused rather than becoming that count, so a typo
|
||||
// cannot destroy the count a target is delivering with.
|
||||
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
|
||||
if err != nil {
|
||||
return "Invalid max retries: " + retriesErrorMessage(err), nil
|
||||
}
|
||||
|
||||
target.Name = in.Name
|
||||
target.Config = configJSON
|
||||
target.MaxRetries = maxRetries
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// pageOrFirst parses a paginated page number, answering 1 for
|
||||
@@ -1732,9 +1761,10 @@ func pageOrFirst(s string) int {
|
||||
}
|
||||
|
||||
// targetFormInput carries the raw values of a target form. Both the
|
||||
// create and the edit path fill one and hand it to buildTargetConfig,
|
||||
// so neither can come to validate a destination differently from the
|
||||
// other. A refused add target form is shown again from it.
|
||||
// create and the edit path fill one and hand it to setTargetFromForm,
|
||||
// so neither can come to validate a target differently from the
|
||||
// other. Both forms are filled from one: the edit form with the
|
||||
// stored values, and a refused form with the values submitted.
|
||||
type targetFormInput struct {
|
||||
// Name is the target's name.
|
||||
Name string
|
||||
|
||||
Reference in New Issue
Block a user