87 lines
2.3 KiB
Go
87 lines
2.3 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||
|
|
|
||
|
|
package webhooks
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
|
||
|
|
)
|
||
|
|
|
||
|
|
// File is the admin-managed hook store: webhooks.toml beside the users
|
||
|
|
// file. The admin edits it through the settings forms, so a change
|
||
|
|
// applies without a restart; the file is written atomically with
|
||
|
|
// owner-only permissions, like the other state files.
|
||
|
|
type File struct {
|
||
|
|
Path string
|
||
|
|
}
|
||
|
|
|
||
|
|
// LoadFile reads the hook store. A missing file means no hooks, not an
|
||
|
|
// error; a malformed one is, because silently dropping the operator's
|
||
|
|
// endpoints would hide a real fault.
|
||
|
|
func LoadFile(path string) ([]Webhook, error) {
|
||
|
|
raw, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
if os.IsNotExist(err) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
||
|
|
}
|
||
|
|
data, err := interpres.ParseMap(raw)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||
|
|
}
|
||
|
|
var out []Webhook
|
||
|
|
for _, entry := range tomlfile.Tables(data["webhooks"]) {
|
||
|
|
url := tomlfile.String(entry["url"])
|
||
|
|
if url == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out = append(out, Webhook{
|
||
|
|
URL: url,
|
||
|
|
Secret: tomlfile.String(entry["secret"]),
|
||
|
|
Events: stringList(entry["events"]),
|
||
|
|
Enabled: tomlfile.Bool(entry["enabled"], true),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SaveFile replaces the hook store with the given hooks.
|
||
|
|
func SaveFile(path string, hooks []Webhook) error {
|
||
|
|
rows := make([]map[string]any, 0, len(hooks))
|
||
|
|
for _, hook := range hooks {
|
||
|
|
rows = append(rows, map[string]any{
|
||
|
|
"url": hook.URL,
|
||
|
|
"secret": hook.Secret,
|
||
|
|
"events": hook.Events,
|
||
|
|
"enabled": hook.Enabled,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if err := tomlfile.Write(path, "webhooks", rows); err != nil {
|
||
|
|
return fmt.Errorf("write %s: %w", path, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// stringList reads a string array that may arrive as either the []any
|
||
|
|
// the parser produces or a typed slice.
|
||
|
|
func stringList(value any) []string {
|
||
|
|
switch list := value.(type) {
|
||
|
|
case []string:
|
||
|
|
return list
|
||
|
|
case []any:
|
||
|
|
out := make([]string, 0, len(list))
|
||
|
|
for _, item := range list {
|
||
|
|
if s, ok := item.(string); ok && s != "" {
|
||
|
|
out = append(out, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|