Correct AI uptime/reinstall/sleep, broaden live stats over WebSocket, fix blueprint delete JSON, gate hollowing behind build tag, and stop stale server binds on restart.
214 lines
5.7 KiB
Go
214 lines
5.7 KiB
Go
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]interface{}{"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, ".")
|
|
// Prevent explicit traversal sequences
|
|
name = strings.ReplaceAll(name, "..", "")
|
|
// Limit length
|
|
if len(name) > 100 {
|
|
name = name[:100]
|
|
}
|
|
return name
|
|
}
|