This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/surfaces.go
2026-03-10 13:38:01 +00:00

303 lines
8.3 KiB
Go

package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// 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
}
// 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
}
// Only extract js/, css/, assets/ directories
name := f.Name
// Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js")
if idx := strings.Index(name, "/"); idx > 0 {
rest := name[idx+1:]
if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") {
name = rest
} else if strings.HasPrefix(name, "js/") || strings.HasPrefix(name, "css/") || strings.HasPrefix(name, "assets/") {
// Already relative
} else {
continue // Skip non-static files (templates, manifest)
}
}
destPath := filepath.Join(destDir, name)
// 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,
})
}
// 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})
}