package handlers import ( "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "sproutclaw-web/internal/config" ) // RegisterPrompts registers prompt-template CRUD routes. func RegisterPrompts(r *gin.RouterGroup, cfg *config.Config) { dir := cfg.PromptsDir r.GET("/prompts", handleListPrompts(dir)) r.GET("/prompts/file", handleGetPromptFile(dir)) r.POST("/prompts/file", handleSavePromptFile(dir)) r.POST("/prompts/delete", handleDeletePromptFile(dir)) } // PromptMeta is the metadata extracted from a prompt .md file. type PromptMeta struct { Name string `json:"name"` Title string `json:"title"` Description string `json:"description,omitempty"` ArgumentHint string `json:"argumentHint,omitempty"` } // parsePromptMeta reads YAML frontmatter (description / argument-hint) from content. func parsePromptMeta(name, content string) PromptMeta { title := strings.TrimSuffix(name, ".md") meta := PromptMeta{Name: name, Title: title} if !strings.HasPrefix(content, "---\n") { return meta } rest := content[4:] end := strings.Index(rest, "\n---") if end < 0 { return meta } for _, line := range strings.Split(rest[:end], "\n") { if after, ok := strings.CutPrefix(line, "description:"); ok { meta.Description = strings.Trim(strings.TrimSpace(after), `"'`) } if after, ok := strings.CutPrefix(line, "argument-hint:"); ok { meta.ArgumentHint = strings.Trim(strings.TrimSpace(after), `"'`) } } return meta } // safePromptName validates that the filename is a plain .md name with no path traversal. func safePromptName(name string) bool { if name == "" { return false } if !strings.HasSuffix(name, ".md") { return false } // Reject anything that looks like a path if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { return false } return true } func handleListPrompts(dir string) gin.HandlerFunc { return func(c *gin.Context) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { c.JSON(http.StatusOK, gin.H{"prompts": []any{}}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } list := make([]PromptMeta, 0) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } raw, _ := os.ReadFile(filepath.Join(dir, e.Name())) list = append(list, parsePromptMeta(e.Name(), string(raw))) } c.JSON(http.StatusOK, gin.H{"prompts": list}) } } func handleGetPromptFile(dir string) gin.HandlerFunc { return func(c *gin.Context) { name := c.Query("name") if !safePromptName(name) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"}) return } raw, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) return } c.JSON(http.StatusOK, gin.H{"content": string(raw)}) } } func handleSavePromptFile(dir string) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Name string `json:"name"` Content string `json:"content"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if !safePromptName(req.Name) { c.JSON(http.StatusBadRequest, gin.H{"error": "文件名必须以 .md 结尾,且不能含路径符"}) return } if err := os.MkdirAll(dir, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := os.WriteFile(filepath.Join(dir, req.Name), []byte(req.Content), 0o644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } } func handleDeletePromptFile(dir string) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if !safePromptName(req.Name) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"}) return } if err := os.Remove(filepath.Join(dir, req.Name)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } }