347 lines
9.4 KiB
Go
347 lines
9.4 KiB
Go
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})
|
|
}
|