package handlers import ( "errors" "net/http" "strconv" "github.com/go-chi/chi" "sneak.berlin/go/webhooker/internal/archive" "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" ) // targetEditView is the display model for the target edit page: the // target's row fields as stored. The values the form shows, the // UNMASKED URL and headers among them, come separately, as a // targetFormInput. // // It deliberately omits database.Target's settings: the form renders // them from the targetFormInput, and giving the template the target's // URL and headers 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, filled with the // target's settings as stored. // // This page is the one place the full destination URL and header // values are shown: an operator cannot correct a value they cannot // see. 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. Do not fill a // form like this one on any other page. func (h *Handlers) HandleTargetEdit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { webhook, target, ok := h.ownedTarget(w, r) if !ok { return } form := targetFormInput{ Name: target.Name, URL: target.URL, Headers: delivery.FormatTargetHeaders(target.Headers), Timeout: delivery.FormatTargetTimeout(target.TimeoutSeconds), ForwardQuery: target.ForwardQuery, MaxAttempts: strconv.Itoa(target.MaxAttempts), Expiry: formatArchiveExpiry(target.ArchiveExpiry), Rotation: target.ArchiveRotation, } h.renderTargetEdit( w, r, webhook, target, form, "", http.StatusOK, ) } } // HandleTargetEditSubmit handles the target edit form submission. func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.archives.RenameLock().Lock() defer h.archives.RenameLock().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 uses different // settings 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 settings are set. 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 := h.setTargetFromForm(r.Context(), &edited, in) 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 // archive.Writers.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, archive.ErrNameTaken) { 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, tmplKeyArchiveExpiryChoices: archiveExpiryOptions(form.Expiry), tmplKeyArchiveRotationChoices: archiveRotationOptions( form.Rotation, ), } 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 }