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/package_registry.go
Jeffrey Smith 680ec3b897
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Feat rebrand armature (#43)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00

261 lines
6.6 KiB
Go

package handlers
// package_registry.go
//
// 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"
"armature/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, &reg); err != nil {
return nil, fmt.Errorf("invalid registry JSON: %w", err)
}
return &reg, nil
}