926 lines
34 KiB
Go
926 lines
34 KiB
Go
package management
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/htmlsanitize"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginstore"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
|
log "github.com/sirupsen/logrus"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
// pluginReleaseCacheTTL bounds how long a resolved latest release version is
|
|
// reused before the GitHub API is queried again.
|
|
pluginReleaseCacheTTL = 10 * time.Minute
|
|
// pluginReleaseFailureCacheTTL throttles retries after a failed lookup so a
|
|
// rate-limited or unreachable API is not hammered on every listing.
|
|
pluginReleaseFailureCacheTTL = 30 * time.Second
|
|
)
|
|
|
|
type pluginReleaseCacheEntry struct {
|
|
version string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type pluginStoreListResponse struct {
|
|
PluginsEnabled bool `json:"plugins_enabled"`
|
|
PluginsDir string `json:"plugins_dir"`
|
|
Sources []pluginStoreSource `json:"sources"`
|
|
SourceErrors []pluginStoreSourceErr `json:"source_errors,omitempty"`
|
|
Plugins []pluginStoreListEntry `json:"plugins"`
|
|
}
|
|
|
|
type pluginStoreSource struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type pluginStoreSourceErr struct {
|
|
SourceID string `json:"source_id"`
|
|
SourceName string `json:"source_name"`
|
|
SourceURL string `json:"source_url"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type pluginStoreListEntry struct {
|
|
StoreID string `json:"store_id"`
|
|
SourceID string `json:"source_id"`
|
|
SourceName string `json:"source_name"`
|
|
SourceURL string `json:"source_url"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Author string `json:"author"`
|
|
Version string `json:"version"`
|
|
Repository string `json:"repository"`
|
|
InstallType string `json:"install_type"`
|
|
AuthRequired bool `json:"auth_required"`
|
|
AuthConfigured bool `json:"auth_configured"`
|
|
Platforms []pluginStorePlatform `json:"platforms,omitempty"`
|
|
Logo string `json:"logo,omitempty"`
|
|
Homepage string `json:"homepage,omitempty"`
|
|
License string `json:"license,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Installed bool `json:"installed"`
|
|
InstalledVersion string `json:"installed_version"`
|
|
InstalledSourceID string `json:"installed_source_id,omitempty"`
|
|
InstallSourceStatus string `json:"install_source_status,omitempty"`
|
|
Path string `json:"path"`
|
|
Configured bool `json:"configured"`
|
|
Registered bool `json:"registered"`
|
|
Enabled bool `json:"enabled"`
|
|
EffectiveEnabled bool `json:"effective_enabled"`
|
|
UpdateAvailable bool `json:"update_available"`
|
|
}
|
|
|
|
type pluginStorePlatform struct {
|
|
GOOS string `json:"goos"`
|
|
GOARCH string `json:"goarch"`
|
|
}
|
|
|
|
type pluginInstallResponse struct {
|
|
Status string `json:"status"`
|
|
SourceID string `json:"source_id"`
|
|
SourceName string `json:"source_name"`
|
|
SourceURL string `json:"source_url"`
|
|
ID string `json:"id"`
|
|
Version string `json:"version"`
|
|
InstallType string `json:"install_type"`
|
|
Path string `json:"path"`
|
|
PluginsEnabled bool `json:"plugins_enabled"`
|
|
RestartRequired bool `json:"restart_required"`
|
|
}
|
|
|
|
type pluginInstallRequest struct {
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
type pluginLocalStatus struct {
|
|
Installed bool
|
|
InstalledVersion string
|
|
StoreManaged bool
|
|
InstalledSourceID string
|
|
InstalledSourceURL string
|
|
Path string
|
|
Configured bool
|
|
Registered bool
|
|
Enabled bool
|
|
EffectiveEnabled bool
|
|
}
|
|
|
|
type sourcedPlugin struct {
|
|
source pluginstore.Source
|
|
plugin pluginstore.Plugin
|
|
}
|
|
|
|
func (h *Handler) ListPluginStore(c *gin.Context) {
|
|
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot()
|
|
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
|
if errSources != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
|
return
|
|
}
|
|
plugins, sourceErrors := h.fetchSourcedPlugins(c.Request.Context(), proxyURL, storeAuth, sources)
|
|
if len(plugins) == 0 && len(sourceErrors) > 0 {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message})
|
|
return
|
|
}
|
|
statuses, errStatus := pluginLocalStatuses(pluginsEnabled, pluginsDir, configs, host)
|
|
if errStatus != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errStatus.Error()})
|
|
return
|
|
}
|
|
|
|
latestInput := make([]pluginstore.Plugin, 0, len(plugins))
|
|
for _, item := range plugins {
|
|
latestInput = append(latestInput, item.plugin)
|
|
}
|
|
client := h.newPluginStoreClient(proxyURL, "", storeAuth)
|
|
latestVersions := h.latestPluginVersions(c.Request.Context(), client, latestInput)
|
|
pluginSourceCounts := make(map[string]int, len(plugins))
|
|
for _, item := range plugins {
|
|
pluginSourceCounts[item.plugin.ID]++
|
|
}
|
|
|
|
entries := make([]pluginStoreListEntry, 0, len(plugins))
|
|
for index, item := range plugins {
|
|
plugin := item.plugin
|
|
status := statuses[plugin.ID]
|
|
installedSourceID, installSourceStatus, sourceAllowsUpdate := pluginStoreInstallSourceStatus(
|
|
status,
|
|
sources,
|
|
item.source.ID,
|
|
pluginSourceCounts[plugin.ID],
|
|
)
|
|
installedVersion := status.InstalledVersion
|
|
// Fall back to the registry version when the latest release is unknown.
|
|
storeVersion := plugin.Version
|
|
if latestVersions[index] != "" {
|
|
storeVersion = latestVersions[index]
|
|
}
|
|
entries = append(entries, pluginStoreListEntry{
|
|
StoreID: htmlsanitize.String(item.source.ID + "/" + plugin.ID),
|
|
SourceID: htmlsanitize.String(item.source.ID),
|
|
SourceName: htmlsanitize.String(item.source.Name),
|
|
SourceURL: htmlsanitize.String(item.source.URL),
|
|
ID: htmlsanitize.String(plugin.ID),
|
|
Name: htmlsanitize.String(plugin.Name),
|
|
Description: htmlsanitize.String(plugin.Description),
|
|
Author: htmlsanitize.String(plugin.Author),
|
|
Version: htmlsanitize.String(storeVersion),
|
|
Repository: htmlsanitize.String(plugin.Repository),
|
|
InstallType: htmlsanitize.String(pluginstore.PluginInstallType(plugin)),
|
|
AuthRequired: plugin.AuthRequired,
|
|
AuthConfigured: pluginAuthConfigured(item.source, plugin, storeAuth),
|
|
Platforms: sanitizePluginStorePlatforms(pluginstore.PluginPlatforms(plugin)),
|
|
Logo: htmlsanitize.String(plugin.Logo),
|
|
Homepage: htmlsanitize.String(plugin.Homepage),
|
|
License: htmlsanitize.String(plugin.License),
|
|
Tags: htmlsanitize.Strings(plugin.Tags),
|
|
Installed: status.Installed,
|
|
InstalledVersion: htmlsanitize.String(installedVersion),
|
|
InstalledSourceID: htmlsanitize.String(installedSourceID),
|
|
InstallSourceStatus: htmlsanitize.String(installSourceStatus),
|
|
Path: htmlsanitize.String(status.Path),
|
|
Configured: status.Configured,
|
|
Registered: status.Registered,
|
|
Enabled: status.Enabled,
|
|
EffectiveEnabled: status.EffectiveEnabled,
|
|
UpdateAvailable: sourceAllowsUpdate && pluginstore.UpdateAvailable(installedVersion, storeVersion),
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, pluginStoreListResponse{
|
|
PluginsEnabled: pluginsEnabled,
|
|
PluginsDir: htmlsanitize.String(pluginsDir),
|
|
Sources: sanitizePluginStoreSources(sources),
|
|
SourceErrors: sanitizePluginStoreSourceErrors(sourceErrors),
|
|
Plugins: entries,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) InstallPluginFromStore(c *gin.Context) {
|
|
h.installPluginFromStore(c, runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) {
|
|
id, okID := pluginIDFromRequest(c)
|
|
if !okID {
|
|
return
|
|
}
|
|
requestedVersion, errVersionRequest := pluginInstallRequestedVersion(c)
|
|
if errVersionRequest != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "message": errVersionRequest.Error()})
|
|
return
|
|
}
|
|
installCtx := c.Request.Context()
|
|
pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, host := h.pluginStoreSnapshot()
|
|
sources, errSources := h.pluginStoreSources(sourceConfigs)
|
|
if errSources != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_store_source_invalid", "message": errSources.Error()})
|
|
return
|
|
}
|
|
source, plugin, client, okPlugin := h.findPluginStoreInstallTarget(installCtx, proxyURL, storeAuth, sources, id, c.Query("source"), c)
|
|
if !okPlugin {
|
|
return
|
|
}
|
|
if !validatePluginStoreInstallSource(c, configs, sources, id, source.ID) {
|
|
return
|
|
}
|
|
pluginIsBusy := func() bool { return pluginBusy(host, id) }
|
|
installOptions := pluginstore.InstallOptions{
|
|
PluginsDir: pluginsDir,
|
|
GOOS: goos,
|
|
GOARCH: goarch,
|
|
PluginLoaded: pluginIsBusy,
|
|
}
|
|
var manifest pluginstore.Manifest
|
|
var result pluginstore.InstallResult
|
|
var errInstall error
|
|
switch pluginstore.PluginInstallType(plugin) {
|
|
case pluginstore.InstallTypeDirect:
|
|
var errManifest error
|
|
manifest, errManifest = pluginStoreDirectManifest(source, plugin, requestedVersion)
|
|
if errManifest != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_manifest_invalid", "message": errManifest.Error()})
|
|
return
|
|
}
|
|
result, errInstall = client.InstallManifest(installCtx, manifest, installOptions)
|
|
case pluginstore.InstallTypeGitHubRelease:
|
|
result, errInstall = installPluginStoreGitHubRelease(installCtx, client, plugin, requestedVersion, installOptions)
|
|
default:
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_manifest_invalid", "message": fmt.Sprintf("unsupported install type %q", plugin.Install.Type)})
|
|
return
|
|
}
|
|
if errInstall != nil {
|
|
if errors.Is(errInstall, pluginstore.ErrLoadedPluginLocked) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "plugin_update_requires_restart",
|
|
"message": "loaded plugin cannot be overwritten while the server is running",
|
|
"restart_required": true,
|
|
})
|
|
return
|
|
}
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_install_failed", "message": errInstall.Error()})
|
|
return
|
|
}
|
|
if manifest.ID == "" {
|
|
var errManifest error
|
|
manifest, errManifest = pluginStoreManifestForInstall(source, plugin, result)
|
|
if errManifest != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "plugin_manifest_failed",
|
|
"message": fmt.Sprintf("plugin file installed at %s but creating store manifest failed: %s", result.Path, errManifest.Error()),
|
|
"path": result.Path,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
restartRequired := false
|
|
|
|
h.mu.Lock()
|
|
if h.cfg == nil {
|
|
h.mu.Unlock()
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "config_unavailable",
|
|
"message": fmt.Sprintf("plugin file installed at %s but config is unavailable to enable it", result.Path),
|
|
"path": result.Path,
|
|
})
|
|
return
|
|
}
|
|
if errEnable := h.enablePluginConfigLocked(id, manifest); errEnable != nil {
|
|
h.mu.Unlock()
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "config_update_failed",
|
|
"message": fmt.Sprintf("plugin file installed at %s but enabling it in config failed: %s", result.Path, errEnable.Error()),
|
|
"path": result.Path,
|
|
})
|
|
return
|
|
}
|
|
if errSave := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); errSave != nil {
|
|
h.mu.Unlock()
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "config_save_failed",
|
|
"message": fmt.Sprintf("plugin file installed at %s but saving config failed: %s", result.Path, errSave.Error()),
|
|
"path": result.Path,
|
|
})
|
|
return
|
|
}
|
|
cfgSnapshot := h.reloadSnapshotConfigLocked()
|
|
h.mu.Unlock()
|
|
|
|
h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), cfgSnapshot)
|
|
log.WithFields(log.Fields{
|
|
"plugin_id": result.ID,
|
|
"plugin_name": plugin.Name,
|
|
"source_id": source.ID,
|
|
"version": result.Version,
|
|
"install_type": result.InstallType,
|
|
"path": result.Path,
|
|
"overwritten": result.Overwritten,
|
|
}).Info("pluginstore: plugin installed")
|
|
|
|
c.JSON(http.StatusOK, pluginInstallResponse{
|
|
Status: "installed",
|
|
SourceID: htmlsanitize.String(source.ID),
|
|
SourceName: htmlsanitize.String(source.Name),
|
|
SourceURL: htmlsanitize.String(source.URL),
|
|
ID: htmlsanitize.String(result.ID),
|
|
Version: htmlsanitize.String(result.Version),
|
|
InstallType: htmlsanitize.String(result.InstallType),
|
|
Path: htmlsanitize.String(result.Path),
|
|
PluginsEnabled: pluginsEnabled,
|
|
RestartRequired: restartRequired,
|
|
})
|
|
}
|
|
|
|
func pluginStoreDirectManifest(source pluginstore.Source, plugin pluginstore.Plugin, requestedVersion string) (pluginstore.Manifest, error) {
|
|
version := normalizePluginStoreRequestedVersion(requestedVersion)
|
|
if version == "" {
|
|
version = normalizePluginStoreRequestedVersion(plugin.Version)
|
|
}
|
|
if normalizePluginStoreRequestedVersion(plugin.Version) == version {
|
|
plugin.Version = version
|
|
return pluginstore.ManifestFromPlugin(source, plugin)
|
|
}
|
|
for _, candidate := range plugin.Versions {
|
|
if normalizePluginStoreRequestedVersion(candidate.Version) != version {
|
|
continue
|
|
}
|
|
plugin.Version = version
|
|
plugin.Install = candidate.Install
|
|
if strings.TrimSpace(plugin.Install.Type) == "" {
|
|
plugin.Install.Type = pluginstore.InstallTypeDirect
|
|
}
|
|
return pluginstore.ManifestFromPlugin(source, plugin)
|
|
}
|
|
return pluginstore.Manifest{}, fmt.Errorf("direct plugin version %q not found", version)
|
|
}
|
|
|
|
func installPluginStoreGitHubRelease(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin, requestedVersion string, options pluginstore.InstallOptions) (pluginstore.InstallResult, error) {
|
|
version := normalizePluginStoreRequestedVersion(requestedVersion)
|
|
if version == "" {
|
|
return client.Install(ctx, plugin, options)
|
|
}
|
|
tags := pluginStoreReleaseTagCandidates(requestedVersion)
|
|
errs := make([]error, 0, len(tags))
|
|
for _, tag := range tags {
|
|
result, errInstall := client.InstallVersion(ctx, plugin, tag, version, options)
|
|
if errInstall == nil {
|
|
return result, nil
|
|
}
|
|
errs = append(errs, fmt.Errorf("%s: %w", tag, errInstall))
|
|
}
|
|
return pluginstore.InstallResult{}, fmt.Errorf("install release by tag: %w", errors.Join(errs...))
|
|
}
|
|
|
|
func pluginStoreManifestForInstall(source pluginstore.Source, plugin pluginstore.Plugin, result pluginstore.InstallResult) (pluginstore.Manifest, error) {
|
|
installType := strings.TrimSpace(result.InstallType)
|
|
if installType == "" {
|
|
installType = pluginstore.PluginInstallType(plugin)
|
|
}
|
|
switch installType {
|
|
case pluginstore.InstallTypeDirect:
|
|
plugin.Version = strings.TrimSpace(result.Version)
|
|
plugin.Install = pluginstore.NormalizeInstallPlan(plugin.Install)
|
|
return pluginstore.ManifestFromPlugin(source, plugin)
|
|
case pluginstore.InstallTypeGitHubRelease:
|
|
releaseTag := strings.TrimSpace(result.ReleaseTag)
|
|
if releaseTag == "" {
|
|
return pluginstore.Manifest{}, fmt.Errorf("release tag is required")
|
|
}
|
|
return pluginstore.ManifestFromRelease(source, plugin, pluginstore.Release{TagName: releaseTag})
|
|
default:
|
|
return pluginstore.Manifest{}, fmt.Errorf("unsupported install type %q", result.InstallType)
|
|
}
|
|
}
|
|
|
|
func pluginInstallRequestedVersion(c *gin.Context) (string, error) {
|
|
requestedVersion := strings.TrimSpace(c.Query("version"))
|
|
if c == nil || c.Request == nil || c.Request.Body == nil || c.Request.Body == http.NoBody {
|
|
return requestedVersion, nil
|
|
}
|
|
body, errRead := io.ReadAll(c.Request.Body)
|
|
if errRead != nil {
|
|
return "", fmt.Errorf("read install request: %w", errRead)
|
|
}
|
|
if strings.TrimSpace(string(body)) == "" {
|
|
return requestedVersion, nil
|
|
}
|
|
var req pluginInstallRequest
|
|
if errDecode := json.Unmarshal(body, &req); errDecode != nil {
|
|
return "", fmt.Errorf("decode install request: %w", errDecode)
|
|
}
|
|
bodyVersion := strings.TrimSpace(req.Version)
|
|
if requestedVersion == "" {
|
|
return bodyVersion, nil
|
|
}
|
|
if bodyVersion == "" || normalizePluginStoreRequestedVersion(bodyVersion) == normalizePluginStoreRequestedVersion(requestedVersion) {
|
|
return requestedVersion, nil
|
|
}
|
|
return "", fmt.Errorf("version query %q does not match request body version %q", requestedVersion, bodyVersion)
|
|
}
|
|
|
|
func pluginStoreReleaseTagCandidates(version string) []string {
|
|
version = strings.TrimSpace(version)
|
|
if version == "" {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(version), "v") {
|
|
return []string{version, strings.TrimSpace(version[1:])}
|
|
}
|
|
return []string{version, "v" + version}
|
|
}
|
|
|
|
func normalizePluginStoreRequestedVersion(version string) string {
|
|
version = strings.TrimSpace(version)
|
|
if strings.HasPrefix(strings.ToLower(version), "v") {
|
|
return strings.TrimSpace(version[1:])
|
|
}
|
|
return version
|
|
}
|
|
|
|
// enablePluginConfigLocked sets plugins.configs.<id>.enabled and store while
|
|
// preserving the rest of the plugin's raw configuration. Callers must hold h.mu.
|
|
func (h *Handler) enablePluginConfigLocked(id string, storeManifest pluginstore.Manifest) error {
|
|
ensurePluginConfigMap(h.cfg)
|
|
node := pluginConfigNode(h.cfg.Plugins.Configs[id])
|
|
storeNode, errStoreNode := pluginStoreManifestYAMLNode(storeManifest)
|
|
if errStoreNode != nil {
|
|
return errStoreNode
|
|
}
|
|
setYAMLMappingValue(node, "enabled", boolYAMLNode(true))
|
|
setYAMLMappingValue(node, "store", storeNode)
|
|
updated, errConfig := pluginInstanceConfigFromNode(node)
|
|
if errConfig != nil {
|
|
return fmt.Errorf("decode plugin config: %w", errConfig)
|
|
}
|
|
h.cfg.Plugins.Configs[id] = updated
|
|
return nil
|
|
}
|
|
|
|
func pluginStoreManifestYAMLNode(manifest pluginstore.Manifest) (*yaml.Node, error) {
|
|
var node yaml.Node
|
|
if errEncode := node.Encode(manifest); errEncode != nil {
|
|
return nil, fmt.Errorf("encode store manifest: %w", errEncode)
|
|
}
|
|
return &node, nil
|
|
}
|
|
|
|
func (h *Handler) pluginStoreSnapshot() (bool, string, string, []string, []pluginstore.AuthConfig, map[string]config.PluginInstanceConfig, *pluginhost.Host) {
|
|
if h == nil {
|
|
return false, "plugins", "", nil, nil, map[string]config.PluginInstanceConfig{}, nil
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.cfg == nil {
|
|
return false, "plugins", "", nil, nil, map[string]config.PluginInstanceConfig{}, nil
|
|
}
|
|
pluginsEnabled := h.cfg.Plugins.Enabled
|
|
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
|
|
proxyURL := strings.TrimSpace(h.cfg.ProxyURL)
|
|
sourceConfigs := append([]string(nil), h.cfg.Plugins.StoreSources...)
|
|
storeAuth := append([]pluginstore.AuthConfig(nil), h.cfg.Plugins.StoreAuth...)
|
|
configs := make(map[string]config.PluginInstanceConfig, len(h.cfg.Plugins.Configs))
|
|
for id, item := range h.cfg.Plugins.Configs {
|
|
configs[id] = item
|
|
}
|
|
return pluginsEnabled, pluginsDir, proxyURL, sourceConfigs, storeAuth, configs, h.pluginHost
|
|
}
|
|
|
|
func (h *Handler) pluginStoreSources(sourceConfigs []string) ([]pluginstore.Source, error) {
|
|
if h != nil && strings.TrimSpace(h.pluginStoreRegistryURL) != "" {
|
|
source := pluginstore.DefaultSource()
|
|
source.URL = strings.TrimSpace(h.pluginStoreRegistryURL)
|
|
return []pluginstore.Source{source}, nil
|
|
}
|
|
return pluginstore.NormalizeSources(sourceConfigs)
|
|
}
|
|
|
|
func (h *Handler) newPluginStoreClient(proxyURL string, registryURL string, storeAuth []pluginstore.AuthConfig) pluginstore.Client {
|
|
registryURL = strings.TrimSpace(registryURL)
|
|
var httpClient pluginstore.HTTPDoer
|
|
if h != nil {
|
|
httpClient = h.pluginStoreHTTPClient
|
|
}
|
|
if registryURL == "" {
|
|
registryURL = pluginstore.DefaultRegistryURL
|
|
}
|
|
if httpClient != nil {
|
|
return pluginstore.Client{HTTPClient: httpClient, RegistryURL: registryURL, Auth: storeAuth}
|
|
}
|
|
client := &http.Client{}
|
|
if strings.TrimSpace(proxyURL) != "" {
|
|
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}, client)
|
|
}
|
|
return pluginstore.Client{HTTPClient: client, RegistryURL: registryURL, Auth: storeAuth}
|
|
}
|
|
|
|
func (h *Handler) fetchSourcedPlugins(ctx context.Context, proxyURL string, storeAuth []pluginstore.AuthConfig, sources []pluginstore.Source) ([]sourcedPlugin, []pluginStoreSourceErr) {
|
|
plugins := make([]sourcedPlugin, 0)
|
|
sourceErrors := make([]pluginStoreSourceErr, 0)
|
|
for _, source := range sources {
|
|
client := h.newPluginStoreClient(proxyURL, source.URL, storeAuth)
|
|
registry, errRegistry := client.FetchRegistry(ctx)
|
|
if errRegistry != nil {
|
|
sourceErrors = append(sourceErrors, pluginStoreSourceErr{
|
|
SourceID: source.ID,
|
|
SourceName: source.Name,
|
|
SourceURL: source.URL,
|
|
Message: errRegistry.Error(),
|
|
})
|
|
continue
|
|
}
|
|
for _, plugin := range registry.Plugins {
|
|
plugins = append(plugins, sourcedPlugin{source: source, plugin: plugin})
|
|
}
|
|
}
|
|
return plugins, sourceErrors
|
|
}
|
|
|
|
func (h *Handler) findPluginStoreInstallTarget(ctx context.Context, proxyURL string, storeAuth []pluginstore.AuthConfig, sources []pluginstore.Source, id string, requestedSourceID string, c *gin.Context) (pluginstore.Source, pluginstore.Plugin, pluginstore.Client, bool) {
|
|
requestedSourceID = strings.TrimSpace(requestedSourceID)
|
|
if requestedSourceID != "" {
|
|
for _, source := range sources {
|
|
if source.ID != requestedSourceID {
|
|
continue
|
|
}
|
|
client := h.newPluginStoreClient(proxyURL, source.URL, storeAuth)
|
|
registry, errRegistry := client.FetchRegistry(ctx)
|
|
if errRegistry != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": errRegistry.Error()})
|
|
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
|
}
|
|
plugin, okPlugin := registry.PluginByID(id)
|
|
if !okPlugin {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry source"})
|
|
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
|
}
|
|
return source, plugin, client, true
|
|
}
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_store_source_not_found", "message": "plugin store source not found"})
|
|
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
|
}
|
|
|
|
plugins, sourceErrors := h.fetchSourcedPlugins(ctx, proxyURL, storeAuth, sources)
|
|
matches := make([]sourcedPlugin, 0)
|
|
for _, item := range plugins {
|
|
if item.plugin.ID == id {
|
|
matches = append(matches, item)
|
|
}
|
|
}
|
|
if len(matches) == 0 {
|
|
if len(plugins) == 0 && len(sourceErrors) > 0 {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "plugin_store_registry_failed", "message": sourceErrors[0].Message})
|
|
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
|
}
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found in registry"})
|
|
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
|
}
|
|
if len(matches) > 1 {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "plugin_store_source_required",
|
|
"message": "multiple plugin store sources contain this plugin id; specify source",
|
|
"sources": sanitizePluginStoreSources(sourcedPluginSources(matches)),
|
|
})
|
|
return pluginstore.Source{}, pluginstore.Plugin{}, pluginstore.Client{}, false
|
|
}
|
|
match := matches[0]
|
|
return match.source, match.plugin, h.newPluginStoreClient(proxyURL, match.source.URL, storeAuth), true
|
|
}
|
|
|
|
func sourcedPluginSources(plugins []sourcedPlugin) []pluginstore.Source {
|
|
sources := make([]pluginstore.Source, 0, len(plugins))
|
|
for _, item := range plugins {
|
|
sources = append(sources, item.source)
|
|
}
|
|
return sources
|
|
}
|
|
|
|
func sanitizePluginStoreSources(sources []pluginstore.Source) []pluginStoreSource {
|
|
out := make([]pluginStoreSource, 0, len(sources))
|
|
for _, source := range sources {
|
|
out = append(out, pluginStoreSource{
|
|
ID: htmlsanitize.String(source.ID),
|
|
Name: htmlsanitize.String(source.Name),
|
|
URL: htmlsanitize.String(source.URL),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sanitizePluginStoreSourceErrors(sourceErrors []pluginStoreSourceErr) []pluginStoreSourceErr {
|
|
if len(sourceErrors) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]pluginStoreSourceErr, 0, len(sourceErrors))
|
|
for _, sourceError := range sourceErrors {
|
|
out = append(out, pluginStoreSourceErr{
|
|
SourceID: htmlsanitize.String(sourceError.SourceID),
|
|
SourceName: htmlsanitize.String(sourceError.SourceName),
|
|
SourceURL: htmlsanitize.String(sourceError.SourceURL),
|
|
Message: htmlsanitize.String(sourceError.Message),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sanitizePluginStorePlatforms(platforms []pluginstore.Platform) []pluginStorePlatform {
|
|
if len(platforms) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]pluginStorePlatform, 0, len(platforms))
|
|
for _, platform := range platforms {
|
|
out = append(out, pluginStorePlatform{
|
|
GOOS: htmlsanitize.String(platform.GOOS),
|
|
GOARCH: htmlsanitize.String(platform.GOARCH),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pluginAuthConfigured(source pluginstore.Source, plugin pluginstore.Plugin, storeAuth []pluginstore.AuthConfig) bool {
|
|
return pluginstore.PluginAuthConfigured(source, plugin, storeAuth)
|
|
}
|
|
|
|
// latestPluginVersions resolves the latest release version of each registry
|
|
// plugin concurrently, returning results positionally aligned with plugins.
|
|
// Unresolved entries are left empty so callers can fall back gracefully.
|
|
func (h *Handler) latestPluginVersions(ctx context.Context, client pluginstore.Client, plugins []pluginstore.Plugin) []string {
|
|
versions := make([]string, len(plugins))
|
|
var wg sync.WaitGroup
|
|
for index := range plugins {
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
versions[index] = h.latestPluginVersion(ctx, client, plugins[index])
|
|
}(index)
|
|
}
|
|
wg.Wait()
|
|
return versions
|
|
}
|
|
|
|
// latestPluginVersion returns the plugin's latest release version, caching
|
|
// lookups per repository so repeated listings do not exhaust the GitHub API
|
|
// rate limit. Failed lookups are cached for a shorter interval and reported
|
|
// as an empty version.
|
|
func (h *Handler) latestPluginVersion(ctx context.Context, client pluginstore.Client, plugin pluginstore.Plugin) string {
|
|
if pluginstore.PluginInstallType(plugin) != pluginstore.InstallTypeGitHubRelease {
|
|
return ""
|
|
}
|
|
repository := strings.TrimSpace(plugin.Repository)
|
|
if repository == "" {
|
|
return ""
|
|
}
|
|
now := time.Now()
|
|
h.pluginReleaseCacheMu.Lock()
|
|
entry, found := h.pluginReleaseCache[repository]
|
|
h.pluginReleaseCacheMu.Unlock()
|
|
if found && now.Before(entry.expiresAt) {
|
|
return entry.version
|
|
}
|
|
|
|
version := ""
|
|
ttl := pluginReleaseFailureCacheTTL
|
|
release, errRelease := client.FetchLatestRelease(ctx, plugin)
|
|
if errRelease != nil {
|
|
log.WithError(errRelease).WithField("plugin_id", plugin.ID).Warn("pluginstore: failed to fetch latest release")
|
|
} else if latestVersion, errVersion := pluginstore.ReleaseVersion(release); errVersion != nil {
|
|
log.WithError(errVersion).WithField("plugin_id", plugin.ID).Warn("pluginstore: invalid latest release tag")
|
|
} else {
|
|
version = latestVersion
|
|
ttl = pluginReleaseCacheTTL
|
|
}
|
|
|
|
h.pluginReleaseCacheMu.Lock()
|
|
if h.pluginReleaseCache == nil {
|
|
h.pluginReleaseCache = make(map[string]pluginReleaseCacheEntry)
|
|
}
|
|
h.pluginReleaseCache[repository] = pluginReleaseCacheEntry{version: version, expiresAt: now.Add(ttl)}
|
|
h.pluginReleaseCacheMu.Unlock()
|
|
return version
|
|
}
|
|
|
|
func pluginLocalStatuses(pluginsEnabled bool, pluginsDir string, configs map[string]config.PluginInstanceConfig, host *pluginhost.Host) (map[string]pluginLocalStatus, error) {
|
|
statuses := map[string]pluginLocalStatus{}
|
|
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir, pluginStoreDesiredVersions(configs))
|
|
if errDiscover != nil {
|
|
return nil, errDiscover
|
|
}
|
|
for _, file := range files {
|
|
status := statuses[file.ID]
|
|
status.Installed = true
|
|
status.Path = file.Path
|
|
if strings.TrimSpace(file.Version) != "" {
|
|
status.InstalledVersion = strings.TrimSpace(file.Version)
|
|
}
|
|
status.Enabled = true
|
|
statuses[file.ID] = status
|
|
}
|
|
for id, item := range configs {
|
|
status := statuses[id]
|
|
status.Configured = true
|
|
status.Enabled = pluginInstanceEnabled(item)
|
|
status.InstalledSourceID, status.InstalledSourceURL, status.StoreManaged = pluginStoreConfiguredSource(item)
|
|
statuses[id] = status
|
|
}
|
|
if host != nil {
|
|
for _, info := range host.RegisteredPlugins() {
|
|
status := statuses[info.ID]
|
|
status.Installed = true
|
|
status.Registered = true
|
|
status.InstalledVersion = strings.TrimSpace(info.Metadata.Version)
|
|
if _, configured := configs[info.ID]; !configured && !status.Enabled {
|
|
status.Enabled = false
|
|
}
|
|
statuses[info.ID] = status
|
|
}
|
|
}
|
|
for id, status := range statuses {
|
|
status.EffectiveEnabled = pluginsEnabled && status.Enabled && status.Registered
|
|
statuses[id] = status
|
|
}
|
|
return statuses, nil
|
|
}
|
|
|
|
func pluginStoreConfiguredSource(item config.PluginInstanceConfig) (sourceID string, sourceURL string, managed bool) {
|
|
storeNode := pluginStoreConfigNode(item)
|
|
if storeNode == nil {
|
|
return "", "", false
|
|
}
|
|
var manifest pluginstore.Manifest
|
|
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
|
|
return "", "", true
|
|
}
|
|
return strings.TrimSpace(manifest.SourceID), strings.TrimSpace(manifest.SourceURL), true
|
|
}
|
|
|
|
func pluginStoreResolveInstalledSource(status pluginLocalStatus, sources []pluginstore.Source) (string, bool) {
|
|
sourceID := strings.TrimSpace(status.InstalledSourceID)
|
|
sourceURL := strings.TrimSpace(status.InstalledSourceURL)
|
|
if sourceID != "" {
|
|
for _, source := range sources {
|
|
if strings.TrimSpace(source.ID) != sourceID {
|
|
continue
|
|
}
|
|
if sourceURL != "" && strings.TrimSpace(source.URL) != sourceURL {
|
|
return "", false
|
|
}
|
|
return sourceID, true
|
|
}
|
|
return sourceID, true
|
|
}
|
|
if sourceURL == "" {
|
|
return "", false
|
|
}
|
|
for _, source := range sources {
|
|
if strings.TrimSpace(source.URL) == sourceURL {
|
|
return strings.TrimSpace(source.ID), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func pluginStoreInstallSourceStatus(status pluginLocalStatus, sources []pluginstore.Source, entrySourceID string, sourceCount int) (installedSourceID string, sourceStatus string, allowUpdate bool) {
|
|
if !status.Installed && !status.Configured && !status.Registered {
|
|
return "", "", true
|
|
}
|
|
if sourceID, known := pluginStoreResolveInstalledSource(status, sources); known {
|
|
if sourceID == strings.TrimSpace(entrySourceID) {
|
|
return sourceID, "matched", true
|
|
}
|
|
return sourceID, "different", false
|
|
}
|
|
if status.StoreManaged || sourceCount > 1 {
|
|
return "", "unknown", false
|
|
}
|
|
return "", "assumed", true
|
|
}
|
|
|
|
func validatePluginStoreInstallSource(c *gin.Context, configs map[string]config.PluginInstanceConfig, sources []pluginstore.Source, id string, requestedSourceID string) bool {
|
|
item, configured := configs[id]
|
|
if !configured {
|
|
return true
|
|
}
|
|
installedSourceID, installedSourceURL, managed := pluginStoreConfiguredSource(item)
|
|
if !managed {
|
|
return true
|
|
}
|
|
status := pluginLocalStatus{
|
|
StoreManaged: true,
|
|
InstalledSourceID: installedSourceID,
|
|
InstalledSourceURL: installedSourceURL,
|
|
}
|
|
resolvedSourceID, known := pluginStoreResolveInstalledSource(status, sources)
|
|
if !known {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "plugin_store_installed_source_unknown",
|
|
"message": "installed plugin source cannot be verified; uninstall it before reinstalling from the store",
|
|
"requested_source_id": strings.TrimSpace(requestedSourceID),
|
|
})
|
|
return false
|
|
}
|
|
if resolvedSourceID != strings.TrimSpace(requestedSourceID) {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": "plugin_store_source_conflict",
|
|
"message": "installed plugin belongs to a different store source; uninstall it before switching sources",
|
|
"installed_source_id": resolvedSourceID,
|
|
"requested_source_id": strings.TrimSpace(requestedSourceID),
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func pluginStoreDesiredVersions(configs map[string]config.PluginInstanceConfig) map[string]string {
|
|
if len(configs) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(configs))
|
|
for id, item := range configs {
|
|
id = strings.TrimSpace(id)
|
|
version := pluginStoreDesiredVersion(item)
|
|
if id == "" || version == "" {
|
|
continue
|
|
}
|
|
out[id] = version
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pluginStoreDesiredVersion(item config.PluginInstanceConfig) string {
|
|
storeNode := pluginStoreConfigNode(item)
|
|
if storeNode == nil {
|
|
return ""
|
|
}
|
|
if version := pluginStoreNormalizeDesiredVersion(pluginStoreYAMLScalar(yamlMappingValue(storeNode, "version"))); version != "" {
|
|
return version
|
|
}
|
|
return pluginStoreNormalizeDesiredVersion(pluginStoreYAMLScalar(yamlMappingValue(storeNode, "release-tag")))
|
|
}
|
|
|
|
func pluginStoreConfigNode(item config.PluginInstanceConfig) *yaml.Node {
|
|
if item.Raw.Kind != yaml.MappingNode {
|
|
return nil
|
|
}
|
|
return yamlMappingValue(&item.Raw, "store")
|
|
}
|
|
|
|
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
|
|
if node == nil || node.Kind != yaml.MappingNode {
|
|
return nil
|
|
}
|
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
keyNode := node.Content[i]
|
|
if keyNode == nil || keyNode.Value != key {
|
|
continue
|
|
}
|
|
return node.Content[i+1]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pluginStoreYAMLScalar(node *yaml.Node) string {
|
|
if node == nil || node.Kind != yaml.ScalarNode {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(node.Value)
|
|
}
|
|
|
|
func pluginStoreNormalizeDesiredVersion(version string) string {
|
|
version = strings.TrimSpace(version)
|
|
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') {
|
|
version = version[1:]
|
|
}
|
|
if version == "" || version[0] < '0' || version[0] > '9' {
|
|
return ""
|
|
}
|
|
return version
|
|
}
|
|
|
|
func pluginBusy(host *pluginhost.Host, id string) bool {
|
|
if host == nil {
|
|
return false
|
|
}
|
|
return host.PluginBusy(id)
|
|
}
|