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 }