322 lines
9.9 KiB
Go
322 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
|
)
|
|
|
|
type actionsResponse struct {
|
|
Actions []settings.Action `json:"actions"`
|
|
}
|
|
|
|
type createActionRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Kind settings.ActionKind `json:"kind"`
|
|
Body string `json:"body"`
|
|
Env []settings.ActionEnvVar `json:"env"`
|
|
RequiresSudo bool `json:"requires_sudo"`
|
|
ShowWidget bool `json:"show_widget"`
|
|
WidgetType settings.ActionWidgetType `json:"widget_type"`
|
|
}
|
|
|
|
type patchActionRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Body *string `json:"body"`
|
|
Env *[]settings.ActionEnvVar `json:"env"`
|
|
RequiresSudo *bool `json:"requires_sudo"`
|
|
ShowWidget *bool `json:"show_widget"`
|
|
WidgetType *settings.ActionWidgetType `json:"widget_type"`
|
|
}
|
|
|
|
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
|
|
var (
|
|
errActionNameRequired = errors.New("action name is required")
|
|
errActionBodyRequired = errors.New("action body is required")
|
|
errActionKindInvalid = errors.New("action kind must be shell or script")
|
|
errActionNotFound = errors.New("action not found")
|
|
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
|
|
errActionBuiltinBodyRO = errors.New("built-in action body and name cannot be modified")
|
|
errActionIDRequired = errors.New("action id is required")
|
|
errActionWidgetTypeNeeded = errors.New("widget_type is required when show_widget is true")
|
|
errActionWidgetTypeBad = errors.New("widget_type must be memory, disk, or raw")
|
|
)
|
|
|
|
func actionsGetHandler(configDir string) http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
store, err := settings.LoadActionsOrSeed(configDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
|
}
|
|
}
|
|
|
|
func actionsCreateHandler(configDir string) http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
if err := authorizeActionsCreate(request, configDir); err != nil {
|
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
var payload createActionRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
action, err := buildCustomAction(payload)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
store, err := settings.LoadActionsOrSeed(configDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
store.Actions = append(store.Actions, action)
|
|
if err := settings.SaveActions(configDir, store); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
|
}
|
|
}
|
|
|
|
func actionsPatchHandler(configDir string) http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
if err := authorizeActionsUpdate(request, configDir); err != nil {
|
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
|
|
if actionID == "" {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
|
|
return
|
|
}
|
|
|
|
var payload patchActionRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
store, err := settings.LoadActionsOrSeed(configDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
index := findActionIndex(store.Actions, actionID)
|
|
if index < 0 {
|
|
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
|
|
return
|
|
}
|
|
|
|
updated := store.Actions[index]
|
|
isBuiltin := updated.Builtin
|
|
if isBuiltin {
|
|
if payload.Name != nil || payload.Description != nil || payload.Body != nil ||
|
|
payload.Env != nil || payload.RequiresSudo != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinBodyRO.Error()})
|
|
return
|
|
}
|
|
if payload.ShowWidget == nil && payload.WidgetType == nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
if !isBuiltin {
|
|
if payload.Name != nil {
|
|
updated.Name = strings.TrimSpace(*payload.Name)
|
|
}
|
|
if payload.Description != nil {
|
|
updated.Description = strings.TrimSpace(*payload.Description)
|
|
}
|
|
if payload.Body != nil {
|
|
updated.Body = strings.TrimSpace(*payload.Body)
|
|
}
|
|
if payload.Env != nil {
|
|
updated.Env = *payload.Env
|
|
}
|
|
if payload.RequiresSudo != nil {
|
|
updated.RequiresSudo = *payload.RequiresSudo
|
|
}
|
|
}
|
|
if payload.ShowWidget != nil {
|
|
updated.ShowWidget = *payload.ShowWidget
|
|
}
|
|
if payload.WidgetType != nil {
|
|
updated.WidgetType = *payload.WidgetType
|
|
}
|
|
if !updated.ShowWidget {
|
|
updated.WidgetType = settings.ActionWidgetTypeNone
|
|
}
|
|
updated.UpdatedAt = time.Now().UTC()
|
|
|
|
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if err := validateActionWidget(updated.ShowWidget, updated.WidgetType); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
if updated.Env == nil {
|
|
updated.Env = []settings.ActionEnvVar{}
|
|
}
|
|
|
|
store.Actions[index] = updated
|
|
if err := settings.SaveActions(configDir, store); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
|
}
|
|
}
|
|
|
|
func actionsDeleteHandler(configDir string) http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
if err := authorizeActionsDelete(request, configDir); err != nil {
|
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
|
|
if actionID == "" {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
|
|
return
|
|
}
|
|
|
|
store, err := settings.LoadActionsOrSeed(configDir)
|
|
if err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
index := findActionIndex(store.Actions, actionID)
|
|
if index < 0 {
|
|
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
|
|
return
|
|
}
|
|
if store.Actions[index].Builtin {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
|
|
return
|
|
}
|
|
|
|
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
|
|
if err := settings.SaveActions(configDir, store); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
|
}
|
|
}
|
|
|
|
func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
|
name := strings.TrimSpace(payload.Name)
|
|
description := strings.TrimSpace(payload.Description)
|
|
body := strings.TrimSpace(payload.Body)
|
|
env := payload.Env
|
|
if env == nil {
|
|
env = []settings.ActionEnvVar{}
|
|
}
|
|
|
|
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
|
|
return settings.Action{}, err
|
|
}
|
|
widgetType := payload.WidgetType
|
|
if !payload.ShowWidget {
|
|
widgetType = settings.ActionWidgetTypeNone
|
|
}
|
|
if err := validateActionWidget(payload.ShowWidget, widgetType); err != nil {
|
|
return settings.Action{}, err
|
|
}
|
|
|
|
actionID, err := auth.NewUUID()
|
|
if err != nil {
|
|
return settings.Action{}, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
return settings.Action{
|
|
ID: actionID,
|
|
Name: name,
|
|
Description: description,
|
|
Kind: payload.Kind,
|
|
Body: body,
|
|
Env: env,
|
|
RequiresSudo: payload.RequiresSudo,
|
|
ShowWidget: payload.ShowWidget,
|
|
WidgetType: widgetType,
|
|
Builtin: false,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func validateActionFields(
|
|
name string,
|
|
kind settings.ActionKind,
|
|
body string,
|
|
env []settings.ActionEnvVar,
|
|
) error {
|
|
if name == "" {
|
|
return errActionNameRequired
|
|
}
|
|
if body == "" {
|
|
return errActionBodyRequired
|
|
}
|
|
if kind != settings.ActionKindShell && kind != settings.ActionKindScript {
|
|
return errActionKindInvalid
|
|
}
|
|
for index := range env {
|
|
env[index].Name = strings.TrimSpace(env[index].Name)
|
|
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
|
return errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateActionWidget(showWidget bool, widgetType settings.ActionWidgetType) error {
|
|
if !showWidget {
|
|
return nil
|
|
}
|
|
switch widgetType {
|
|
case settings.ActionWidgetTypeMemory, settings.ActionWidgetTypeDisk, settings.ActionWidgetTypeRaw:
|
|
return nil
|
|
case settings.ActionWidgetTypeNone:
|
|
return errActionWidgetTypeNeeded
|
|
default:
|
|
return errActionWidgetTypeBad
|
|
}
|
|
}
|
|
|
|
func findActionIndex(actions []settings.Action, actionID string) int {
|
|
for index := range actions {
|
|
if actions[index].ID == actionID {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|