This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||
@@ -63,6 +64,12 @@ func (sh *ScriptHandlersGroup) CreateScript(c *gin.Context) {
|
||||
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) {
|
||||
@@ -133,6 +140,14 @@ func (sh *ScriptHandlersGroup) UpdateScript(c *gin.Context) {
|
||||
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) {
|
||||
@@ -255,6 +270,136 @@ type RunStoredScriptIn struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -272,3 +417,28 @@ func searchSubstring(s, substr string) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user