// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "net/http" "net/url" "slices" "strings" "sourcedock.dev/petrbalvin/volumen/internal/payloads" "sourcedock.dev/petrbalvin/volumen/internal/webhooks" ) // fileHooks reads the admin-managed hook store. func (a *Admin) fileHooks() ([]webhooks.Webhook, error) { if a.deps.WebhooksFile == "" { return nil, nil } return webhooks.LoadFile(a.deps.WebhooksFile) } // refreshWebhooks re-saves the store and applies the merged hook set to // the manager, so a settings change delivers without a restart. func (a *Admin) refreshWebhooks(hooks []webhooks.Webhook) error { if err := webhooks.SaveFile(a.deps.WebhooksFile, hooks); err != nil { return err } merged := make([]webhooks.Webhook, 0, len(a.deps.StaticWebhooks)+len(hooks)) merged = append(merged, a.deps.StaticWebhooks...) merged = append(merged, hooks...) a.deps.Webhooks.SetHooks(merged) return nil } // handleSettingsWebhookAdd adds one endpoint to the store. Config-declared // hooks are the operator's business and stay read-only; this list is the // admin's to manage. func (a *Admin) handleSettingsWebhookAdd(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } file, err := a.fileHooks() if err != nil { a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity) return } hook, problem := validateHookInput( r.PostFormValue("url"), strings.TrimSpace(r.PostFormValue("secret")), r.PostFormValue("events"), r.PostFormValue("enabled") == "on", append(slices.Clone(a.deps.StaticWebhooks), file...), ) if problem != "" { a.renderSettings(w, r, a.tr(r, problem), "", http.StatusUnprocessableEntity) return } if err := a.refreshWebhooks(append(file, hook)); err != nil { a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity) return } a.record(r, "webhook.added", hook.URL, nil) a.renderSettings(w, r, "", a.tr(r, "Webhook added."), http.StatusOK) } // handleSettingsWebhookToggle flips one stored hook's enabled flag. The // URL names the hook, because the row the form was rendered from may no // longer be at its old index by the time the POST lands. func (a *Admin) handleSettingsWebhookToggle(w http.ResponseWriter, r *http.Request) { a.mutateStoredHook(w, r, "webhook.updated", "Webhook updated.", func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) { for i, hook := range hooks { if hook.URL == url { hooks[i].Enabled = !hook.Enabled return hooks, true } } return hooks, false }) } func (a *Admin) handleSettingsWebhookDelete(w http.ResponseWriter, r *http.Request) { a.mutateStoredHook(w, r, "webhook.deleted", "Webhook removed.", func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) { for i, hook := range hooks { if hook.URL == url { return slices.Delete(hooks, i, i+1), true } } return hooks, false }) } // mutateStoredHook applies a change to the stored hook the form's url // field names, re-saves, and refreshes the manager. func (a *Admin) mutateStoredHook(w http.ResponseWriter, r *http.Request, auditAction, notice string, apply func([]webhooks.Webhook, string) ([]webhooks.Webhook, bool)) { if !a.requireCSRF(w, r) { return } file, err := a.fileHooks() if err != nil { a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity) return } target := r.PostFormValue("url") changed, ok := apply(file, target) if !ok { a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity) return } if err := a.refreshWebhooks(changed); err != nil { a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity) return } a.record(r, auditAction, target, nil) a.renderSettings(w, r, "", a.tr(r, notice), http.StatusOK) } // validateHookInput checks the add form: an absolute http(s) URL no // configured hook already uses, an optional secret, and the event // filter as a comma-separated list (empty delivers everything). func validateHookInput(raw, secret, events string, enabled bool, existing []webhooks.Webhook) (webhooks.Webhook, string) { raw = strings.TrimSpace(raw) u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return webhooks.Webhook{}, "That URL is not a valid http(s) endpoint." } for _, hook := range existing { if hook.URL == raw { return webhooks.Webhook{}, "That URL is already configured." } } return webhooks.Webhook{ URL: raw, Secret: secret, Events: payloads.ParseTags(events), Enabled: enabled, }, "" }