From aae27fa5e01fd755c794a8a214de0e483fa25eff Mon Sep 17 00:00:00 2001 From: d3m0k1d Date: Sun, 5 Apr 2026 02:18:34 +0300 Subject: [PATCH] chore: add logic for scripts --- backend/cmd/main.go | 7 + backend/docs/docs.go | 395 +++++++++++++++++++- backend/docs/swagger.json | 395 +++++++++++++++++++- backend/docs/swagger.yaml | 256 ++++++++++++- backend/internal/handlers/scripts_manage.go | 170 +++++++++ 5 files changed, 1179 insertions(+), 44 deletions(-) diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 2486b44..bc54df1 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -260,6 +260,13 @@ func main() { scriptsGroup.PUT("/:id", scriptManageHandlers.UpdateScript) scriptsGroup.DELETE("/:id", scriptManageHandlers.DeleteScript) scriptsGroup.POST("/:id/run", scriptManageHandlers.RunScriptByID) + + // Folder management + scriptsGroup.POST("/folder", scriptManageHandlers.CreateFolder) + scriptsGroup.DELETE("/folder", scriptManageHandlers.DeleteFolder) + + // Get script by path + scriptsGroup.GET("/by-path", scriptManageHandlers.GetScriptByPath) } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e37faad..80f8092 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -965,12 +965,7 @@ const docTemplate = `{ }, "/jobs": { "post": { - "security": [ - { - "Bearer": [] - } - ], - "description": "Sends a command to the specified agent, waits for execution, and returns the result", + "description": "Sends a command to the specified agent and returns a URL to wait for the result", "consumes": [ "application/json" ], @@ -980,7 +975,7 @@ const docTemplate = `{ "tags": [ "jobs" ], - "summary": "Create and run a job on an agent", + "summary": "Submit a job to an agent", "parameters": [ { "description": "Job request", @@ -1002,6 +997,148 @@ const docTemplate = `{ } } }, + "/jobs/check_cmd": { + "post": { + "description": "Validates that a command binary exists on the system", + "consumes": [ + "application/json" + ], + "tags": [ + "jobs" + ], + "summary": "Check command path", + "parameters": [ + { + "description": "Command to check", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CheckCmdIn" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.CheckCmdOut" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/jobs/metrics": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns total, successful, failed, and pending job counts over the given period", + "produces": [ + "application/json" + ], + "tags": [ + "jobs" + ], + "summary": "Get job metrics", + "parameters": [ + { + "type": "string", + "default": "24h", + "description": "Time period (e.g. 1h, 24h, 7d)", + "name": "period", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.JobMetricsOut" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/jobs/{id}/wait": { + "post": { + "description": "Long-polls for a job result. Returns immediately if the job is already finished.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "jobs" + ], + "summary": "Wait for job result", + "parameters": [ + { + "type": "integer", + "description": "Job ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Agent reference", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.WaitJobIn" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.JobResult" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/logs": { "get": { "security": [ @@ -1580,6 +1717,138 @@ const docTemplate = `{ } } }, + "/scripts/by-path": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')", + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Get script by path", + "parameters": [ + { + "type": "string", + "description": "Script path", + "name": "path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/scripts/folder": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a virtual folder by creating a placeholder script with the folder path", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Create folder", + "parameters": [ + { + "description": "Folder path", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreateFolderRequest" + } + } + ], + "responses": { + "201": { + "description": "Folder created", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Deletes all scripts that start with the given folder path", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Delete folder", + "parameters": [ + { + "description": "Folder path", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.DeleteFolderRequest" + } + } + ], + "responses": { + "200": { + "description": "Folder deleted with count of deleted scripts", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/scripts/interpreters": { "get": { "security": [ @@ -2337,16 +2606,7 @@ const docTemplate = `{ "id": { "type": "integer" }, - "status": { - "type": "integer" - }, - "stderr": { - "type": "string" - }, - "stdin": { - "type": "string" - }, - "stdout": { + "wait_url": { "type": "string" } } @@ -2382,6 +2642,50 @@ const docTemplate = `{ } } }, + "internal_handlers.CheckCmdIn": { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "example": "bash" + } + } + }, + "internal_handlers.CheckCmdOut": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + } + }, + "internal_handlers.CreateFolderRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "example": "deploy/nginx" + } + } + }, + "internal_handlers.DeleteFolderRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "example": "deploy/nginx" + } + } + }, "internal_handlers.InsertLogRequest": { "type": "object", "required": [ @@ -2422,6 +2726,52 @@ const docTemplate = `{ } } }, + "internal_handlers.JobMetricsOut": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "period": { + "type": "string" + }, + "success": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "internal_handlers.JobResult": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "stdout": { + "type": "string" + } + } + }, "internal_handlers.RegisterRequest": { "type": "object", "required": [ @@ -2509,6 +2859,17 @@ const docTemplate = `{ "type": "string" } } + }, + "internal_handlers.WaitJobIn": { + "type": "object", + "required": [ + "agent_id" + ], + "properties": { + "agent_id": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index fd31f8b..3ab94c9 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -954,12 +954,7 @@ }, "/jobs": { "post": { - "security": [ - { - "Bearer": [] - } - ], - "description": "Sends a command to the specified agent, waits for execution, and returns the result", + "description": "Sends a command to the specified agent and returns a URL to wait for the result", "consumes": [ "application/json" ], @@ -969,7 +964,7 @@ "tags": [ "jobs" ], - "summary": "Create and run a job on an agent", + "summary": "Submit a job to an agent", "parameters": [ { "description": "Job request", @@ -991,6 +986,148 @@ } } }, + "/jobs/check_cmd": { + "post": { + "description": "Validates that a command binary exists on the system", + "consumes": [ + "application/json" + ], + "tags": [ + "jobs" + ], + "summary": "Check command path", + "parameters": [ + { + "description": "Command to check", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CheckCmdIn" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.CheckCmdOut" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/jobs/metrics": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns total, successful, failed, and pending job counts over the given period", + "produces": [ + "application/json" + ], + "tags": [ + "jobs" + ], + "summary": "Get job metrics", + "parameters": [ + { + "type": "string", + "default": "24h", + "description": "Time period (e.g. 1h, 24h, 7d)", + "name": "period", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.JobMetricsOut" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/jobs/{id}/wait": { + "post": { + "description": "Long-polls for a job result. Returns immediately if the job is already finished.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "jobs" + ], + "summary": "Wait for job result", + "parameters": [ + { + "type": "integer", + "description": "Job ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Agent reference", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.WaitJobIn" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.JobResult" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/logs": { "get": { "security": [ @@ -1569,6 +1706,138 @@ } } }, + "/scripts/by-path": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns a script by its full path (e.g. 'deploy/nginx/restart.sh')", + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Get script by path", + "parameters": [ + { + "type": "string", + "description": "Script path", + "name": "path", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/scripts/folder": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a virtual folder by creating a placeholder script with the folder path", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Create folder", + "parameters": [ + { + "description": "Folder path", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreateFolderRequest" + } + } + ], + "responses": { + "201": { + "description": "Folder created", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Deletes all scripts that start with the given folder path", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "scripts" + ], + "summary": "Delete folder", + "parameters": [ + { + "description": "Folder path", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.DeleteFolderRequest" + } + } + ], + "responses": { + "200": { + "description": "Folder deleted with count of deleted scripts", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/scripts/interpreters": { "get": { "security": [ @@ -2326,16 +2595,7 @@ "id": { "type": "integer" }, - "status": { - "type": "integer" - }, - "stderr": { - "type": "string" - }, - "stdin": { - "type": "string" - }, - "stdout": { + "wait_url": { "type": "string" } } @@ -2371,6 +2631,50 @@ } } }, + "internal_handlers.CheckCmdIn": { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string", + "example": "bash" + } + } + }, + "internal_handlers.CheckCmdOut": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + } + }, + "internal_handlers.CreateFolderRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "example": "deploy/nginx" + } + } + }, + "internal_handlers.DeleteFolderRequest": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "example": "deploy/nginx" + } + } + }, "internal_handlers.InsertLogRequest": { "type": "object", "required": [ @@ -2411,6 +2715,52 @@ } } }, + "internal_handlers.JobMetricsOut": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "period": { + "type": "string" + }, + "success": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "internal_handlers.JobResult": { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "stdout": { + "type": "string" + } + } + }, "internal_handlers.RegisterRequest": { "type": "object", "required": [ @@ -2498,6 +2848,17 @@ "type": "string" } } + }, + "internal_handlers.WaitJobIn": { + "type": "object", + "required": [ + "agent_id" + ], + "properties": { + "agent_id": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index dde312e..dc725b8 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -348,13 +348,7 @@ definitions: type: array id: type: integer - status: - type: integer - stderr: - type: string - stdin: - type: string - stdout: + wait_url: type: string type: object internal_handlers.AgentInfo: @@ -380,6 +374,35 @@ definitions: example: agent-001 type: string type: object + internal_handlers.CheckCmdIn: + properties: + command: + example: bash + type: string + required: + - command + type: object + internal_handlers.CheckCmdOut: + properties: + exists: + type: boolean + type: object + internal_handlers.CreateFolderRequest: + properties: + path: + example: deploy/nginx + type: string + required: + - path + type: object + internal_handlers.DeleteFolderRequest: + properties: + path: + example: deploy/nginx + type: string + required: + - path + type: object internal_handlers.InsertLogRequest: properties: agent: @@ -407,6 +430,36 @@ definitions: required: - logs type: object + internal_handlers.JobMetricsOut: + properties: + failed: + type: integer + pending: + type: integer + period: + type: string + success: + type: integer + total: + type: integer + type: object + internal_handlers.JobResult: + properties: + command: + items: + type: string + type: array + id: + type: integer + status: + type: integer + stderr: + type: string + stdin: + type: string + stdout: + type: string + type: object internal_handlers.RegisterRequest: properties: csr: @@ -465,6 +518,13 @@ definitions: required: - token type: object + internal_handlers.WaitJobIn: + properties: + agent_id: + type: string + required: + - agent_id + type: object info: contact: {} paths: @@ -1079,8 +1139,8 @@ paths: post: consumes: - application/json - description: Sends a command to the specified agent, waits for execution, and - returns the result + description: Sends a command to the specified agent and returns a URL to wait + for the result parameters: - description: Job request in: body @@ -1095,9 +1155,101 @@ paths: description: Created schema: $ref: '#/definitions/internal_handlers.AddJobOut' + summary: Submit a job to an agent + tags: + - jobs + /jobs/{id}/wait: + post: + consumes: + - application/json + description: Long-polls for a job result. Returns immediately if the job is + already finished. + parameters: + - description: Job ID + in: path + name: id + required: true + type: integer + - description: Agent reference + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_handlers.WaitJobIn' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.JobResult' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Wait for job result + tags: + - jobs + /jobs/check_cmd: + post: + consumes: + - application/json + description: Validates that a command binary exists on the system + parameters: + - description: Command to check + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_handlers.CheckCmdIn' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.CheckCmdOut' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Check command path + tags: + - jobs + /jobs/metrics: + get: + description: Returns total, successful, failed, and pending job counts over + the given period + parameters: + - default: 24h + description: Time period (e.g. 1h, 24h, 7d) + in: query + name: period + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.JobMetricsOut' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object security: - Bearer: [] - summary: Create and run a job on an agent + summary: Get job metrics tags: - jobs /logs: @@ -1468,6 +1620,90 @@ paths: summary: Run script by ID tags: - scripts + /scripts/by-path: + get: + description: Returns a script by its full path (e.g. 'deploy/nginx/restart.sh') + parameters: + - description: Script path + in: query + name: path + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/gitea_d3m0k1d_ru_d3m0k1d_HellreigN_backend_internal_repository.Script' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Get script by path + tags: + - scripts + /scripts/folder: + delete: + consumes: + - application/json + description: Deletes all scripts that start with the given folder path + parameters: + - description: Folder path + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_handlers.DeleteFolderRequest' + produces: + - application/json + responses: + "200": + description: Folder deleted with count of deleted scripts + schema: + additionalProperties: true + type: object + security: + - Bearer: [] + summary: Delete folder + tags: + - scripts + post: + consumes: + - application/json + description: Creates a virtual folder by creating a placeholder script with + the folder path + parameters: + - description: Folder path + in: body + name: body + required: true + schema: + $ref: '#/definitions/internal_handlers.CreateFolderRequest' + produces: + - application/json + responses: + "201": + description: Folder created + schema: + additionalProperties: + type: string + type: object + security: + - Bearer: [] + summary: Create folder + tags: + - scripts /scripts/interpreters: get: description: Returns all script interpreters available in the system diff --git a/backend/internal/handlers/scripts_manage.go b/backend/internal/handlers/scripts_manage.go index b0835ab..1da9540 100644 --- a/backend/internal/handlers/scripts_manage.go +++ b/backend/internal/handlers/scripts_manage.go @@ -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 +}