Files

140 lines
4.8 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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,
}, ""
}