535 lines
16 KiB
Go
535 lines
16 KiB
Go
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
|
|
}
|
|
|
|
// NewScriptHandlersGroup creates a new ScriptHandlersGroup.
|
|
func NewScriptHandlersGroup(svc *service.ScriptService, cmder *commander.Commander) *ScriptHandlersGroup {
|
|
return &ScriptHandlersGroup{svc: svc, cmder: cmder}
|
|
}
|
|
|
|
// 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 executes on the specified agent
|
|
// @Tags scripts
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path int true "Script ID"
|
|
// @Param body body RunStoredScriptIn true "Agent token and optional stdin"
|
|
// @Success 201 {object} RunScriptOut
|
|
// @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
|
|
}
|
|
|
|
job, err := agent.WaitJob(jid)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("job execution failed: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, RunScriptOut{
|
|
ID: job.ID,
|
|
Command: job.Command,
|
|
Stdin: job.Stdin,
|
|
Stdout: job.Stdout,
|
|
Stderr: job.Stderr,
|
|
Status: job.Status,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|