This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/packages_bundled.go
Jeffrey Smith 7915d84c8b
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 29s
Feat v0.6.6 final hardening (#41)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 17:40:40 +00:00

341 lines
9.9 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).
//
// 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
}