All checks were successful
check / check (push) Successful in 3m26s
max_retries was read through parseNonNegativeInt, which returned 0 for any parse failure. On the create form `abc`, `2.7`, `-5` and a twenty-digit number were all accepted with HTTP 200 and stored as 0, and `999999999` was stored verbatim with no ceiling. On the edit form the same input destroyed a working retry configuration: a target delivering with max_retries=2, re-saved with a typo in the field, was silently left at 0 — fire-and-forget on a store-and-forward proxy, with nothing said. A value that is set but unparseable must be rejected loudly. A default belongs only to an absent value. parseMaxRetries makes that distinction explicit: an empty or omitted field yields the caller's fallback (0 at creation, the stored count on edit), and anything else that is not a whole number in range is a 400. Both forms go through one validator, so they cannot come to disagree. The ceiling is 20, which both target templates have always declared as max="20" on the input; only the server never enforced it. Backoff is 2^(n-1) seconds, so attempt 20 is already about six days out, and each attempt writes a delivery_results row the event log then loads and renders. The rejection wording matches the timeout control on the same submission, which already got this right, and names the ceiling when the value is out of range. Existing rows above the ceiling are untouched: they still render on the source and edit pages and still deliver. This is input validation, not a migration. parseNonNegativeInt is removed. Its other two callers read the log page number for a post-action redirect, where falling back to page 1 is correct — it is navigation, not stored configuration, and the action has already completed. They now use pageOrFirst, named for what it does and shared with parsePage on the GET side, so no general silently-coercing int parser is left for a configuration field to reach for.
230 lines
6.4 KiB
Go
230 lines
6.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"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, and tmplKeyMaxTimeout for the timeout ceiling the form
|
|
// tells the user about.
|
|
const (
|
|
tmplKeyTarget = "Target"
|
|
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.
|
|
//
|
|
// It carries the target's row fields alongside its UNMASKED
|
|
// configuration, and 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
|
|
MaxRetries int
|
|
Config delivery.TargetConfigForm
|
|
}
|
|
|
|
// 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
|
|
// /source/{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
|
|
}
|
|
|
|
h.renderTargetEdit(w, r, webhook, target, cfg, msg)
|
|
}
|
|
}
|
|
|
|
// HandleTargetEditSubmit handles the target edit form submission.
|
|
func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
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 {
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
h.applyTargetEdit(w, r, webhook, target)
|
|
}
|
|
}
|
|
|
|
// applyTargetEdit validates and saves target edits.
|
|
//
|
|
// The submitted configuration goes through buildTargetConfig, the
|
|
// same builder the create path uses, 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,
|
|
) {
|
|
name := r.PostFormValue("name")
|
|
if name == "" {
|
|
http.Error(
|
|
w, "Name is required", http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
configJSON, err := h.buildTargetConfig(
|
|
w, r, target.Type, targetFormInputFrom(r),
|
|
)
|
|
if err != nil {
|
|
// buildTargetConfig has already written the response.
|
|
return
|
|
}
|
|
|
|
// Retries are offered only by the forms for target types that
|
|
// retry, so an absent field means "this form does not edit
|
|
// retries" rather than "set them to zero". Reading it
|
|
// unconditionally would silently disable retries on any target
|
|
// saved from a form that does not render the input.
|
|
//
|
|
// A field that IS submitted but does not parse is a 400, through
|
|
// the same validator the create path uses. It is rejected before
|
|
// anything is written, so a typo cannot destroy the retry count
|
|
// the target is already delivering with.
|
|
if r.PostForm.Has("max_retries") {
|
|
retries, ok := targetMaxRetries(w, r, target.MaxRetries)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
target.MaxRetries = retries
|
|
}
|
|
|
|
target.Name = name
|
|
target.Config = configJSON
|
|
|
|
err = h.db.DB().Save(target).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to update target", err)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
w, r, "/source/"+webhook.ID, http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// renderTargetEdit renders the target edit page with an optional
|
|
// error message.
|
|
func (h *Handlers) renderTargetEdit(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
webhook database.Webhook,
|
|
target *database.Target,
|
|
cfg delivery.TargetConfigForm,
|
|
errMsg string,
|
|
) {
|
|
// 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,
|
|
MaxRetries: target.MaxRetries,
|
|
Config: cfg,
|
|
},
|
|
tmplKeyMaxTimeout: delivery.MaxTargetTimeoutSeconds,
|
|
tmplKeyError: errMsg,
|
|
}
|
|
|
|
h.renderTemplate(w, r, targetEditTemplate, data)
|
|
}
|
|
|
|
// 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 {
|
|
http.NotFound(w, r)
|
|
|
|
return database.Webhook{}, nil, false
|
|
}
|
|
|
|
return webhook, &target, true
|
|
}
|