1
0
Fork 0
mirror of https://github.com/adnanh/webhook.git synced 2026-07-21 17:29:49 +00:00
webhook/admin_store.go
2026-04-28 13:47:51 +08:00

356 lines
7.9 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/adnanh/webhook/internal/hook"
"github.com/ghodss/yaml"
)
var (
loadedHooksMu sync.RWMutex
errAdminReadOnly = errors.New("admin writes are disabled when -template is enabled")
errUnknownHookFile = errors.New("unknown hooks file")
)
type adminHooksFile struct {
Path string `json:"path"`
Format string `json:"format"`
Writable bool `json:"writable"`
Hooks hook.Hooks `json:"hooks"`
}
type adminHooksState struct {
Files []adminHooksFile `json:"files"`
ReadOnly bool `json:"readOnly"`
ReadOnlyReason string `json:"readOnlyReason,omitempty"`
}
func adminWritesDisabled() bool {
return *asTemplate
}
func adminReadOnlyReason() string {
if !adminWritesDisabled() {
return ""
}
return "editing is disabled while webhook is running with -template because the original template source cannot be preserved safely"
}
func cloneHook(src hook.Hook) hook.Hook {
dst := src
if src.ResponseHeaders != nil {
dst.ResponseHeaders = append(hook.ResponseHeaders(nil), src.ResponseHeaders...)
}
if src.PassEnvironmentToCommand != nil {
dst.PassEnvironmentToCommand = append([]hook.Argument(nil), src.PassEnvironmentToCommand...)
}
if src.PassArgumentsToCommand != nil {
dst.PassArgumentsToCommand = append([]hook.Argument(nil), src.PassArgumentsToCommand...)
}
if src.PassFileToCommand != nil {
dst.PassFileToCommand = append([]hook.Argument(nil), src.PassFileToCommand...)
}
if src.JSONStringParameters != nil {
dst.JSONStringParameters = append([]hook.Argument(nil), src.JSONStringParameters...)
}
if src.HTTPMethods != nil {
dst.HTTPMethods = append([]string(nil), src.HTTPMethods...)
}
if src.MaxConcurrency != nil {
value := *src.MaxConcurrency
dst.MaxConcurrency = &value
}
return dst
}
func cloneHooks(src hook.Hooks) hook.Hooks {
if src == nil {
return nil
}
dst := make(hook.Hooks, len(src))
for i := range src {
dst[i] = cloneHook(src[i])
}
return dst
}
func cloneLoadedHooksMapLocked() map[string]hook.Hooks {
dst := make(map[string]hook.Hooks, len(loadedHooksFromFiles))
for path, hooksInFile := range loadedHooksFromFiles {
dst[path] = cloneHooks(hooksInFile)
}
return dst
}
func hooksFilesSnapshot() []string {
loadedHooksMu.RLock()
defer loadedHooksMu.RUnlock()
return append([]string(nil), []string(hooksFiles)...)
}
func hooksStateSnapshot() adminHooksState {
loadedHooksMu.RLock()
files := append([]string(nil), []string(hooksFiles)...)
loaded := cloneLoadedHooksMapLocked()
loadedHooksMu.RUnlock()
state := adminHooksState{
ReadOnly: adminWritesDisabled(),
ReadOnlyReason: adminReadOnlyReason(),
Files: make([]adminHooksFile, 0, len(files)),
}
for _, path := range files {
hooksInFile := cloneHooks(loaded[path])
if hooksInFile == nil {
hooksInFile = hook.Hooks{}
}
sort.Slice(hooksInFile, func(i, j int) bool {
return hooksInFile[i].ID < hooksInFile[j].ID
})
state.Files = append(state.Files, adminHooksFile{
Path: path,
Format: hooksFileFormat(path),
Writable: !state.ReadOnly,
Hooks: hooksInFile,
})
}
return state
}
func hooksFileFormat(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".yaml", ".yml":
return "yaml"
default:
return "json"
}
}
func marshalHooksFile(path string, hooksInFile hook.Hooks) ([]byte, error) {
var (
data []byte
err error
)
switch hooksFileFormat(path) {
case "yaml":
data, err = yaml.Marshal(hooksInFile)
default:
data, err = json.MarshalIndent(hooksInFile, "", " ")
}
if err != nil {
return nil, err
}
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
}
return data, nil
}
func writeHooksFile(path string, hooksInFile hook.Hooks) error {
data, err := marshalHooksFile(path, hooksInFile)
if err != nil {
return err
}
dir := filepath.Dir(path)
if dir == "" {
dir = "."
}
tmp, err := ioutil.TempFile(dir, filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
mode := os.FileMode(0o644)
if stat, statErr := os.Stat(path); statErr == nil {
mode = stat.Mode()
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
renameErr := os.Rename(tmpName, path)
if renameErr == nil {
return nil
}
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
return renameErr
}
return os.Rename(tmpName, path)
}
func validateUniqueHookIDs(candidate map[string]hook.Hooks) error {
seen := make(map[string]string)
for path, hooksInFile := range candidate {
for _, currentHook := range hooksInFile {
id := strings.TrimSpace(currentHook.ID)
if id == "" {
return fmt.Errorf("hook id is required for hooks file %s", path)
}
if prevPath, ok := seen[id]; ok {
return fmt.Errorf("hook with ID %s is already defined in %s", id, prevPath)
}
seen[id] = path
}
}
return nil
}
func hookIndexByID(hooksInFile hook.Hooks, id string) int {
for i := range hooksInFile {
if hooksInFile[i].ID == id {
return i
}
}
return -1
}
func normalizeAdminHook(current hook.Hook) hook.Hook {
current.ID = strings.TrimSpace(current.ID)
current.ExecuteCommand = strings.TrimSpace(current.ExecuteCommand)
current.CommandWorkingDirectory = strings.TrimSpace(current.CommandWorkingDirectory)
current.CommandTimeout = strings.TrimSpace(current.CommandTimeout)
current.ResponseMessage = strings.TrimSpace(current.ResponseMessage)
if len(current.HTTPMethods) != 0 {
methods := make([]string, 0, len(current.HTTPMethods))
for _, method := range current.HTTPMethods {
method = strings.ToUpper(strings.TrimSpace(method))
if method != "" {
methods = append(methods, method)
}
}
current.HTTPMethods = methods
}
return current
}
func upsertHookInFile(path, currentID string, updated hook.Hook) error {
if adminWritesDisabled() {
return errAdminReadOnly
}
updated = normalizeAdminHook(updated)
if updated.ID == "" {
return errors.New("hook id is required")
}
if err := updated.ValidateExecutionSettings(); err != nil {
return err
}
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
existingHooks, ok := loadedHooksFromFiles[path]
if !ok {
return fmt.Errorf("%w: %s", errUnknownHookFile, path)
}
nextHooks := cloneHooks(existingHooks)
if currentID == "" {
if hookIndexByID(nextHooks, updated.ID) != -1 {
return fmt.Errorf("hook with ID %s is already defined", updated.ID)
}
nextHooks = append(nextHooks, updated)
} else {
index := hookIndexByID(nextHooks, currentID)
if index == -1 {
return fmt.Errorf("hook with ID %s was not found in %s", currentID, path)
}
nextHooks[index] = updated
}
candidate := cloneLoadedHooksMapLocked()
candidate[path] = nextHooks
if err := validateUniqueHookIDs(candidate); err != nil {
return err
}
if err := writeHooksFile(path, nextHooks); err != nil {
return err
}
loadedHooksFromFiles[path] = nextHooks
return nil
}
func deleteHookFromFile(path, id string) error {
if adminWritesDisabled() {
return errAdminReadOnly
}
loadedHooksMu.Lock()
defer loadedHooksMu.Unlock()
existingHooks, ok := loadedHooksFromFiles[path]
if !ok {
return fmt.Errorf("%w: %s", errUnknownHookFile, path)
}
index := hookIndexByID(existingHooks, id)
if index == -1 {
return fmt.Errorf("hook with ID %s was not found in %s", id, path)
}
nextHooks := cloneHooks(existingHooks[:index])
nextHooks = append(nextHooks, cloneHooks(existingHooks[index+1:])...)
candidate := cloneLoadedHooksMapLocked()
candidate[path] = nextHooks
if err := validateUniqueHookIDs(candidate); err != nil {
return err
}
if err := writeHooksFile(path, nextHooks); err != nil {
return err
}
loadedHooksFromFiles[path] = nextHooks
return nil
}