222 lines
6.1 KiB
Go
222 lines
6.1 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
|
|
}
|
|
|
|
target.Name = name
|
|
target.Config = configJSON
|
|
|
|
// 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.
|
|
if r.PostForm.Has("max_retries") {
|
|
target.MaxRetries = parseNonNegativeInt(
|
|
r.PostFormValue("max_retries"),
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|