298 lines
8.1 KiB
Go
298 lines
8.1 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
|
|
}
|
|
|
|
// TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/
|
|
// TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/
|
|
|
|
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})
|
|
}
|