mirror of
https://github.com/adnanh/webhook.git
synced 2026-07-21 17:29:49 +00:00
356 lines
7.9 KiB
Go
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
|
|
}
|