package handlers import ( "archive/zip" "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "switchboard-core/database" "switchboard-core/models" "switchboard-core/sandbox" "switchboard-core/store" "switchboard-core/triggers" ) // defaultBundledPackages is the curated set of packages installed by default. // Other packages still ship in the Docker image but require BUNDLED_PACKAGES // to be set explicitly (or "*" for all). // // Recommended production override: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules var defaultBundledPackages = map[string]bool{ "notes": true, "chat": true, "chat-core": true, "mermaid-renderer": true, "schedules": true, } // InstallBundledPackages scans bundledDir for .pkg archives and installs // any that don't already exist in the database. Called once at startup // (unless SKIP_BUNDLED_PACKAGES=true). // // allowlist controls which packages are installed: // - Empty string → install the curated default set (see defaultBundledPackages) // - "*" → install all .pkg archives found in the directory // - Comma-separated IDs → install only those (e.g. "tasks,schedules") // // Design: install-once, skip-if-present. If an admin uninstalls a bundled // package, it won't be re-installed on the next restart. func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores store.Stores, runner *sandbox.Runner) { entries, err := os.ReadDir(bundledDir) if err != nil { if os.IsNotExist(err) { log.Printf("[bundled] No bundled packages directory at %s — skipping", bundledDir) return } log.Printf("[bundled] ⚠️ Cannot read bundled packages: %v", err) return } // Parse allowlist: "" → curated defaults, "*" → all, "a,b" → explicit list allowed := parseBundleAllowlist(allowlist) if allowed != nil { if allowlist == "" || allowlist == " " { log.Printf("[bundled] Using curated default set (%d packages)", len(defaultBundledPackages)) } else { log.Printf("[bundled] Allowlist: %v", allowlist) } } var installed, skipped, filtered int for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pkg") { continue } // Check allowlist by filename (strip .pkg extension = package ID) if len(allowed) > 0 { pkgName := strings.TrimSuffix(entry.Name(), ".pkg") if !allowed[pkgName] { filtered++ continue } } pkgPath := filepath.Join(bundledDir, entry.Name()) result, err := installBundledPackage(pkgPath, packagesDir, stores, runner) if err != nil { log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err) continue } if result == "skipped" { skipped++ } else { installed++ } } if installed > 0 || skipped > 0 || filtered > 0 { msg := fmt.Sprintf("[bundled] Installed %d, skipped %d existing", installed, skipped) if filtered > 0 { msg += fmt.Sprintf(", filtered %d by allowlist", filtered) } log.Println(msg) } } // parseBundleAllowlist parses a comma-separated string into a set of // package IDs. Returns: // - nil for "*" input (meaning "install all") // - defaultBundledPackages for empty input // - explicit set for comma-separated list func parseBundleAllowlist(s string) map[string]bool { s = strings.TrimSpace(s) if s == "" { return defaultBundledPackages } if s == "*" { return nil } parts := strings.Split(s, ",") m := make(map[string]bool, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { m[p] = true } } return m } // installBundledPackage installs a single .pkg archive if its package ID // doesn't already exist in the database. Returns "installed" or "skipped". func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, runner *sandbox.Runner) (string, error) { ctx := context.Background() // Open as zip zr, err := zip.OpenReader(pkgPath) if err != nil { return "", fmt.Errorf("invalid zip archive: %w", err) } defer zr.Close() // Find and parse manifest.json manifest, err := extractManifest(zr) if err != nil { return "", err } // Validate manifest structure mInfo, err := ValidateManifest(manifest) if err != nil { return "", fmt.Errorf("invalid manifest: %w", err) } pkgID := mInfo.ID title := mInfo.Title pkgType := mInfo.Type // Skip if already exists (admin uninstalled → stays uninstalled) existing, _ := stores.Packages.Get(ctx, pkgID) if existing != nil { return "skipped", nil } // Extract static assets to packagesDir/{id}/ if packagesDir != "" { if err := extractPackageAssets(zr, packagesDir, pkgID); err != nil { return "", fmt.Errorf("asset extraction: %w", err) } } // Use validated manifest fields for DB columns version := mInfo.Version description := mInfo.Description author := mInfo.Author tier := mInfo.Tier // Register in database via Seed (upsert) if err := stores.Packages.Seed(ctx, pkgID, title, "bundled", manifest); err != nil { return "", fmt.Errorf("seed failed: %w", err) } // Update fields that Seed doesn't set pkg := &store.PackageRegistration{ Title: title, Type: pkgType, Version: version, Description: description, Author: author, Tier: tier, IsSystem: false, Enabled: true, Manifest: manifest, } if err := stores.Packages.Update(ctx, pkgID, pkg); err != nil { log.Printf("[bundled] Failed to update package metadata for %s: %v", pkgID, err) } // Create namespaced DB tables declared in the manifest if tables, ok := ParseDBTables(manifest); ok { if err := CreateExtTables(ctx, database.DB, database.IsPostgres(), stores, pkgID, tables); err != nil { log.Printf("[bundled] [ext-db] schema create failed for %s: %v", pkgID, err) } } // Sync permissions — use a minimal gin.Context for the functions that need it fakeCtx := newBackgroundGinContext() declaredPerms := SyncManifestPermissions(fakeCtx, stores, pkgID, manifest) // Bundled packages are trusted — auto-grant all declared permissions // and restore active status (SyncManifestPermissions sets pending_review). // Use direct SQL with NULL granted_by to satisfy FK constraint on users(id). if len(declaredPerms) > 0 { now := time.Now().UTC().Format(time.RFC3339) var grantSQL string if database.IsPostgres() { grantSQL = `UPDATE extension_permissions SET granted = true, granted_by = NULL, granted_at = $1 WHERE package_id = $2 AND granted = false` } else { grantSQL = `UPDATE extension_permissions SET granted = 1, granted_by = NULL, granted_at = ? WHERE package_id = ? AND granted = 0` } _, err := database.DB.ExecContext(ctx, grantSQL, now, pkgID) if err != nil { log.Printf("[bundled] Failed to auto-grant permissions for %s: %v", pkgID, err) } _ = stores.Packages.SetStatus(ctx, pkgID, "active") } // Sync triggers from manifest SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest) // Install workflow definition if applicable if pkgType == "workflow" { if err := InstallWorkflowFromManifest(fakeCtx, stores, pkgID, manifest); err != nil { log.Printf("[bundled] workflow install failed for %s: %v", pkgID, err) } } // Handle dormant status for packages with unmet requires knownCaps := map[string]bool{} if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 { var unmetReqs []string for _, r := range reqs { req, _ := r.(string) if req != "" && !knownCaps[req] { unmetReqs = append(unmetReqs, req) } } if len(unmetReqs) > 0 { stores.Packages.SetStatus(ctx, pkgID, "dormant") stores.Packages.SetEnabled(ctx, pkgID, false) log.Printf("[bundled] %s: dormant (unmet requires: %v)", pkgID, unmetReqs) } } // Auto-set default_surface when the first surface is installed if (pkgType == "surface" || pkgType == "full") { if raw, err := stores.GlobalConfig.Get(ctx, "default_surface"); err != nil || raw == nil { dflt := models.JSONMap{"id": pkgID} if setErr := stores.GlobalConfig.Set(ctx, "default_surface", dflt, ""); setErr != nil { log.Printf("[bundled] failed to auto-set default_surface to %s: %v", pkgID, setErr) } } } log.Printf("[bundled] Installed %s (%s) v%s", pkgID, pkgType, version) return "installed", nil } // extractManifest reads and parses manifest.json from a zip archive. func extractManifest(zr *zip.ReadCloser) (map[string]any, error) { 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 } var manifest map[string]any if err := json.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("invalid manifest.json: %w", err) } return manifest, nil } } return nil, fmt.Errorf("archive missing manifest.json") } // extractPackageAssets extracts static assets from a zip archive to packagesDir/{pkgID}/. func extractPackageAssets(zr *zip.ReadCloser, packagesDir, pkgID string) error { destDir := filepath.Join(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() } return nil } // newBackgroundGinContext creates a minimal gin.Context backed by a // background context. Used by startup code that calls functions // originally designed for HTTP handlers. func newBackgroundGinContext() *gin.Context { req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil) c := &gin.Context{} c.Request = req return c }