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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user