package handlers import ( "errors" "net/http" "strconv" "github.com/go-chi/chi" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // HandleSourceEdit shows the form to edit a webhook. func (h *Handlers) HandleSourceEdit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } sourceID := chi.URLParam(r, "sourceID") var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { h.renderError(w, r, http.StatusNotFound) return } h.renderWebhookEdit( w, r, &webhook, webhook.Name, webhook.Description, strconv.Itoa(webhook.RetentionDays), "", http.StatusOK, ) } } // HandleSourceEditSubmit handles the webhook edit form // submission. func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, ok := h.getUserID(r) if !ok { http.Redirect( w, r, "/pages/login", http.StatusSeeOther, ) return } sourceID := chi.URLParam(r, "sourceID") h.renameMu.Lock() defer h.renameMu.Unlock() var webhook database.Webhook err := h.db.DB().Where( "id = ? AND user_id = ?", sourceID, userID, ).First(&webhook).Error if err != nil { h.renderError(w, r, http.StatusNotFound) 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.applyWebhookEdit(w, r, &webhook) } } // applyWebhookEdit validates and saves webhook edits. A refused save // shows the edit form again with the values submitted and the reason. func (h *Handlers) applyWebhookEdit( w http.ResponseWriter, r *http.Request, webhook *database.Webhook, ) { // The body size cap is enforced by the MaxBodySize middleware, // which runs before CSRF parses the form. name := r.PostFormValue("name") description := r.PostFormValue("description") retention := r.PostFormValue("retention_days") if name == "" { h.renderWebhookEdit( w, r, webhook, name, description, retention, "Name is required", http.StatusBadRequest, ) return } // An empty field falls back to the stored value, so submitting the // form without touching retention leaves the policy alone. retentionDays, errMsg := parseRetentionDays( retention, webhook.RetentionDays, ) if errMsg != "" { h.renderWebhookEdit( w, r, webhook, name, description, retention, errMsg, http.StatusBadRequest, ) return } // edited is the webhook as the submission leaves it; webhook stays // as stored, for the page shown again when the save is refused. edited := *webhook edited.Name = name edited.Description = description edited.RetentionDays = retentionDays // A new name renames the archive files before it is saved (see // delivery.Engine.Rename). If either step fails, the same targets' // archives go back to the name that is still stored, without // reading the main database again. targets, err := h.renameWebhookArchives( webhook.ID, webhook.Name, edited.Name, ) if err == nil { err = h.db.DB().Save(&edited).Error } if err != nil { restoreErr := h.renameArchives(targets, webhook.Name) if restoreErr != nil { h.log.Error( "failed to rename archives back", "webhook_id", webhook.ID, "error", restoreErr, ) } if errors.Is(err, delivery.ErrArchiveNameTaken) { h.renderWebhookEdit( w, r, webhook, name, description, retention, "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 webhook", err) return } http.Redirect( w, r, withNotice("/hook/"+webhook.ID, webhookSaved), http.StatusSeeOther, ) } // renderWebhookEdit renders the webhook edit page for the webhook as // stored, its form showing name, description and retentionDays, with // an optional error message above it. func (h *Handlers) renderWebhookEdit( w http.ResponseWriter, r *http.Request, webhook *database.Webhook, name, description, retentionDays, errMsg string, status int, ) { data := map[string]any{ tmplKeyWebhook: webhook, tmplKeyError: errMsg, "Name": name, "Description": description, "RetentionDays": retentionDays, } h.renderTemplateStatus(w, r, "source_edit.html", data, status) } // renameWebhookArchives renames the archive file of every database // target of a webhook from the webhook name oldName to newName, // keeping each target's own name. It does nothing when the name is // unchanged. It returns the targets it read, so that a failed edit can // move those same archives back with renameArchives. func (h *Handlers) renameWebhookArchives( webhookID, oldName, newName string, ) ([]database.Target, error) { if h.archives == nil || oldName == newName { return nil, nil } var targets []database.Target err := h.db.DB(). Where( "webhook_id = ? AND type = ?", webhookID, database.TargetTypeDatabase, ). Find(&targets).Error if err != nil { return nil, err } return targets, h.renameArchives(targets, newName) } // renameArchives renames the archive file of each of the given // database targets to the webhook name webhookName, keeping each // target's own name. It tries every target even after one fails, so // that moving the archives back after a failed edit leaves none under // the new name, and returns every failure joined. func (h *Handlers) renameArchives( targets []database.Target, webhookName string, ) error { var errs []error for i := range targets { err := h.archives.Rename( targets[i].ID, webhookName, targets[i].Name, ) if err != nil { errs = append(errs, err) } } return errors.Join(errs...) }