package handlers import ( "archive/zip" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/gin-gonic/gin" "chat-switchboard/database" "chat-switchboard/sandbox" "chat-switchboard/store" ) // validPackageID matches lowercase alphanumeric slugs with optional hyphens. // No leading/trailing hyphens, 2-64 chars. var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) // PackageHandler manages unified package lifecycle (admin-only). // Replaces SurfaceHandler (v0.28.7). type PackageHandler struct { stores store.Stores 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 { dir := "" if len(packagesDir) > 0 { dir = packagesDir[0] } 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) func (h *PackageHandler) ListPackages(c *gin.Context) { typeFilter := c.Query("type") var pkgs []store.PackageRegistration var err error if typeFilter != "" { pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter) } else { pkgs, err = h.stores.Packages.List(c.Request.Context()) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) return } if pkgs == nil { pkgs = []store.PackageRegistration{} } c.JSON(http.StatusOK, gin.H{"data": pkgs}) } // GetPackage returns a single package by ID. // GET /api/v1/admin/packages/:id // GET /api/v1/admin/surfaces/:id (alias) func (h *PackageHandler) GetPackage(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 } c.JSON(http.StatusOK, pkg) } // EnablePackage enables a package. // PUT /api/v1/admin/packages/:id/enable // PUT /api/v1/admin/surfaces/:id/enable (alias) func (h *PackageHandler) EnablePackage(c *gin.Context) { id := c.Param("id") if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true}) } // DisablePackage disables a package. Chat and Admin cannot be disabled. // PUT /api/v1/admin/packages/:id/disable // PUT /api/v1/admin/surfaces/:id/disable (alias) func (h *PackageHandler) DisablePackage(c *gin.Context) { id := c.Param("id") if id == "chat" || id == "admin" { c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"}) return } if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false}) } // DeletePackage uninstalls a package. Core packages cannot be deleted. // DELETE /api/v1/admin/packages/:id // DELETE /api/v1/admin/surfaces/:id (alias) func (h *PackageHandler) DeletePackage(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 } if pkg.Source == "core" { c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"}) return } // v0.29.2: Drop namespaced DB tables before removing the package record. if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil { log.Printf("[ext-db] schema drop failed for %s: %v", id, err) } if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"}) return } // Clean up extracted static assets if h.packagesDir != "" { assetDir := filepath.Join(h.packagesDir, id) if err := os.RemoveAll(assetDir); err != nil { log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err) } } c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true}) } // 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) { var tmpPath string var cleanupTmp bool // 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() // 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 } // 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("", "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() } if cleanupTmp { defer os.Remove(tmpPath) } // 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 pkgID, _ := manifest["id"].(string) title, _ := manifest["title"].(string) if pkgID == "" || title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"}) return } // Validate package ID is a safe slug if !validPackageID.MatchString(pkgID) { c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"}) return } // Determine type (default: surface for backward compat) pkgType, _ := manifest["type"].(string) if pkgType == "" { pkgType = "surface" } if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" { c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"}) return } // Type-specific validation hasRoute := manifest["route"] != nil || manifest["routes"] != nil hasTools := manifest["tools"] != nil hasPipes := manifest["pipes"] != nil hasHooks := manifest["hooks"] != nil hasSettings := manifest["settings"] != nil hasExtBehavior := hasTools || hasPipes || hasHooks switch pkgType { case "surface": // route is optional for surfaces (core surfaces don't always have it in manifest) case "extension": if !hasExtBehavior { c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"}) return } if hasRoute { c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"}) return } case "full": if !hasRoute { c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"}) return } // v0.31.0: full packages need either server-side behavior (tools/pipes/hooks) // or a settings schema. A surface-with-settings is a valid "full" package // (e.g. editor: browser-tier surface + admin-configurable settings). if !hasExtBehavior && !hasSettings { c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks, settings"}) return } case "workflow": if manifest["workflow_definition"] == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"}) return } } // Check for conflicts with core packages existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) if existing != nil && existing.Source == "core" { c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID}) return } // Extract static assets (js/, css/, assets/) to packagesDir/{id}/ if h.packagesDir != "" { destDir := filepath.Join(h.packagesDir, pkgID) os.MkdirAll(destDir, 0755) for _, f := range zr.File { if f.FileInfo().IsDir() { continue } relPath := extractableRelPath(f.Name) if relPath == "" { continue } 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("[packages] Extracted %s to %s", pkgID, destDir) } // Extract manifest fields for DB columns version, _ := manifest["version"].(string) if version == "" { version = "0.0.0" } description, _ := manifest["description"].(string) author, _ := manifest["author"].(string) tier, _ := manifest["tier"].(string) if tier == "" { tier = "browser" } 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, pkgSource, manifest); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"}) return } // Read back to get the preserved enabled state (Seed doesn't override it) existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID) preservedEnabled := true if existing != nil { preservedEnabled = existing.Enabled } // Update fields that Seed doesn't set (type, version, author, etc.) pkg := &store.PackageRegistration{ Title: title, Type: pkgType, Version: version, Description: description, Author: author, Tier: tier, IsSystem: false, Enabled: preservedEnabled, Manifest: manifest, } if userID != "" { pkg.InstalledBy = &userID } if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil { log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err) } // v0.29.2: Create namespaced DB tables declared in the manifest. if tables, ok := ParseDBTables(manifest); ok { if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil { log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err) } } // 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 } } } // v0.30.2: Install workflow definition from package manifest. if pkgType == "workflow" { if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil { log.Printf("[packages] workflow install failed for %s: %v", pkgID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()}) return } } c.JSON(http.StatusOK, gin.H{ "id": pkgID, "title": title, "type": pkgType, "version": version, "source": pkgSource, "enabled": preservedEnabled, }) } // 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. func extractableRelPath(name string) string { staticPrefixes := []string{"js/", "css/", "assets/"} for _, p := range staticPrefixes { if strings.HasPrefix(name, p) { return name } } idx := strings.Index(name, "/") if idx <= 0 { return "" } rest := name[idx+1:] for _, p := range staticPrefixes { if strings.HasPrefix(rest, p) { return rest } } 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{"data": 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) { pkgs, err := h.stores.Packages.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"}) return } type navSurface struct { ID string `json:"id"` Title string `json:"title"` Route string `json:"route"` } var enabled []navSurface for _, p := range pkgs { if !p.Enabled { continue } // Only surface and full types have routes if p.Type != "surface" && p.Type != "full" { continue } route, _ := p.Manifest["route"].(string) enabled = append(enabled, navSurface{ ID: p.ID, Title: p.Title, Route: route, }) } if enabled == nil { enabled = []navSurface{} } c.JSON(http.StatusOK, gin.H{"data": enabled}) }