package handlers import ( "errors" "net/http" "strconv" "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, 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" ) // 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: the // target's row fields as stored. The values the form shows, the // UNMASKED configuration among them, come separately, as a // targetFormInput. // // It 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 } // 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 // /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. 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 } form := targetFormInput{ Name: target.Name, URL: cfg.URL, Headers: cfg.Headers, Timeout: cfg.Timeout, MaxRetries: strconv.Itoa(target.MaxRetries), Expiry: cfg.Expiry, } h.renderTargetEdit( w, r, webhook, target, form, msg, http.StatusOK, ) } } // HandleTargetEditSubmit handles the target edit form submission. func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.renameMu.Lock() defer h.renameMu.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 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, ) { 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, err := h.setTargetFromForm(r.Context(), &edited, in) if err != nil { h.serverError(w, r, "failed to encode target config", err) return } 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 // delivery.Engine.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, delivery.ErrArchiveNameTaken) { 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, } 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 }