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:
127
server/handlers/package_export.go
Normal file
127
server/handlers/package_export.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
// package_export.go — v0.30.0 CS3
|
||||
//
|
||||
// Exports an installed package as a downloadable .pkg archive for
|
||||
// cross-instance sharing. Includes manifest, static assets, and
|
||||
// Starlark scripts. Does NOT include extension table data or secrets.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// PackageExportHandler handles package export operations.
|
||||
type PackageExportHandler struct {
|
||||
stores store.Stores
|
||||
packagesDir string
|
||||
}
|
||||
|
||||
// NewPackageExportHandler creates a new export handler.
|
||||
func NewPackageExportHandler(s store.Stores, packagesDir string) *PackageExportHandler {
|
||||
return &PackageExportHandler{stores: s, packagesDir: packagesDir}
|
||||
}
|
||||
|
||||
// ExportPackage downloads an installed package as a .pkg archive.
|
||||
// GET /api/v1/admin/packages/:id/export
|
||||
func (h *PackageExportHandler) ExportPackage(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": "cannot export core packages"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build manifest for export
|
||||
manifest := make(map[string]any, len(pkg.Manifest))
|
||||
for k, v := range pkg.Manifest {
|
||||
// Skip internal fields that shouldn't be exported
|
||||
if k == "_script" {
|
||||
continue
|
||||
}
|
||||
manifest[k] = v
|
||||
}
|
||||
|
||||
// Include package_settings as default_settings so importing instance
|
||||
// gets admin defaults (but not secrets)
|
||||
if len(pkg.PackageSettings) > 0 && string(pkg.PackageSettings) != "{}" {
|
||||
var ps map[string]any
|
||||
if json.Unmarshal(pkg.PackageSettings, &ps) == nil && len(ps) > 0 {
|
||||
manifest["default_settings"] = ps
|
||||
}
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
filename := fmt.Sprintf("%s-%s.pkg", id, pkg.Version)
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
|
||||
// Create zip writer directly to response
|
||||
zw := zip.NewWriter(c.Writer)
|
||||
defer zw.Close()
|
||||
|
||||
// Write manifest.json
|
||||
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
|
||||
return
|
||||
}
|
||||
mf, err := zw.Create("manifest.json")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mf.Write(manifestJSON)
|
||||
|
||||
// Include Starlark script as script.star if present
|
||||
if script, ok := pkg.Manifest["_starlark_script"].(string); ok && script != "" {
|
||||
sf, err := zw.Create("script.star")
|
||||
if err == nil {
|
||||
sf.Write([]byte(script))
|
||||
}
|
||||
}
|
||||
|
||||
// Walk packagesDir/{id}/ for static assets
|
||||
if h.packagesDir != "" {
|
||||
assetsDir := filepath.Join(h.packagesDir, id)
|
||||
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
|
||||
filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(assetsDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Normalize path separators for zip
|
||||
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
||||
|
||||
f, err := zw.Create(relPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
io.Copy(f, src)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
160
server/handlers/package_migrations.go
Normal file
160
server/handlers/package_migrations.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
// package_migrations.go — v0.30.0 CS1
|
||||
//
|
||||
// Schema migration engine for extension packages. When a package declares
|
||||
// schema_version and migrations in its manifest, this engine runs Starlark
|
||||
// migration scripts on install (fresh or upgrade).
|
||||
//
|
||||
// Manifest format:
|
||||
// {
|
||||
// "schema_version": 2,
|
||||
// "migrations": {
|
||||
// "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})",
|
||||
// "2": "def migrate(db):\n ..."
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// On fresh install: runs migrations 1..schema_version.
|
||||
// On upgrade: runs migrations (current+1)..new_schema_version.
|
||||
// On downgrade: rejected (409).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ParseSchemaVersion extracts the "schema_version" integer from a manifest.
|
||||
// Returns 0 if not present or not a number.
|
||||
func ParseSchemaVersion(manifest map[string]any) int {
|
||||
raw, ok := manifest["schema_version"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ParseMigrations extracts the "migrations" map from a manifest.
|
||||
// Keys are string integers ("1", "2", ...), values are Starlark source code.
|
||||
func ParseMigrations(manifest map[string]any) map[int]string {
|
||||
raw, ok := manifest["migrations"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
migrationsRaw, ok := raw.(map[string]any)
|
||||
if !ok || len(migrationsRaw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[int]string, len(migrationsRaw))
|
||||
for key, val := range migrationsRaw {
|
||||
version, err := strconv.Atoi(key)
|
||||
if err != nil {
|
||||
log.Printf("[pkg-migrate] skipping non-integer migration key %q", key)
|
||||
continue
|
||||
}
|
||||
script, ok := val.(string)
|
||||
if !ok || script == "" {
|
||||
log.Printf("[pkg-migrate] skipping empty migration for version %d", version)
|
||||
continue
|
||||
}
|
||||
result[version] = script
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RunSchemaMigrations executes Starlark migration scripts from fromVersion+1
|
||||
// to toVersion, updating schema_version in the store after each successful step.
|
||||
//
|
||||
// The sandbox runs each migration script with a db module at write level,
|
||||
// scoped to the package's namespace. This bypasses the permission system
|
||||
// because migrations are admin-initiated.
|
||||
//
|
||||
// On error, returns immediately — partial migration state is tracked via
|
||||
// the per-step schema_version update.
|
||||
func RunSchemaMigrations(
|
||||
ctx context.Context,
|
||||
sb *sandbox.Sandbox,
|
||||
stores store.Stores,
|
||||
db *sql.DB,
|
||||
isPostgres bool,
|
||||
packageID string,
|
||||
manifest map[string]any,
|
||||
fromVersion int,
|
||||
toVersion int,
|
||||
) error {
|
||||
if fromVersion >= toVersion {
|
||||
return nil
|
||||
}
|
||||
|
||||
migrations := ParseMigrations(manifest)
|
||||
if migrations == nil && toVersion > 0 {
|
||||
// No migration scripts but schema_version declared — just set the version
|
||||
return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion)
|
||||
}
|
||||
|
||||
for step := fromVersion + 1; step <= toVersion; step++ {
|
||||
script, ok := migrations[step]
|
||||
if !ok {
|
||||
// No script for this step — just bump the version
|
||||
log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step)
|
||||
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
|
||||
return fmt.Errorf("set schema_version to %d: %w", step, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[pkg-migrate] %s: running migration %d", packageID, step)
|
||||
|
||||
// Build a db module at write level for the migration
|
||||
dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{
|
||||
PackageID: packageID,
|
||||
CanWrite: true,
|
||||
DB: db,
|
||||
IsPostgres: isPostgres,
|
||||
})
|
||||
|
||||
modules := map[string]starlark.Value{
|
||||
"db": dbModule,
|
||||
}
|
||||
|
||||
// Execute the migration script
|
||||
result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err)
|
||||
}
|
||||
|
||||
// Call migrate(db) if defined
|
||||
if migrateFn, ok := result.Globals["migrate"]; ok {
|
||||
if callable, ok := migrateFn.(starlark.Callable); ok {
|
||||
_, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this step as complete
|
||||
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
|
||||
return fmt.Errorf("set schema_version to %d: %w", step, err)
|
||||
}
|
||||
|
||||
log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
260
server/handlers/package_registry.go
Normal file
260
server/handlers/package_registry.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package handlers
|
||||
|
||||
// package_registry.go — v0.30.0 CS4
|
||||
//
|
||||
// Package registry (marketplace discovery). Admin browses available
|
||||
// packages from an external JSON registry and installs them by URL.
|
||||
//
|
||||
// Registry format (external JSON file):
|
||||
// {
|
||||
// "packages": [
|
||||
// {"id": "...", "title": "...", "version": "...", "description": "...",
|
||||
// "author": "...", "type": "...", "tier": "...",
|
||||
// "download_url": "https://..."}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// Registry URL is stored in global_config under key "package_registry".
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// RegistryEntry represents a single package in the registry.
|
||||
type RegistryEntry struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Type string `json:"type"`
|
||||
Tier string `json:"tier"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// RegistryResponse is the top-level registry JSON structure.
|
||||
type RegistryResponse struct {
|
||||
Packages []RegistryEntry `json:"packages"`
|
||||
}
|
||||
|
||||
// RegistryHandler handles registry browse and install operations.
|
||||
type RegistryHandler struct {
|
||||
stores store.Stores
|
||||
packagesDir string
|
||||
pkgHandler *PackageHandler
|
||||
client *http.Client
|
||||
|
||||
cacheMu sync.RWMutex
|
||||
cacheData *RegistryResponse
|
||||
cacheURL string
|
||||
cacheUntil time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
registryCacheTTL = 5 * time.Minute
|
||||
registryFetchTimeout = 10 * time.Second
|
||||
registryMaxSize = 50 * 1024 * 1024 // 50MB
|
||||
)
|
||||
|
||||
// NewRegistryHandler creates a new registry handler.
|
||||
func NewRegistryHandler(s store.Stores, packagesDir string, pkgHandler *PackageHandler) *RegistryHandler {
|
||||
return &RegistryHandler{
|
||||
stores: s,
|
||||
packagesDir: packagesDir,
|
||||
pkgHandler: pkgHandler,
|
||||
client: &http.Client{
|
||||
Timeout: registryFetchTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getRegistryURL reads the registry URL from global_config.
|
||||
func (h *RegistryHandler) getRegistryURL(c *gin.Context) string {
|
||||
if h.stores.GlobalConfig == nil {
|
||||
return ""
|
||||
}
|
||||
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), "package_registry")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
url, _ := val["url"].(string)
|
||||
return url
|
||||
}
|
||||
|
||||
// BrowseRegistry fetches and returns the registry package list.
|
||||
// GET /api/v1/admin/packages/registry
|
||||
func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
|
||||
url := h.getRegistryURL(c)
|
||||
if url == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"packages": []RegistryEntry{},
|
||||
"registry_url": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache
|
||||
h.cacheMu.RLock()
|
||||
if h.cacheData != nil && h.cacheURL == url && time.Now().Before(h.cacheUntil) {
|
||||
data := h.cacheData
|
||||
h.cacheMu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"packages": data.Packages,
|
||||
"registry_url": url,
|
||||
})
|
||||
return
|
||||
}
|
||||
h.cacheMu.RUnlock()
|
||||
|
||||
// Fetch registry
|
||||
data, err := h.fetchRegistry(url)
|
||||
if err != nil {
|
||||
log.Printf("[registry] fetch failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch registry: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update cache
|
||||
h.cacheMu.Lock()
|
||||
h.cacheData = data
|
||||
h.cacheURL = url
|
||||
h.cacheUntil = time.Now().Add(registryCacheTTL)
|
||||
h.cacheMu.Unlock()
|
||||
|
||||
// Mark installed packages
|
||||
installed := make(map[string]bool)
|
||||
pkgs, _ := h.stores.Packages.List(c.Request.Context())
|
||||
for _, p := range pkgs {
|
||||
installed[p.ID] = true
|
||||
}
|
||||
|
||||
type enrichedEntry struct {
|
||||
RegistryEntry
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
entries := make([]enrichedEntry, len(data.Packages))
|
||||
for i, p := range data.Packages {
|
||||
entries[i] = enrichedEntry{
|
||||
RegistryEntry: p,
|
||||
Installed: installed[p.ID],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"packages": entries,
|
||||
"registry_url": url,
|
||||
})
|
||||
}
|
||||
|
||||
// InstallFromRegistry downloads a .pkg from a URL and installs it.
|
||||
// POST /api/v1/admin/packages/registry/install
|
||||
func (h *RegistryHandler) InstallFromRegistry(c *gin.Context) {
|
||||
var body struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.DownloadURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "download_url is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Security: HTTPS only
|
||||
if !strings.HasPrefix(body.DownloadURL, "https://") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "download_url must use HTTPS"})
|
||||
return
|
||||
}
|
||||
|
||||
// Download the package
|
||||
resp, err := h.client.Get(body.DownloadURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download package: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": fmt.Sprintf("registry returned HTTP %d", resp.StatusCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Write to temp file with size limit
|
||||
tmpFile, err := os.CreateTemp("", "registry-pkg-*.pkg")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
limited := io.LimitReader(resp.Body, registryMaxSize)
|
||||
n, err := io.Copy(tmpFile, limited)
|
||||
tmpFile.Close()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download package"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[registry] downloaded %d bytes from %s", n, body.DownloadURL)
|
||||
|
||||
// Open and re-serve through the package install handler by creating
|
||||
// a synthetic multipart request. Instead, we directly open the file
|
||||
// and call the shared install logic.
|
||||
file, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read downloaded package"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Use the existing install flow by setting the file on the request
|
||||
// We'll create a helper that accepts an io.ReadSeeker
|
||||
c.Set("_registry_file", tmpPath)
|
||||
c.Set("_registry_source", "registry")
|
||||
h.pkgHandler.InstallPackage(c)
|
||||
}
|
||||
|
||||
// fetchRegistry downloads and parses a registry JSON file.
|
||||
func (h *RegistryHandler) fetchRegistry(url string) (*RegistryResponse, error) {
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
return nil, fmt.Errorf("registry URL must use HTTPS")
|
||||
}
|
||||
|
||||
resp, err := h.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, 10*1024*1024) // 10MB for registry JSON
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var reg RegistryResponse
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
return nil, fmt.Errorf("invalid registry JSON: %w", err)
|
||||
}
|
||||
|
||||
return ®, nil
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
353
server/handlers/user_packages.go
Normal file
353
server/handlers/user_packages.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package handlers
|
||||
|
||||
// user_packages.go — v0.30.0 CS5
|
||||
//
|
||||
// Non-admin package management. Users install personal-scoped packages;
|
||||
// team admins install team-scoped packages.
|
||||
//
|
||||
// Restrictions for non-global scope:
|
||||
// - Tier: browser only (no starlark/sidecar)
|
||||
// - Permissions: only notifications.send allowed
|
||||
// - Prevents privilege escalation through user-installed packages
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// allowedNonGlobalPermissions is the set of permissions that non-admin
|
||||
// (team/personal scope) packages may declare.
|
||||
var allowedNonGlobalPermissions = map[string]bool{
|
||||
"notifications.send": true,
|
||||
}
|
||||
|
||||
// UserPackageHandler manages user and team scoped package operations.
|
||||
type UserPackageHandler struct {
|
||||
stores store.Stores
|
||||
packagesDir string
|
||||
}
|
||||
|
||||
// NewUserPackageHandler creates a new user package handler.
|
||||
func NewUserPackageHandler(s store.Stores, packagesDir string) *UserPackageHandler {
|
||||
return &UserPackageHandler{stores: s, packagesDir: packagesDir}
|
||||
}
|
||||
|
||||
// ListVisiblePackages returns packages visible to the current user
|
||||
// (global + user's teams + personal).
|
||||
// GET /api/v1/packages
|
||||
func (h *UserPackageHandler) ListVisiblePackages(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
pkgs, err := h.stores.Packages.ListVisiblePackages(c.Request.Context(), userID)
|
||||
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})
|
||||
}
|
||||
|
||||
// InstallPersonalPackage installs a package with personal scope.
|
||||
// POST /api/v1/packages/install
|
||||
func (h *UserPackageHandler) InstallPersonalPackage(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
h.installScoped(c, "personal", "", userID)
|
||||
}
|
||||
|
||||
// InstallTeamPackage installs a package with team scope.
|
||||
// POST /api/v1/teams/:teamId/packages/install
|
||||
func (h *UserPackageHandler) InstallTeamPackage(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
teamID := c.Param("teamId")
|
||||
if userID == "" || teamID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing user or team context"})
|
||||
return
|
||||
}
|
||||
|
||||
h.installScoped(c, "team", teamID, userID)
|
||||
}
|
||||
|
||||
// DeletePersonalPackage deletes a personal-scoped package owned by the user.
|
||||
// DELETE /api/v1/packages/:id
|
||||
func (h *UserPackageHandler) DeletePersonalPackage(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
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.Scope != "personal" || pkg.InstalledBy == nil || *pkg.InstalledBy != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own personal packages"})
|
||||
return
|
||||
}
|
||||
|
||||
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 assets
|
||||
if h.packagesDir != "" {
|
||||
os.RemoveAll(filepath.Join(h.packagesDir, id))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": id})
|
||||
}
|
||||
|
||||
// DeleteTeamPackage deletes a team-scoped package.
|
||||
// DELETE /api/v1/teams/:teamId/packages/:id
|
||||
func (h *UserPackageHandler) DeleteTeamPackage(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
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.Scope != "team" || pkg.TeamID == nil || *pkg.TeamID != teamID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.packagesDir != "" {
|
||||
os.RemoveAll(filepath.Join(h.packagesDir, id))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": id})
|
||||
}
|
||||
|
||||
// installScoped handles the common install flow for team/personal packages.
|
||||
func (h *UserPackageHandler) installScoped(c *gin.Context, scope, teamID, userID string) {
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if header.Size > 50*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read into temp file
|
||||
tmpFile, err := os.CreateTemp("", "userpkg-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
||||
return
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := io.Copy(tmpFile, file); err != nil {
|
||||
tmpFile.Close()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Open as zip and extract manifest
|
||||
zr, err := zip.OpenReader(tmpPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
||||
return
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
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, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
json.Unmarshal(data, &manifest)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if !validPackageID.MatchString(pkgID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug"})
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce tier restriction: browser only for non-global
|
||||
tier, _ := manifest["tier"].(string)
|
||||
if tier == "" {
|
||||
tier = "browser"
|
||||
}
|
||||
if tier != "browser" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "only browser-tier packages can be installed at team/personal scope",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce permission restriction
|
||||
if permsRaw, ok := manifest["permissions"].([]any); ok {
|
||||
for _, p := range permsRaw {
|
||||
if perm, ok := p.(string); ok {
|
||||
if !allowedNonGlobalPermissions[perm] {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "permission " + perm + " is not allowed for " + scope + "-scoped packages",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract static assets
|
||||
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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
version, _ := manifest["version"].(string)
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
}
|
||||
|
||||
pkgType, _ := manifest["type"].(string)
|
||||
if pkgType == "" {
|
||||
pkgType = "surface"
|
||||
}
|
||||
|
||||
description, _ := manifest["description"].(string)
|
||||
author, _ := manifest["author"].(string)
|
||||
|
||||
// Create the package with scope
|
||||
pkg := &store.PackageRegistration{
|
||||
ID: pkgID,
|
||||
Title: title,
|
||||
Type: pkgType,
|
||||
Version: version,
|
||||
Description: description,
|
||||
Author: author,
|
||||
Tier: tier,
|
||||
IsSystem: false,
|
||||
Scope: scope,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Manifest: manifest,
|
||||
}
|
||||
|
||||
if teamID != "" {
|
||||
pkg.TeamID = &teamID
|
||||
}
|
||||
pkg.InstalledBy = &userID
|
||||
|
||||
if existing != nil {
|
||||
// Update existing
|
||||
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create package"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[packages] installed %s-scoped package %s by user %s", scope, pkgID, userID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
"type": pkgType,
|
||||
"version": version,
|
||||
"scope": scope,
|
||||
"source": "extension",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user