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 }