package api import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "sort" "strings" "time" "github.com/go-chi/chi/v5" ) // BlueprintHandler handles save/load/list/delete of config blueprints type BlueprintHandler struct { dataDir string } // BlueprintInfo is the metadata returned when listing blueprints type BlueprintInfo struct { Name string `json:"name"` Size int64 `json:"size"` CreatedAt string `json:"created_at"` Data json.RawMessage `json:"data,omitempty"` } func NewBlueprintHandler(dataDir string) *BlueprintHandler { return &BlueprintHandler{dataDir: dataDir} } func (h *BlueprintHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.listBlueprints(w, r) case http.MethodPost: h.saveBlueprint(w, r) case http.MethodDelete: h.deleteBlueprint(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // GET /api/v1/blueprints func (h *BlueprintHandler) listBlueprints(w http.ResponseWriter, r *http.Request) { blueprintsDir := filepath.Join(h.dataDir, "blueprints") if err := os.MkdirAll(blueprintsDir, 0755); err != nil { http.Error(w, `{"error":"Cannot create blueprints directory"}`, http.StatusInternalServerError) return } entries, err := os.ReadDir(blueprintsDir) if err != nil { http.Error(w, `{"error":"Cannot read blueprints directory"}`, http.StatusInternalServerError) return } var blueprints []BlueprintInfo for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } info, err := entry.Info() if err != nil { continue } name := strings.TrimSuffix(entry.Name(), ".json") blueprints = append(blueprints, BlueprintInfo{ Name: name, Size: info.Size(), CreatedAt: info.ModTime().Format(time.RFC3339), }) } // Sort by creation time, newest first sort.Slice(blueprints, func(i, j int) bool { return blueprints[i].CreatedAt > blueprints[j].CreatedAt }) if blueprints == nil { blueprints = []BlueprintInfo{} } writeJSON(w, blueprints) } // POST /api/v1/blueprints func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` Data json.RawMessage `json:"data"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"Invalid JSON"}`, http.StatusBadRequest) return } if req.Name == "" { http.Error(w, `{"error":"Blueprint name is required"}`, http.StatusBadRequest) return } // Sanitize name - only allow safe filename characters safeName := sanitizeFilename(req.Name) if safeName == "" { http.Error(w, `{"error":"Invalid blueprint name"}`, http.StatusBadRequest) return } blueprintsDir := filepath.Join(h.dataDir, "blueprints") if err := os.MkdirAll(blueprintsDir, 0755); err != nil { http.Error(w, `{"error":"Cannot create blueprints directory"}`, http.StatusInternalServerError) return } filePath := filepath.Join(blueprintsDir, safeName+".json") // Pretty-print the JSON var prettyData interface{} if err := json.Unmarshal(req.Data, &prettyData); err != nil { http.Error(w, `{"error":"Invalid data JSON"}`, http.StatusBadRequest) return } formatted, err := json.MarshalIndent(prettyData, "", " ") if err != nil { http.Error(w, `{"error":"Failed to format JSON"}`, http.StatusInternalServerError) return } if err := os.WriteFile(filePath, formatted, 0644); err != nil { http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError) return } writeJSON(w, map[string]interface{}{ "success": true, "name": safeName, "file_path": filePath, "created_at": time.Now().Format(time.RFC3339), }) } // GET /api/v1/blueprints/{name} func (h *BlueprintHandler) GetBlueprint(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") if name == "" { http.Error(w, `{"error":"Blueprint name required"}`, http.StatusBadRequest) return } safeName := sanitizeFilename(name) filePath := filepath.Join(h.dataDir, "blueprints", safeName+".json") data, err := os.ReadFile(filePath) if err != nil { if os.IsNotExist(err) { http.Error(w, `{"error":"Blueprint not found"}`, http.StatusNotFound) } else { http.Error(w, `{"error":"Failed to read blueprint"}`, http.StatusInternalServerError) } return } // Return the raw JSON data w.Header().Set("Content-Type", "application/json") w.Write(data) } // DELETE /api/v1/blueprints/{name} func (h *BlueprintHandler) deleteBlueprint(w http.ResponseWriter, r *http.Request) { // Parse name from query param since chi doesn't have PathValue name := r.URL.Query().Get("name") if name == "" { http.Error(w, `{"error":"Blueprint name required (use ?name=...)"}`, http.StatusBadRequest) return } safeName := sanitizeFilename(name) filePath := filepath.Join(h.dataDir, "blueprints", safeName+".json") if err := os.Remove(filePath); err != nil { if os.IsNotExist(err) { http.Error(w, `{"error":"Blueprint not found"}`, http.StatusNotFound) } else { http.Error(w, `{"error":"Failed to delete blueprint"}`, http.StatusInternalServerError) } return } writeJSON(w, map[string]string{"success": "true", "name": safeName}) } func sanitizeFilename(name string) string { // Remove path separators and dangerous characters name = strings.Map(func(r rune) rune { if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { return -1 } return r }, name) // Trim spaces and dots name = strings.TrimSpace(name) name = strings.Trim(name, ".") // Limit length if len(name) > 100 { name = name[:100] } return name }