package handlers import ( "errors" "fmt" "net/http" "strconv" "strings" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service" "github.com/gin-gonic/gin" ) // ScriptHandlersGroup handles script management routes. type ScriptHandlersGroup struct { svc *service.ScriptService cmder *commander.Commander whereami string } // NewScriptHandlersGroup creates a new ScriptHandlersGroup. func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander, whereami string) *ScriptHandlersGroup { return &ScriptHandlersGroup{svc: svc, cmder: cmder, whereami: whereami} } // GetTree returns the script directory tree. // @Summary Get script directory tree // @Description Returns a hierarchical tree of all scripts organized by their paths // @Tags scripts // @Produce json // @Success 200 {array} repository.ScriptTreeNode // @Security Bearer // @Router /scripts/tree [get] func (sh *ScriptHandlersGroup) GetTree(c *gin.Context) { tree, err := sh.svc.BuildTree() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build script tree"}) return } if tree == nil { tree = []repository.ScriptTreeNode{} } c.JSON(http.StatusOK, tree) } // CreateScript creates a new script. // @Summary Create script // @Description Creates a new script with path, content, and interpreter binding // @Tags scripts // @Accept json // @Produce json // @Param body body repository.ScriptCreate true "Script data" // @Success 201 {object} repository.Script // @Security Bearer // @Router /scripts [post] func (sh *ScriptHandlersGroup) CreateScript(c *gin.Context) { var req repository.ScriptCreate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Validate path if err := validateScriptPath(req.Path); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } script, err := sh.svc.Repo.CreateScript(req) if err != nil { if isUniqueConstraint(err) { c.JSON(http.StatusConflict, gin.H{"error": "script with this path already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create script"}) return } c.JSON(http.StatusCreated, script) } // GetScript returns a script by ID. // @Summary Get script // @Description Returns a script by its ID // @Tags scripts // @Produce json // @Param id path int true "Script ID" // @Success 200 {object} repository.Script // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Security Bearer // @Router /scripts/:id [get] func (sh *ScriptHandlersGroup) GetScript(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } script, err := sh.svc.Repo.GetScript(id) if err != nil { if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"}) return } c.JSON(http.StatusOK, script) } // UpdateScript updates a script. // @Summary Update script // @Description Updates a script's path, content, or interpreter // @Tags scripts // @Accept json // @Produce json // @Param id path int true "Script ID" // @Param body body repository.ScriptUpdate true "Script data" // @Success 200 {object} repository.Script // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Security Bearer // @Router /scripts/:id [put] func (sh *ScriptHandlersGroup) UpdateScript(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var req repository.ScriptUpdate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Validate path if it's being updated if req.Path != nil { if err := validateScriptPath(*req.Path); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } } script, err := sh.svc.Repo.UpdateScript(id, req) if err != nil { if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update script"}) return } c.JSON(http.StatusOK, script) } // DeleteScript deletes a script. // @Summary Delete script // @Description Deletes a script by its ID // @Tags scripts // @Param id path int true "Script ID" // @Success 200 {object} map[string]string // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Security Bearer // @Router /scripts/:id [delete] func (sh *ScriptHandlersGroup) DeleteScript(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } if err := sh.svc.Repo.DeleteScript(id); err != nil { if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete script"}) return } c.JSON(http.StatusOK, gin.H{"message": "script deleted"}) } // RunScriptByID executes a stored script on a target agent. // @Summary Run script by ID // @Description Loads a script from storage, resolves interpreter command, and submits it to the agent // @Tags scripts // @Accept json // @Produce json // @Param id path int true "Script ID" // @Param body body RunStoredScriptIn true "Agent ID and optional stdin" // @Success 201 {object} AddJobOut // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security Bearer // @Router /scripts/:id/run [post] func (sh *ScriptHandlersGroup) RunScriptByID(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var in RunStoredScriptIn if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } script, err := sh.svc.Repo.GetScript(id) if err != nil { if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"}) return } command, err := sh.svc.ResolveCommand(c.Request.Context(), script.InterpreterID, script.Content) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve command: %v", err)}) return } agent, ok := sh.cmder.GetAgent(in.Token) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) return } jid, err := agent.AddJob(models.JobForInsert{ Command: command, Stdin: in.Stdin, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add job: %v", err)}) return } waitURL := fmt.Sprintf("%s/api/v1/jobs/%d/wait", sh.whereami, jid) c.JSON(http.StatusCreated, AddJobOut{ ID: jid, Command: command, WaitURL: waitURL, }) } // RunStoredScriptIn is the request body for running a stored script on an agent. type RunStoredScriptIn struct { Token string `json:"token" binding:"required"` Stdin *string `json:"stdin"` } // CreateFolderRequest is the request body for creating a script folder. type CreateFolderRequest struct { Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path (e.g. 'deploy/nginx')"` } // DeleteFolderRequest is the request body for deleting a script folder. type DeleteFolderRequest struct { Path string `json:"path" binding:"required" example:"deploy/nginx" description:"Folder path to delete"` } // RenameRequest is the request body for renaming a script or folder. type RenameRequest struct { OldPath string `json:"old_path" binding:"required" example:"deploy/nginx" description:"Current path"` NewPath string `json:"new_path" binding:"required" example:"deploy/nginx-v2" description:"New path"` } // Rename renames a script or all scripts under a folder path. // @Summary Rename script or folder // @Description Renames a single script or all scripts under a folder prefix // @Tags scripts // @Accept json // @Produce json // @Param body body RenameRequest true "Rename request" // @Success 200 {object} map[string]interface{} "Rename result with count of renamed scripts" // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Failure 409 {object} map[string]string // @Security Bearer // @Router /scripts/rename [post] func (sh *ScriptHandlersGroup) Rename(c *gin.Context) { var req RenameRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Validate new path if err := validateScriptPath(req.NewPath); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid new path: %v", err)}) return } // Validate old path if err := validateScriptPath(req.OldPath); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid old path: %v", err)}) return } // Get all scripts allScripts, err := sh.svc.Repo.ListScripts() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"}) return } // Find scripts to rename: exact match or folder prefix prefix := req.OldPath + "/" var toRename []repository.Script for _, script := range allScripts { if script.Path == req.OldPath || strings.HasPrefix(script.Path, prefix) { toRename = append(toRename, script) } } if len(toRename) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "no scripts found with this path"}) return } // Rename each script renamedCount := 0 for _, script := range toRename { newPath := req.NewPath + strings.TrimPrefix(script.Path, req.OldPath) // Check if new path already exists (excluding the scripts we're renaming) for _, existing := range allScripts { if existing.ID != script.ID && existing.Path == newPath { c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("path '%s' already exists", newPath)}) return } } _, err := sh.svc.Repo.UpdateScript(script.ID, repository.ScriptUpdate{ Path: &newPath, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to rename %s: %v", script.Path, err)}) return } renamedCount++ } c.JSON(http.StatusOK, gin.H{ "message": "renamed", "old_path": req.OldPath, "new_path": req.NewPath, "renamed_count": renamedCount, }) } // CreateFolder creates a virtual folder in the script tree. // @Summary Create folder // @Description Creates a virtual folder by creating a placeholder script with the folder path // @Tags scripts // @Accept json // @Produce json // @Param body body CreateFolderRequest true "Folder path" // @Success 201 {object} map[string]string "Folder created" // @Security Bearer // @Router /scripts/folder [post] func (sh *ScriptHandlersGroup) CreateFolder(c *gin.Context) { var req CreateFolderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Validate folder path if err := validateScriptPath(req.Path); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)}) return } // Create a placeholder script with the folder path to ensure the folder exists in the tree // The placeholder uses ".folder" as content and interpreter_id 0 (will be resolved at runtime) _, err := sh.svc.Repo.CreateScript(repository.ScriptCreate{ Path: req.Path, Content: "", InterpreterID: 0, }) if err != nil { if isUniqueConstraint(err) { c.JSON(http.StatusConflict, gin.H{"error": "folder with this path already exists"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"}) return } c.JSON(http.StatusCreated, gin.H{"message": "folder created", "path": req.Path}) } // DeleteFolder deletes all scripts under a given path prefix. // @Summary Delete folder // @Description Deletes all scripts that start with the given folder path // @Tags scripts // @Accept json // @Produce json // @Param body body DeleteFolderRequest true "Folder path" // @Success 200 {object} map[string]interface{} "Folder deleted with count of deleted scripts" // @Security Bearer // @Router /scripts/folder [delete] func (sh *ScriptHandlersGroup) DeleteFolder(c *gin.Context) { var req DeleteFolderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } // Validate folder path if err := validateScriptPath(req.Path); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid folder path: %v", err)}) return } // Get all scripts and filter by path prefix allScripts, err := sh.svc.Repo.ListScripts() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list scripts"}) return } prefix := req.Path + "/" deletedCount := 0 for _, script := range allScripts { // Delete scripts that are in this folder (path starts with prefix) // or the folder placeholder itself (exact match) if script.Path == req.Path || strings.HasPrefix(script.Path, prefix) { if err := sh.svc.Repo.DeleteScript(script.ID); err != nil && !errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete script %s: %v", script.Path, err)}) return } deletedCount++ } } c.JSON(http.StatusOK, gin.H{"message": "folder deleted", "path": req.Path, "deleted_count": deletedCount}) } // GetScriptByPath returns a script by its path. // @Summary Get script by path // @Description Returns a script by its full path (e.g. 'deploy/nginx/restart.sh') // @Tags scripts // @Produce json // @Param path query string true "Script path" // @Success 200 {object} repository.Script // @Failure 400 {object} map[string]string // @Failure 404 {object} map[string]string // @Security Bearer // @Router /scripts/by-path [get] func (sh *ScriptHandlersGroup) GetScriptByPath(c *gin.Context) { path := c.Query("path") if path == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"}) return } script, err := sh.svc.Repo.GetScriptByPath(path) if err != nil { if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "script not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get script"}) return } c.JSON(http.StatusOK, script) } // isUniqueConstraint checks if the error is a SQLite UNIQUE constraint violation. func isUniqueConstraint(err error) bool { return err != nil && (err.Error() != "" && contains(err.Error(), "UNIQUE constraint")) } func contains(s, substr string) bool { return len(s) >= len(substr) && searchSubstring(s, substr) } func searchSubstring(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // validateScriptPath validates that a script path is well-formed. // Rules: non-empty, no leading slash, no double slashes, no trailing slash, no empty segments. func validateScriptPath(path string) error { if path == "" { return fmt.Errorf("path cannot be empty") } if strings.HasPrefix(path, "/") { return fmt.Errorf("path cannot start with '/'") } if strings.HasSuffix(path, "/") { return fmt.Errorf("path cannot end with '/'") } if strings.Contains(path, "//") { return fmt.Errorf("path cannot contain '//'") } // Check for empty segments (e.g. "a//b" already caught, but "a/ /b" should be allowed) segments := strings.Split(path, "/") for i, seg := range segments { if strings.TrimSpace(seg) == "" { return fmt.Errorf("path segment %d cannot be empty or whitespace", i+1) } } return nil }