Changeset 0.30.0 (#199)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -25,7 +27,8 @@ var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
|
||||
// Replaces SurfaceHandler (v0.28.7).
|
||||
type PackageHandler struct {
|
||||
stores store.Stores
|
||||
packagesDir string // e.g. /data/packages — where archives are extracted
|
||||
packagesDir string // e.g. /data/packages — where archives are extracted
|
||||
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
|
||||
}
|
||||
|
||||
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||
@@ -36,6 +39,11 @@ func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||
return &PackageHandler{stores: s, packagesDir: dir}
|
||||
}
|
||||
|
||||
// SetSandbox attaches a Starlark sandbox for schema migrations (v0.30.0).
|
||||
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
|
||||
h.sandbox = sb
|
||||
}
|
||||
|
||||
// ListPackages returns all registered packages.
|
||||
// GET /api/v1/admin/packages
|
||||
// GET /api/v1/admin/surfaces (alias)
|
||||
@@ -142,44 +150,61 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
// InstallPackage uploads and installs a .pkg/.surface archive.
|
||||
// POST /api/v1/admin/packages/install
|
||||
// POST /api/v1/admin/surfaces/install (alias)
|
||||
//
|
||||
// Also supports pre-downloaded files via gin context:
|
||||
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
||||
// c.Set("_registry_source", "registry") — override source
|
||||
func (h *PackageHandler) InstallPackage(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()
|
||||
var tmpPath string
|
||||
var cleanupTmp bool
|
||||
|
||||
// Validate extension
|
||||
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||
strings.HasSuffix(header.Filename, ".surface") ||
|
||||
strings.HasSuffix(header.Filename, ".zip")
|
||||
if !validExt {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||
return
|
||||
}
|
||||
// v0.30.0: Check for pre-downloaded file from registry install
|
||||
if regFile, ok := c.Get("_registry_file"); ok {
|
||||
tmpPath = regFile.(string)
|
||||
// Don't remove — caller manages lifecycle
|
||||
} else {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Size limit: 50MB
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
// Validate extension
|
||||
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
||||
strings.HasSuffix(header.Filename, ".surface") ||
|
||||
strings.HasSuffix(header.Filename, ".zip")
|
||||
if !validExt {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read into temp file for zip processing
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
// Size limit: 50MB
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
// Read into temp file for zip processing
|
||||
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath = tmpFile.Name()
|
||||
cleanupTmp = true
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
if cleanupTmp {
|
||||
defer os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
// Open as zip
|
||||
zr, err := zip.OpenReader(tmpPath)
|
||||
@@ -332,8 +357,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
// v0.30.0: use registry source if set by registry install handler
|
||||
pkgSource := "extension"
|
||||
if src, ok := c.Get("_registry_source"); ok {
|
||||
pkgSource = src.(string)
|
||||
}
|
||||
|
||||
// Register in database via Seed (upsert — handles re-install)
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
|
||||
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
||||
return
|
||||
}
|
||||
@@ -371,12 +402,41 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.30.0: Run schema migrations if declared.
|
||||
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||
if newSchemaVersion > 0 && h.sandbox != nil {
|
||||
oldSchemaVersion := 0
|
||||
if existing != nil {
|
||||
oldSchemaVersion = existing.SchemaVersion
|
||||
}
|
||||
if newSchemaVersion < oldSchemaVersion {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
||||
oldSchemaVersion, newSchemaVersion),
|
||||
})
|
||||
return
|
||||
}
|
||||
if newSchemaVersion > oldSchemaVersion {
|
||||
if err := RunSchemaMigrations(
|
||||
c.Request.Context(), h.sandbox, h.stores,
|
||||
database.DB, database.IsPostgres(),
|
||||
pkgID, manifest, oldSchemaVersion, newSchemaVersion,
|
||||
); err != nil {
|
||||
log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "schema migration failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
"type": pkgType,
|
||||
"version": version,
|
||||
"source": "extension",
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
})
|
||||
}
|
||||
@@ -408,6 +468,100 @@ func extractableRelPath(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Package settings (v0.30.0 CS2) ──────────────
|
||||
|
||||
// GetPackageSettings returns the settings schema from the manifest merged
|
||||
// with current admin-configured values.
|
||||
// GET /api/v1/admin/packages/:id/settings
|
||||
func (h *PackageHandler) GetPackageSettings(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||
if err != nil || pkg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract settings schema from manifest
|
||||
schema, _ := pkg.Manifest["settings"].([]any)
|
||||
|
||||
// Get current values
|
||||
current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
current = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"schema": schema,
|
||||
"values": json.RawMessage(current),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePackageSettings validates and stores admin-configured package settings.
|
||||
// PUT /api/v1/admin/packages/:id/settings
|
||||
func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||
if err != nil || pkg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate keys against declared schema
|
||||
schemaArr, _ := pkg.Manifest["settings"].([]any)
|
||||
validKeys := make(map[string]map[string]any, len(schemaArr))
|
||||
for _, s := range schemaArr {
|
||||
if entry, ok := s.(map[string]any); ok {
|
||||
if key, ok := entry["key"].(string); ok {
|
||||
validKeys[key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to only declared keys and type-check
|
||||
filtered := make(map[string]any, len(body))
|
||||
for k, v := range body {
|
||||
schemaDef, ok := validKeys[k]
|
||||
if !ok {
|
||||
continue // skip undeclared keys
|
||||
}
|
||||
settingType, _ := schemaDef["type"].(string)
|
||||
if validateSettingType(v, settingType) {
|
||||
filtered[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
settingsJSON, _ := json.Marshal(filtered)
|
||||
if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"values": json.RawMessage(settingsJSON)})
|
||||
}
|
||||
|
||||
// validateSettingType checks if a value matches the expected setting type.
|
||||
func validateSettingType(v any, settingType string) bool {
|
||||
switch settingType {
|
||||
case "string", "select":
|
||||
_, ok := v.(string)
|
||||
return ok
|
||||
case "number":
|
||||
_, ok := v.(float64)
|
||||
return ok
|
||||
case "boolean":
|
||||
_, ok := v.(bool)
|
||||
return ok
|
||||
default:
|
||||
return true // unknown types pass through
|
||||
}
|
||||
}
|
||||
|
||||
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
|
||||
// GET /api/v1/surfaces
|
||||
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
||||
|
||||
Reference in New Issue
Block a user