All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
350 lines
10 KiB
Go
350 lines
10 KiB
Go
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).
|
|
var defaultBundledPackages = map[string]bool{
|
|
"notes": true,
|
|
"chat-core": true,
|
|
"workflow-chat": true,
|
|
"dashboard": true,
|
|
"workflow-demo": true,
|
|
"bug-report-triage": true,
|
|
"content-approval": true,
|
|
"employee-onboarding": 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
|
|
}
|
|
|
|
pkgID, _ := manifest["id"].(string)
|
|
if pkgID == "" {
|
|
return "", fmt.Errorf("manifest missing 'id'")
|
|
}
|
|
|
|
// Skip if already exists (admin uninstalled → stays uninstalled)
|
|
existing, _ := stores.Packages.Get(ctx, pkgID)
|
|
if existing != nil {
|
|
return "skipped", nil
|
|
}
|
|
|
|
title, _ := manifest["title"].(string)
|
|
if title == "" {
|
|
return "", fmt.Errorf("manifest missing 'title'")
|
|
}
|
|
|
|
pkgType, _ := manifest["type"].(string)
|
|
if pkgType == "" {
|
|
pkgType = "surface"
|
|
}
|
|
|
|
// Extract static assets to packagesDir/{id}/
|
|
if packagesDir != "" {
|
|
if err := extractPackageAssets(zr, packagesDir, pkgID); err != nil {
|
|
return "", fmt.Errorf("asset extraction: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// 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)
|
|
_, err := database.DB.ExecContext(ctx,
|
|
`UPDATE extension_permissions SET granted = 1, granted_by = NULL, granted_at = ? WHERE package_id = ? AND granted = 0`,
|
|
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
|
|
}
|