package handlers import ( "archive/zip" "encoding/json" "io" "log" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/store" ) // validSurfaceID matches lowercase alphanumeric slugs with optional hyphens. // No leading/trailing hyphens, 2-64 chars. var validSurfaceID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) // SurfaceHandler manages surface lifecycle (admin-only). type SurfaceHandler struct { stores store.Stores surfacesDir string // e.g. /data/surfaces — where archives are extracted } func NewSurfaceHandler(s store.Stores, surfacesDir ...string) *SurfaceHandler { dir := "" if len(surfacesDir) > 0 { dir = surfacesDir[0] } return &SurfaceHandler{stores: s, surfacesDir: dir} } // ListSurfaces returns all registered surfaces with their enabled state. // GET /api/v1/admin/surfaces func (h *SurfaceHandler) ListSurfaces(c *gin.Context) { surfaces, err := h.stores.Surfaces.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"}) return } if surfaces == nil { surfaces = []store.SurfaceRegistration{} } c.JSON(http.StatusOK, gin.H{"surfaces": surfaces}) } // GetSurface returns a single surface by ID. // GET /api/v1/admin/surfaces/:id func (h *SurfaceHandler) GetSurface(c *gin.Context) { id := c.Param("id") surface, err := h.stores.Surfaces.Get(c.Request.Context(), id) if err != nil || surface == nil { c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"}) return } c.JSON(http.StatusOK, surface) } // EnableSurface enables a surface. // PUT /api/v1/admin/surfaces/:id/enable func (h *SurfaceHandler) EnableSurface(c *gin.Context) { id := c.Param("id") if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, true); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"}) return } c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true}) } // DisableSurface disables a surface. Chat cannot be disabled. // PUT /api/v1/admin/surfaces/:id/disable func (h *SurfaceHandler) DisableSurface(c *gin.Context) { id := c.Param("id") // Chat and Admin are required — cannot be disabled if id == "chat" || id == "admin" { c.JSON(http.StatusBadRequest, gin.H{"error": id + " surface cannot be disabled"}) return } if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, false); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"}) return } c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false}) } // DeleteSurface uninstalls an extension surface. Core surfaces cannot be deleted. // DELETE /api/v1/admin/surfaces/:id func (h *SurfaceHandler) DeleteSurface(c *gin.Context) { id := c.Param("id") // Check it's not a core surface surface, err := h.stores.Surfaces.Get(c.Request.Context(), id) if err != nil || surface == nil { c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"}) return } if surface.Source == "core" { c.JSON(http.StatusBadRequest, gin.H{"error": "core surfaces cannot be uninstalled"}) return } if err := h.stores.Surfaces.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete surface"}) return } // Clean up extracted static assets for this surface if h.surfacesDir != "" { assetDir := filepath.Join(h.surfacesDir, id) if err := os.RemoveAll(assetDir); err != nil { log.Printf("⚠️ Failed to clean up surface assets at %s: %v", assetDir, err) } } c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true}) } // InstallSurface uploads and installs a .surface archive. // POST /api/v1/admin/surfaces/install (multipart: file) func (h *SurfaceHandler) InstallSurface(c *gin.Context) { file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return } defer file.Close() // Validate extension if !strings.HasSuffix(header.Filename, ".surface") && !strings.HasSuffix(header.Filename, ".zip") { c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .surface or .zip archive"}) return } // Size limit: 50MB if header.Size > 50*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) return } // Read into temp file for zip processing tmpFile, err := os.CreateTemp("", "surface-*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } tmpPath := tmpFile.Name() defer os.Remove(tmpPath) if _, err := io.Copy(tmpFile, file); err != nil { tmpFile.Close() c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"}) return } tmpFile.Close() // Open as zip zr, err := zip.OpenReader(tmpPath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) return } defer zr.Close() // Find and parse manifest.json var manifest map[string]any for _, f := range zr.File { name := filepath.Base(f.Name) if name == "manifest.json" && !f.FileInfo().IsDir() { rc, err := f.Open() if err != nil { continue } data, err := io.ReadAll(rc) rc.Close() if err != nil { continue } if err := json.Unmarshal(data, &manifest); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()}) return } break } } if manifest == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"}) return } // Validate required fields surfaceID, _ := manifest["id"].(string) title, _ := manifest["title"].(string) if surfaceID == "" || title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"}) return } // Validate surface ID is a safe slug — prevents path traversal in // filesystem operations (filepath.Join(surfacesDir, surfaceID)). if !validSurfaceID.MatchString(surfaceID) { c.JSON(http.StatusBadRequest, gin.H{"error": "surface id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"}) return } // Check for conflicts with core surfaces existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID) if existing != nil && existing.Source == "core" { c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core surface: " + surfaceID}) return } // Extract static assets (js/, css/, assets/) to surfacesDir/{id}/ if h.surfacesDir != "" { destDir := filepath.Join(h.surfacesDir, surfaceID) os.MkdirAll(destDir, 0755) for _, f := range zr.File { if f.FileInfo().IsDir() { continue } // Resolve the relative path, stripping an optional single // top-level directory prefix from the archive. relPath := extractableRelPath(f.Name) if relPath == "" { continue // Not an extractable static file } destPath := filepath.Join(destDir, relPath) // Security: prevent path traversal if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { continue } os.MkdirAll(filepath.Dir(destPath), 0755) rc, err := f.Open() if err != nil { continue } out, err := os.Create(destPath) if err != nil { rc.Close() continue } io.Copy(out, rc) out.Close() rc.Close() } log.Printf("[surfaces] Extracted %s to %s", surfaceID, destDir) } // Register in database if err := h.stores.Surfaces.Seed(c.Request.Context(), surfaceID, title, "extension", manifest); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register surface"}) return } c.JSON(http.StatusOK, gin.H{ "id": surfaceID, "title": title, "source": "extension", "enabled": true, }) } // extractableRelPath returns the relative path for a zip entry if it // belongs to an extractable static directory (js/, css/, assets/). // Returns "" if the file should be skipped. // // Handles two archive layouts: // // Prefixed: my-surface/js/app.js → js/app.js // Flat: js/app.js → js/app.js // // Files not under js/, css/, or assets/ (e.g., manifest.json, script.js // at root) are skipped. func extractableRelPath(name string) string { // Static directory prefixes we extract staticPrefixes := []string{"js/", "css/", "assets/"} // Check if already a relative static path (flat archive) for _, p := range staticPrefixes { if strings.HasPrefix(name, p) { return name } } // Check for a single directory prefix to strip idx := strings.Index(name, "/") if idx <= 0 { return "" // No slash, or starts with slash — skip } rest := name[idx+1:] for _, p := range staticPrefixes { if strings.HasPrefix(rest, p) { return rest } } return "" // Not under a static directory } // ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav). // GET /api/v1/surfaces func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) { surfaces, err := h.stores.Surfaces.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"}) return } // Return only enabled surfaces with minimal info for nav type navSurface struct { ID string `json:"id"` Title string `json:"title"` Route string `json:"route"` } var enabled []navSurface for _, s := range surfaces { if !s.Enabled { continue } route, _ := s.Manifest["route"].(string) enabled = append(enabled, navSurface{ ID: s.ID, Title: s.Title, Route: route, }) } if enabled == nil { enabled = []navSurface{} } c.JSON(http.StatusOK, gin.H{"surfaces": enabled}) }