- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
261 lines
6.6 KiB
Go
261 lines
6.6 KiB
Go
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"
|
|
|
|
"switchboard-core/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{
|
|
"data":[]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{
|
|
"data":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{
|
|
"data":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
|
|
}
|