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"` } type patchActionRequest struct { Name *string `json:"name"` Description *string `json:"description"` Body *string `json:"body"` Env *[]settings.ActionEnvVar `json:"env"` } 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") errActionIDRequired = errors.New("action id is required") ) 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 } if store.Actions[index].Builtin { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()}) return } updated := store.Actions[index] 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 } 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 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 } 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, 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 findActionIndex(actions []settings.Action, actionID string) int { for index := range actions { if actions[index].ID == actionID { return index } } return -1 }