mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-03 06:23:54 +08:00
feat: plugin marketplace
This commit is contained in:
@@ -5,7 +5,10 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const SourcePlugin = "plugin"
|
||||
const (
|
||||
SourcePlugin = "plugin"
|
||||
SourceMarketplace = "marketplace"
|
||||
)
|
||||
|
||||
type CatalogEntry struct {
|
||||
Source string `json:"source"`
|
||||
|
||||
493
internal/pluginhost/market.go
Normal file
493
internal/pluginhost/market.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package pluginhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/certimate-go/certimate/pkg/plugin"
|
||||
)
|
||||
|
||||
var ErrMissingProviderType = errors.New("market: providerType is required")
|
||||
|
||||
type MarketEntry struct {
|
||||
*plugin.MarketManifest
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MarketService struct {
|
||||
marketRepo string
|
||||
pluginDir string
|
||||
cache []MarketEntry
|
||||
cachedAt time.Time
|
||||
cacheTTL time.Duration
|
||||
mu sync.RWMutex
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type MarketConfig struct {
|
||||
MarketRepo string
|
||||
PluginDir string
|
||||
CacheTTL time.Duration
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMarketService(cfg MarketConfig) *MarketService {
|
||||
if cfg.CacheTTL <= 0 {
|
||||
cfg.CacheTTL = 5 * time.Minute
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if cfg.MarketRepo == "" {
|
||||
cfg.MarketRepo = "certimate-go/plugins"
|
||||
}
|
||||
return &MarketService{
|
||||
marketRepo: cfg.MarketRepo,
|
||||
pluginDir: cfg.PluginDir,
|
||||
cacheTTL: cfg.CacheTTL,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
logger: cfg.Logger.With(slog.String("component", "market")),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MarketService) ListMarket(ctx context.Context) ([]MarketEntry, error) {
|
||||
s.mu.RLock()
|
||||
if s.cache != nil && time.Since(s.cachedAt) < s.cacheTTL {
|
||||
entries := s.cache
|
||||
s.mu.RUnlock()
|
||||
s.logger.Debug("market listing served from cache", slog.Int("entries", len(entries)))
|
||||
return entries, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
return s.fetchAndCache(ctx)
|
||||
}
|
||||
|
||||
func (s *MarketService) fetchAndCache(ctx context.Context) ([]MarketEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cache != nil && time.Since(s.cachedAt) < s.cacheTTL {
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
entries, err := s.fetchMarketListing(ctx)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
s.logger.Warn("market fetch failed, returning stale cache", slog.Any("error", err))
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache = entries
|
||||
s.cachedAt = time.Now()
|
||||
s.logger.Info("market listing fetched", slog.Int("entries", len(entries)))
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *MarketService) fetchMarketListing(ctx context.Context) ([]MarketEntry, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/contents/", s.marketRepo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
s.setAuth(req)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: fetch directory listing: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, fmt.Errorf("market: GitHub API rate limit reached (status %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("market: GitHub API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var dirEntries []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dirEntries); err != nil {
|
||||
return nil, fmt.Errorf("market: decode directory listing: %w", err)
|
||||
}
|
||||
|
||||
var entries []MarketEntry
|
||||
for _, de := range dirEntries {
|
||||
if de.Type != "dir" {
|
||||
continue
|
||||
}
|
||||
entry, err := s.fetchPluginManifest(ctx, de.Name)
|
||||
if err != nil {
|
||||
s.logger.Warn("market: skipping plugin directory",
|
||||
slog.String("dir", de.Name),
|
||||
slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
entries = append(entries, *entry)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *MarketService) fetchPluginManifest(ctx context.Context, dirName string) (*MarketEntry, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s/manifest.json", s.marketRepo, dirName)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: create manifest request for %s: %w", dirName, err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.raw+json")
|
||||
s.setAuth(req)
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: fetch manifest for %s: %w", dirName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("market: manifest fetch for %s returned status %d", dirName, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: read manifest for %s: %w", dirName, err)
|
||||
}
|
||||
|
||||
mm, err := plugin.ParseMarketManifest(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: parse manifest for %s: %w", dirName, err)
|
||||
}
|
||||
|
||||
status := s.computeStatus(mm)
|
||||
return &MarketEntry{MarketManifest: mm, Status: status}, nil
|
||||
}
|
||||
|
||||
func (s *MarketService) computeStatus(mm *plugin.MarketManifest) string {
|
||||
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
|
||||
if mm.Release == nil || mm.Release.Assets[key] == "" {
|
||||
return "unsupported_platform"
|
||||
}
|
||||
|
||||
pluginPath := filepath.Join(s.pluginDir, mm.ProviderType)
|
||||
info, err := os.Stat(pluginPath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "not_installed"
|
||||
}
|
||||
|
||||
meta, err := plugin.ReadMarketMeta(s.pluginDir, mm.ProviderType)
|
||||
if err != nil || meta == nil {
|
||||
return "installed_manual"
|
||||
}
|
||||
|
||||
if plugin.CompareVersions(meta.InstalledVersion, mm.Version) < 0 {
|
||||
return "update_available"
|
||||
}
|
||||
return "installed"
|
||||
}
|
||||
|
||||
func (s *MarketService) setAuth(req *http.Request) {
|
||||
token := os.Getenv("CERTIMATE_GITHUB_TOKEN")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MarketService) GetMarketManifest(ctx context.Context, providerType string) (*plugin.MarketManifest, error) {
|
||||
entries, err := s.ListMarket(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.ProviderType == providerType {
|
||||
return e.MarketManifest, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("market: plugin %q not found in market listing", providerType)
|
||||
}
|
||||
|
||||
func (s *MarketService) Install(ctx context.Context, providerType string) (*ReloadResult, error) {
|
||||
if err := plugin.ValidateProviderType(providerType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(s.pluginDir, providerType)
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
return nil, fmt.Errorf("market: plugin %q is already installed", providerType)
|
||||
}
|
||||
|
||||
mm, err := s.GetMarketManifest(ctx, providerType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := plugin.ValidateReleaseRepo(mm.Release.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := plugin.ValidateBinaryName(mm.Binary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
|
||||
assetName := mm.Release.Assets[key]
|
||||
if assetName == "" {
|
||||
return nil, fmt.Errorf("market: plugin %q has no binary for %s", providerType, key)
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s",
|
||||
mm.Release.Repo, mm.Release.Tag, assetName)
|
||||
|
||||
tmpDir := filepath.Join(s.pluginDir, ".tmp-"+providerType)
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("market: create temp dir: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
s.logger.Warn("market: failed to clean up temp dir", slog.String("dir", tmpDir), slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
binaryPath := filepath.Join(tmpDir, mm.Binary)
|
||||
if err := s.downloadFile(ctx, downloadURL, binaryPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.Chmod(binaryPath, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("market: chmod binary: %w", err)
|
||||
}
|
||||
|
||||
computedSHA256, err := sha256File(binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: compute sha256: %w", err)
|
||||
}
|
||||
|
||||
if expected, ok := mm.Release.Checksums[key]; ok && expected != "" && expected != computedSHA256 {
|
||||
return nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computedSHA256)
|
||||
}
|
||||
|
||||
localManifest := *mm
|
||||
localManifest.OS = runtime.GOOS
|
||||
localManifest.Arch = runtime.GOARCH
|
||||
localManifest.SHA256 = computedSHA256
|
||||
|
||||
manifestData, err := json.MarshalIndent(&localManifest.Manifest, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: marshal local manifest: %w", err)
|
||||
}
|
||||
manifestPath := filepath.Join(tmpDir, "manifest.json")
|
||||
if err := os.WriteFile(manifestPath, manifestData, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("market: write manifest: %w", err)
|
||||
}
|
||||
|
||||
meta := plugin.NewMarketMeta("official", mm.Version, mm.Version, mm.Release.Tag)
|
||||
if err := plugin.WriteMarketMeta(tmpDir, ".", meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpDir, targetDir); err != nil {
|
||||
return nil, fmt.Errorf("market: atomic rename: %w", err)
|
||||
}
|
||||
|
||||
reloader := GlobalReloader()
|
||||
if reloader == nil {
|
||||
return nil, fmt.Errorf("market: reloader not initialized")
|
||||
}
|
||||
return reloader.ReloadNow(ctx), nil
|
||||
}
|
||||
|
||||
func (s *MarketService) Delete(ctx context.Context, providerType string) (*ReloadResult, error) {
|
||||
if err := plugin.ValidateProviderType(providerType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(s.pluginDir, providerType)
|
||||
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("market: plugin %q is not installed", providerType)
|
||||
}
|
||||
|
||||
meta, err := plugin.ReadMarketMeta(s.pluginDir, providerType)
|
||||
if err != nil || meta == nil {
|
||||
return nil, fmt.Errorf("market: plugin %q is not managed by marketplace", providerType)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(targetDir); err != nil {
|
||||
return nil, fmt.Errorf("market: remove plugin dir: %w", err)
|
||||
}
|
||||
|
||||
reloader := GlobalReloader()
|
||||
if reloader == nil {
|
||||
return nil, fmt.Errorf("market: reloader not initialized")
|
||||
}
|
||||
return reloader.ReloadNow(ctx), nil
|
||||
}
|
||||
|
||||
func (s *MarketService) Update(ctx context.Context, providerType string) (*ReloadResult, error) {
|
||||
if err := plugin.ValidateProviderType(providerType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(s.pluginDir, providerType)
|
||||
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("market: plugin %q is not installed", providerType)
|
||||
}
|
||||
|
||||
mm, err := s.GetMarketManifest(ctx, providerType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meta, err := plugin.ReadMarketMeta(s.pluginDir, providerType)
|
||||
if err != nil || meta == nil {
|
||||
return nil, fmt.Errorf("market: plugin %q is not managed by marketplace", providerType)
|
||||
}
|
||||
|
||||
if plugin.CompareVersions(meta.InstalledVersion, mm.Version) >= 0 {
|
||||
return nil, fmt.Errorf("market: plugin %q is already up to date", providerType)
|
||||
}
|
||||
|
||||
if err := plugin.ValidateReleaseRepo(mm.Release.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := plugin.ValidateBinaryName(mm.Binary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := plugin.AssetKey(runtime.GOOS, runtime.GOARCH)
|
||||
assetName := mm.Release.Assets[key]
|
||||
if assetName == "" {
|
||||
return nil, fmt.Errorf("market: plugin %q has no binary for %s", providerType, key)
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s",
|
||||
mm.Release.Repo, mm.Release.Tag, assetName)
|
||||
|
||||
tmpDir := filepath.Join(s.pluginDir, ".tmp-"+providerType)
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("market: create temp dir: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
s.logger.Warn("market: failed to clean up temp dir", slog.String("dir", tmpDir), slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
binaryPath := filepath.Join(tmpDir, mm.Binary)
|
||||
if err := s.downloadFile(ctx, downloadURL, binaryPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.Chmod(binaryPath, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("market: chmod binary: %w", err)
|
||||
}
|
||||
|
||||
computedSHA256, err := sha256File(binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: compute sha256: %w", err)
|
||||
}
|
||||
|
||||
if expected, ok := mm.Release.Checksums[key]; ok && expected != "" && expected != computedSHA256 {
|
||||
return nil, fmt.Errorf("market: plugin %q checksum mismatch for %s: expected %s, got %s", providerType, key, expected, computedSHA256)
|
||||
}
|
||||
|
||||
localManifest := *mm
|
||||
localManifest.OS = runtime.GOOS
|
||||
localManifest.Arch = runtime.GOARCH
|
||||
localManifest.SHA256 = computedSHA256
|
||||
|
||||
manifestData, err := json.MarshalIndent(&localManifest.Manifest, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("market: marshal local manifest: %w", err)
|
||||
}
|
||||
manifestPath := filepath.Join(tmpDir, "manifest.json")
|
||||
if err := os.WriteFile(manifestPath, manifestData, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("market: write manifest: %w", err)
|
||||
}
|
||||
|
||||
newMeta := plugin.NewMarketMeta("official", mm.Version, mm.Version, mm.Release.Tag)
|
||||
if err := plugin.WriteMarketMeta(tmpDir, ".", newMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(s.pluginDir, ".bak-"+providerType)
|
||||
_ = os.RemoveAll(backupDir)
|
||||
if err := os.Rename(targetDir, backupDir); err != nil {
|
||||
return nil, fmt.Errorf("market: backup old plugin dir: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpDir, targetDir); err != nil {
|
||||
if rerr := os.Rename(backupDir, targetDir); rerr != nil {
|
||||
return nil, fmt.Errorf("market: install rename failed (%v) and rollback failed (%v)", err, rerr)
|
||||
}
|
||||
return nil, fmt.Errorf("market: install rename: %w", err)
|
||||
}
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
s.logger.Warn("market: failed to clean up backup dir", slog.String("dir", backupDir), slog.Any("error", err))
|
||||
}
|
||||
|
||||
reloader := GlobalReloader()
|
||||
if reloader == nil {
|
||||
return nil, fmt.Errorf("market: reloader not initialized")
|
||||
}
|
||||
return reloader.ReloadNow(ctx), nil
|
||||
}
|
||||
|
||||
func (s *MarketService) downloadFile(ctx context.Context, url, dest string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("market: create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("market: download %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("market: download returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("market: create dest file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
return fmt.Errorf("market: write download: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package pluginhost
|
||||
|
||||
var globalCatalog = NewCatalog()
|
||||
var globalReloader *Reloader
|
||||
var globalMarketService *MarketService
|
||||
|
||||
func SetGlobalCatalog(c *Catalog) {
|
||||
if c != nil {
|
||||
@@ -20,3 +21,11 @@ func SetGlobalReloader(r *Reloader) {
|
||||
func GlobalReloader() *Reloader {
|
||||
return globalReloader
|
||||
}
|
||||
|
||||
func SetGlobalMarketService(s *MarketService) {
|
||||
globalMarketService = s
|
||||
}
|
||||
|
||||
func GlobalMarketService() *MarketService {
|
||||
return globalMarketService
|
||||
}
|
||||
|
||||
93
internal/rest/handlers/pluginmarket.go
Normal file
93
internal/rest/handlers/pluginmarket.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"github.com/certimate-go/certimate/internal/pluginhost"
|
||||
"github.com/certimate-go/certimate/internal/rest/resp"
|
||||
)
|
||||
|
||||
type marketService interface {
|
||||
ListMarket(ctx context.Context) ([]pluginhost.MarketEntry, error)
|
||||
Install(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error)
|
||||
Delete(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error)
|
||||
Update(ctx context.Context, providerType string) (*pluginhost.ReloadResult, error)
|
||||
}
|
||||
|
||||
type PluginMarketHandler struct {
|
||||
service marketService
|
||||
}
|
||||
|
||||
func NewPluginMarketHandler(rg *router.RouterGroup[*core.RequestEvent], service marketService) {
|
||||
h := &PluginMarketHandler{service: service}
|
||||
rg.GET("/plugin/market", h.listMarket)
|
||||
rg.POST("/plugin/market/install", h.install)
|
||||
rg.DELETE("/plugin/market/{providerType}", h.delete)
|
||||
rg.POST("/plugin/market/update/{providerType}", h.updatePlugin)
|
||||
}
|
||||
|
||||
func (h *PluginMarketHandler) listMarket(e *core.RequestEvent) error {
|
||||
entries, err := h.service.ListMarket(e.Request.Context())
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
return resp.Ok(e, entries)
|
||||
}
|
||||
|
||||
type installRequest struct {
|
||||
ProviderType string `json:"providerType"`
|
||||
}
|
||||
|
||||
func (h *PluginMarketHandler) install(e *core.RequestEvent) error {
|
||||
var req installRequest
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
if req.ProviderType == "" {
|
||||
return resp.Err(e, pluginhost.ErrMissingProviderType)
|
||||
}
|
||||
result, err := h.service.Install(e.Request.Context(), req.ProviderType)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
return resp.Ok(e, result)
|
||||
}
|
||||
|
||||
func (h *PluginMarketHandler) delete(e *core.RequestEvent) error {
|
||||
providerType := e.Request.PathValue("providerType")
|
||||
if providerType == "" {
|
||||
return resp.Err(e, pluginhost.ErrMissingProviderType)
|
||||
}
|
||||
result, err := h.service.Delete(e.Request.Context(), providerType)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
return resp.Ok(e, result)
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
ProviderType string `json:"providerType"`
|
||||
}
|
||||
|
||||
func (h *PluginMarketHandler) updatePlugin(e *core.RequestEvent) error {
|
||||
var req updateRequest
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
providerType := req.ProviderType
|
||||
if providerType == "" {
|
||||
providerType = e.Request.PathValue("providerType")
|
||||
}
|
||||
if providerType == "" {
|
||||
return resp.Err(e, pluginhost.ErrMissingProviderType)
|
||||
}
|
||||
result, err := h.service.Update(e.Request.Context(), providerType)
|
||||
if err != nil {
|
||||
return resp.Err(e, err)
|
||||
}
|
||||
return resp.Ok(e, result)
|
||||
}
|
||||
@@ -47,4 +47,5 @@ func BindRouter(router *router.Router[*core.RequestEvent]) {
|
||||
handlers.NewProviderSchemaHandler(group, providerSchemaSvc)
|
||||
handlers.NewPluginCatalogHandler(group, pluginhost.GlobalCatalog())
|
||||
handlers.NewPluginAdminHandler(group)
|
||||
handlers.NewPluginMarketHandler(group, pluginhost.GlobalMarketService())
|
||||
}
|
||||
|
||||
6
main.go
6
main.go
@@ -126,6 +126,12 @@ func scanPlugins() {
|
||||
reloader.InitFromCatalog()
|
||||
pluginhost.SetGlobalReloader(reloader)
|
||||
|
||||
marketSvc := pluginhost.NewMarketService(pluginhost.MarketConfig{
|
||||
PluginDir: pluginDir,
|
||||
Logger: logger,
|
||||
})
|
||||
pluginhost.SetGlobalMarketService(marketSvc)
|
||||
|
||||
watcher := pluginhost.NewWatcher(pluginDir, logger)
|
||||
watcher.Start(context.Background())
|
||||
reloader.Start(context.Background(), watcher)
|
||||
|
||||
67
pkg/plugin/market_manifest.go
Normal file
67
pkg/plugin/market_manifest.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var providerTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
var repoPattern = regexp.MustCompile(`^certimate-go/[a-z0-9_.-]+$`)
|
||||
|
||||
type Release struct {
|
||||
Repo string `json:"repo"`
|
||||
Tag string `json:"tag"`
|
||||
Assets map[string]string `json:"assets"`
|
||||
Checksums map[string]string `json:"checksums,omitempty"`
|
||||
}
|
||||
|
||||
type MarketManifest struct {
|
||||
Manifest
|
||||
Release *Release `json:"release,omitempty"`
|
||||
}
|
||||
|
||||
func ParseMarketManifest(data []byte) (*MarketManifest, error) {
|
||||
var m MarketManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("plugin: invalid market manifest json: %w", err)
|
||||
}
|
||||
if m.Release == nil {
|
||||
return nil, fmt.Errorf("plugin: market manifest for %q has no release block", m.ProviderType)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func ValidateProviderType(pt string) error {
|
||||
if pt == "" {
|
||||
return fmt.Errorf("plugin: provider_type must not be empty")
|
||||
}
|
||||
if !providerTypePattern.MatchString(pt) {
|
||||
return fmt.Errorf("plugin: provider_type %q contains invalid characters (allowed: a-z, 0-9, underscore, dot, hyphen)", pt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateBinaryName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("plugin: binary must not be empty")
|
||||
}
|
||||
if !providerTypePattern.MatchString(name) {
|
||||
return fmt.Errorf("plugin: binary %q contains invalid characters (allowed: a-z, 0-9, underscore, dot, hyphen)", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateReleaseRepo(repo string) error {
|
||||
if repo == "" {
|
||||
return fmt.Errorf("plugin: release repo must not be empty")
|
||||
}
|
||||
if !repoPattern.MatchString(repo) {
|
||||
return fmt.Errorf("plugin: release repo %q is not in the trusted organization (certimate-go/)", repo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AssetKey(goos, goarch string) string {
|
||||
return goos + "/" + goarch
|
||||
}
|
||||
177
pkg/plugin/market_manifest_test.go
Normal file
177
pkg/plugin/market_manifest_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMarketManifest(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"version": "1.0.0",
|
||||
"provider_type": "webhook-deployer",
|
||||
"access_provider_type": "webhook",
|
||||
"display_name_key": "plugin.webhook-deployer.name",
|
||||
"deploy_category": "other",
|
||||
"protocol_version": 1,
|
||||
"binary": "webhook-deployer",
|
||||
"description": "Deploy via webhook",
|
||||
"release": {
|
||||
"repo": "certimate-go/plugins",
|
||||
"tag": "webhook-deployer/v1.0.0",
|
||||
"assets": {
|
||||
"linux/amd64": "webhook-deployer_linux_amd64",
|
||||
"darwin/arm64": "webhook-deployer_darwin_arm64"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
mm, err := ParseMarketManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if mm.ProviderType != "webhook-deployer" {
|
||||
t.Errorf("expected provider_type webhook-deployer, got %s", mm.ProviderType)
|
||||
}
|
||||
if mm.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", mm.Version)
|
||||
}
|
||||
if mm.Release == nil {
|
||||
t.Fatal("expected non-nil release")
|
||||
}
|
||||
if mm.Release.Repo != "certimate-go/plugins" {
|
||||
t.Errorf("expected repo certimate-go/plugins, got %s", mm.Release.Repo)
|
||||
}
|
||||
if mm.Release.Tag != "webhook-deployer/v1.0.0" {
|
||||
t.Errorf("expected tag webhook-deployer/v1.0.0, got %s", mm.Release.Tag)
|
||||
}
|
||||
if len(mm.Release.Assets) != 2 {
|
||||
t.Errorf("expected 2 assets, got %d", len(mm.Release.Assets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarketManifest_NoRelease(t *testing.T) {
|
||||
data := []byte(`{"version": "1.0.0", "provider_type": "test"}`)
|
||||
_, err := ParseMarketManifest(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for manifest without release block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarketManifest_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseMarketManifest([]byte(`{invalid`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
valid bool
|
||||
}{
|
||||
{"simple", "webhook-deployer", true},
|
||||
{"with dots", "com.example.plugin", true},
|
||||
{"with underscores", "my_plugin_v2", true},
|
||||
{"alphanumeric only", "plugin123", true},
|
||||
{"empty", "", false},
|
||||
{"path traversal", "../../etc/passwd", false},
|
||||
{"with slash", "foo/bar", false},
|
||||
{"starts with dot", ".hidden", false},
|
||||
{"with spaces", "my plugin", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateProviderType(tt.input)
|
||||
if tt.valid && err != nil {
|
||||
t.Errorf("expected valid, got error: %v", err)
|
||||
}
|
||||
if !tt.valid && err == nil {
|
||||
t.Errorf("expected error for %q", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReleaseRepo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
valid bool
|
||||
}{
|
||||
{"valid", "certimate-go/plugins", true},
|
||||
{"valid with dots", "certimate-go/my.plugin-repo_v2", true},
|
||||
{"empty", "", false},
|
||||
{"wrong org", "other-org/plugins", false},
|
||||
{"path traversal", "../etc/passwd", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateReleaseRepo(tt.input)
|
||||
if tt.valid && err != nil {
|
||||
t.Errorf("expected valid, got error: %v", err)
|
||||
}
|
||||
if !tt.valid && err == nil {
|
||||
t.Errorf("expected error for %q", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetKey(t *testing.T) {
|
||||
key := AssetKey("linux", "amd64")
|
||||
if key != "linux/amd64" {
|
||||
t.Errorf("expected linux/amd64, got %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBinaryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
valid bool
|
||||
}{
|
||||
{"simple", "webhook-deployer", true},
|
||||
{"with arch suffix", "webhook-deployer_linux_amd64", true},
|
||||
{"with exe", "webhook-deployer.exe", true},
|
||||
{"empty", "", false},
|
||||
{"path traversal", "../evil", false},
|
||||
{"absolute path", "/bin/sh", false},
|
||||
{"with slash", "foo/bar", false},
|
||||
{"parent dir", "..", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateBinaryName(tt.input)
|
||||
if tt.valid && err != nil {
|
||||
t.Errorf("expected valid, got error: %v", err)
|
||||
}
|
||||
if !tt.valid && err == nil {
|
||||
t.Errorf("expected error for %q", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarketManifest_WithChecksums(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"version": "1.0.0",
|
||||
"provider_type": "webhook-deployer",
|
||||
"access_provider_type": "webhook",
|
||||
"protocol_version": 1,
|
||||
"binary": "webhook-deployer",
|
||||
"release": {
|
||||
"repo": "certimate-go/plugins",
|
||||
"tag": "v1.0.0",
|
||||
"assets": {"linux/amd64": "webhook-deployer_linux_amd64"},
|
||||
"checksums": {"linux/amd64": "abc123"}
|
||||
}
|
||||
}`)
|
||||
|
||||
mm, err := ParseMarketManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := mm.Release.Checksums["linux/amd64"]; got != "abc123" {
|
||||
t.Errorf("expected checksum abc123, got %s", got)
|
||||
}
|
||||
}
|
||||
68
pkg/plugin/market_meta.go
Normal file
68
pkg/plugin/market_meta.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MarketMeta records provenance and version information for a marketplace-installed
|
||||
// plugin. Stored as .market.json alongside the plugin's manifest.json.
|
||||
type MarketMeta struct {
|
||||
Source string `json:"source"`
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
MarketVersionAtInstall string `json:"market_version_at_install"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
ReleaseTag string `json:"release_tag"`
|
||||
}
|
||||
|
||||
// ReadMarketMeta reads the .market.json file for a plugin in the given directory.
|
||||
// Returns (nil, nil) if the file does not exist (plugin is not marketplace-managed).
|
||||
func ReadMarketMeta(pluginDir, providerType string) (*MarketMeta, error) {
|
||||
path := filepath.Join(pluginDir, providerType, ".market.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("plugin: read market meta for %q: %w", providerType, err)
|
||||
}
|
||||
var meta MarketMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, fmt.Errorf("plugin: parse market meta for %q: %w", providerType, err)
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
// WriteMarketMeta writes the .market.json file for a plugin.
|
||||
func WriteMarketMeta(pluginDir, providerType string, meta *MarketMeta) error {
|
||||
path := filepath.Join(pluginDir, providerType, ".market.json")
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin: marshal market meta for %q: %w", providerType, err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("plugin: write market meta for %q: %w", providerType, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewMarketMeta creates a MarketMeta with the current timestamp.
|
||||
func NewMarketMeta(source, installedVersion, marketVersion, releaseTag string) *MarketMeta {
|
||||
return &MarketMeta{
|
||||
Source: source,
|
||||
InstalledVersion: installedVersion,
|
||||
MarketVersionAtInstall: marketVersion,
|
||||
InstalledAt: time.Now().UTC().Format(time.RFC3339),
|
||||
ReleaseTag: releaseTag,
|
||||
}
|
||||
}
|
||||
|
||||
// CompareVersions compares two version strings using semver comparison.
|
||||
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
|
||||
// Falls back to string comparison for non-semver versions.
|
||||
func CompareVersions(a, b string) int {
|
||||
return semverCompare(a, b)
|
||||
}
|
||||
55
ui/src/api/pluginmarket.ts
Normal file
55
ui/src/api/pluginmarket.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { get as httpGet, post as httpPost } from "./_api";
|
||||
import { getPocketBase } from "@/repository/_pocketbase";
|
||||
|
||||
const pb = getPocketBase();
|
||||
|
||||
export interface MarketEntry {
|
||||
provider_type: string;
|
||||
access_provider_type: string;
|
||||
display_name_key: string;
|
||||
deploy_category: string;
|
||||
version: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
protocol_version: number;
|
||||
status: "not_installed" | "installed" | "update_available" | "installed_manual" | "unsupported_platform";
|
||||
release?: {
|
||||
repo: string;
|
||||
tag: string;
|
||||
assets: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export const fetchMarketListing = async (): Promise<MarketEntry[]> => {
|
||||
try {
|
||||
const resp = await httpGet<MarketEntry[]>({
|
||||
url: "/api/plugin/market",
|
||||
});
|
||||
return resp.data ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const installPlugin = async (providerType: string): Promise<unknown> => {
|
||||
const resp = await httpPost<unknown>({
|
||||
url: "/api/plugin/market/install",
|
||||
body: { providerType },
|
||||
});
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
export const deletePlugin = async (providerType: string): Promise<unknown> => {
|
||||
const resp = await pb.send<{ code: number; data: unknown }>(`/api/plugin/market/${providerType}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return resp.data;
|
||||
};
|
||||
|
||||
export const updatePlugin = async (providerType: string): Promise<unknown> => {
|
||||
const resp = await httpPost<unknown>({
|
||||
url: `/api/plugin/market/update/${providerType}`,
|
||||
body: { providerType },
|
||||
});
|
||||
return resp.data;
|
||||
};
|
||||
@@ -19,6 +19,13 @@ const usageMap: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const applyPluginCatalog = (entries: PluginCatalogEntry[]): void => {
|
||||
for (const [key, val] of deploymentProvidersMap) {
|
||||
if (val.source === "plugin") deploymentProvidersMap.delete(key);
|
||||
}
|
||||
for (const [key, val] of accessProvidersMap) {
|
||||
if (val.source === "plugin") accessProvidersMap.delete(key);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const accessType = entry.accessProviderType || entry.providerType;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json";
|
||||
import nlsCommon from "./nls.common.json";
|
||||
import nlsDashboard from "./nls.dashboard.json";
|
||||
import nlsLogin from "./nls.login.json";
|
||||
import nlsPlugin from "./nls.plugin.json";
|
||||
import nlsPreset from "./nls.preset.json";
|
||||
import nlsProvider from "./nls.provider.json";
|
||||
import nlsSettings from "./nls.settings.json";
|
||||
@@ -19,6 +20,7 @@ export default buildTranslations(
|
||||
nlsProvider,
|
||||
nlsAccess,
|
||||
nlsPreset,
|
||||
nlsPlugin,
|
||||
nlsCertificate,
|
||||
nlsWorkflow,
|
||||
nlsWorkflowNodes,
|
||||
|
||||
40
ui/src/i18n/resources/en/nls.plugin.json
Normal file
40
ui/src/i18n/resources/en/nls.plugin.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$ns": "plugin",
|
||||
|
||||
"market": {
|
||||
"nudge": "Didn't find the provider you need? Browse the plugin marketplace for more.",
|
||||
"browse": "Browse marketplace",
|
||||
|
||||
"empty": "No plugins available",
|
||||
"error": {
|
||||
"load_failed": "Failed to load plugin marketplace",
|
||||
"install_failed": "Failed to install plugin",
|
||||
"delete_failed": "Failed to delete plugin",
|
||||
"update_failed": "Failed to update plugin"
|
||||
},
|
||||
"msg": {
|
||||
"installed": "Plugin installed",
|
||||
"deleted": "Plugin removed",
|
||||
"updated": "Plugin updated"
|
||||
},
|
||||
"action": {
|
||||
"install": "Install",
|
||||
"delete": "Delete",
|
||||
"update": "Update to",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"label": {
|
||||
"update_available": "Update available",
|
||||
"manual": "Manual",
|
||||
"unsupported_platform": "Unsupported platform"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "Not installed",
|
||||
"installed": "Installed",
|
||||
"update_available": "Update available",
|
||||
"installed_manual": "Manual",
|
||||
"unsupported_platform": "Unsupported"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,10 @@
|
||||
}
|
||||
},
|
||||
|
||||
"plugins": {
|
||||
"tab": "Plugins"
|
||||
},
|
||||
|
||||
"sslprovider": {
|
||||
"tab": "Certificate authority",
|
||||
"ca": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json";
|
||||
import nlsCommon from "./nls.common.json";
|
||||
import nlsDashboard from "./nls.dashboard.json";
|
||||
import nlsLogin from "./nls.login.json";
|
||||
import nlsPlugin from "./nls.plugin.json";
|
||||
import nlsPreset from "./nls.preset.json";
|
||||
import nlsProvider from "./nls.provider.json";
|
||||
import nlsSettings from "./nls.settings.json";
|
||||
@@ -19,6 +20,7 @@ export default buildTranslations(
|
||||
nlsProvider,
|
||||
nlsAccess,
|
||||
nlsPreset,
|
||||
nlsPlugin,
|
||||
nlsCertificate,
|
||||
nlsWorkflow,
|
||||
nlsWorkflowNodes,
|
||||
|
||||
40
ui/src/i18n/resources/zh/nls.plugin.json
Normal file
40
ui/src/i18n/resources/zh/nls.plugin.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$ns": "plugin",
|
||||
|
||||
"market": {
|
||||
"nudge": "没找到你需要的提供商?前往插件市场发现更多。",
|
||||
"browse": "浏览插件市场",
|
||||
|
||||
"empty": "暂无可用插件",
|
||||
"error": {
|
||||
"load_failed": "加载插件市场失败",
|
||||
"install_failed": "安装插件失败",
|
||||
"delete_failed": "删除插件失败",
|
||||
"update_failed": "更新插件失败"
|
||||
},
|
||||
"msg": {
|
||||
"installed": "插件安装成功",
|
||||
"deleted": "插件已删除",
|
||||
"updated": "插件已更新"
|
||||
},
|
||||
"action": {
|
||||
"install": "安装",
|
||||
"delete": "删除",
|
||||
"update": "更新到",
|
||||
"retry": "重试",
|
||||
"refresh": "刷新"
|
||||
},
|
||||
"label": {
|
||||
"update_available": "有新版本",
|
||||
"manual": "手动安装",
|
||||
"unsupported_platform": "不支持当前平台"
|
||||
},
|
||||
"status": {
|
||||
"not_installed": "未安装",
|
||||
"installed": "已安装",
|
||||
"update_available": "可更新",
|
||||
"installed_manual": "手动安装",
|
||||
"unsupported_platform": "不支持"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,10 @@
|
||||
}
|
||||
},
|
||||
|
||||
"plugins": {
|
||||
"tab": "插件"
|
||||
},
|
||||
|
||||
"sslprovider": {
|
||||
"tab": "证书颁发机构",
|
||||
"ca": {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMount } from "ahooks";
|
||||
import { App, Button, Flex, Form } from "antd";
|
||||
import { App, Button, Flex, Form, Alert } from "antd";
|
||||
|
||||
import AccessForm, { type AccessFormUsages } from "@/components/access/AccessForm";
|
||||
import AccessProviderPicker, { type AccessProviderPickerInstance } from "@/components/provider/AccessProviderPicker";
|
||||
@@ -80,6 +80,17 @@ const AccessNew = () => {
|
||||
|
||||
<div className="container">
|
||||
<Show when={!fieldProvider}>
|
||||
<Alert
|
||||
type="info"
|
||||
message={t("plugin.market.nudge")}
|
||||
action={
|
||||
<Button size="small" onClick={() => navigate("/settings/plugins")}>
|
||||
{t("plugin.market.browse")}
|
||||
</Button>
|
||||
}
|
||||
closable
|
||||
className="mb-4"
|
||||
/>
|
||||
<AccessProviderPicker
|
||||
ref={providerPickerRef}
|
||||
gap="large"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconPalette, IconPlugConnected, IconUserShield } from "@tabler/icons-react";
|
||||
import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconPalette, IconPlugConnected, IconPuzzle, IconUserShield } from "@tabler/icons-react";
|
||||
import { Menu } from "antd";
|
||||
|
||||
const Settings = () => {
|
||||
@@ -14,6 +14,7 @@ const Settings = () => {
|
||||
["account", "settings.account.tab", <IconUserShield size="1em" />],
|
||||
["appearance", "settings.appearance.tab", <IconPalette size="1em" />],
|
||||
["ssl-provider", "settings.sslprovider.tab", <IconPlugConnected size="1em" />],
|
||||
["plugins", "settings.plugins.tab", <IconPuzzle size="1em" />],
|
||||
["persistence", "settings.persistence.tab", <IconDatabaseCog size="1em" />],
|
||||
["diagnostics", "settings.diagnostics.tab", <IconHeartRateMonitor size="1em" />],
|
||||
["about", "settings.about.tab", <IconInfoCircle size="1em" />],
|
||||
|
||||
207
ui/src/pages/settings/SettingsPlugins.tsx
Normal file
207
ui/src/pages/settings/SettingsPlugins.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconDownload, IconPuzzle, IconTrash, IconRefresh } from "@tabler/icons-react";
|
||||
import { Alert, Button, Card, Empty, Skeleton, Tag, App } from "antd";
|
||||
|
||||
import { fetchMarketListing, installPlugin, deletePlugin, updatePlugin, type MarketEntry } from "@/api/pluginmarket";
|
||||
import { usePluginCatalogStore } from "@/stores/pluginCatalog";
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
not_installed: "plugin.market.status.not_installed",
|
||||
installed: "plugin.market.status.installed",
|
||||
update_available: "plugin.market.status.update_available",
|
||||
installed_manual: "plugin.market.status.installed_manual",
|
||||
unsupported_platform: "plugin.market.status.unsupported_platform",
|
||||
};
|
||||
|
||||
const SettingsPlugins = () => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const catalogReload = usePluginCatalogStore((s) => s.reload);
|
||||
|
||||
const [entries, setEntries] = useState<MarketEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [operating, setOperating] = useState<Record<string, boolean>>({});
|
||||
|
||||
const loadListing = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchMarketListing();
|
||||
setEntries(data);
|
||||
} catch {
|
||||
setError(t("plugin.market.error.load_failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadListing();
|
||||
}, [loadListing]);
|
||||
|
||||
const setOp = (pt: string, v: boolean) => {
|
||||
setOperating((prev) => ({ ...prev, [pt]: v }));
|
||||
};
|
||||
|
||||
const handleInstall = async (entry: MarketEntry) => {
|
||||
const pt = entry.provider_type;
|
||||
setOp(pt, true);
|
||||
try {
|
||||
await installPlugin(pt);
|
||||
message.success(t("plugin.market.msg.installed"));
|
||||
await catalogReload();
|
||||
await loadListing();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("plugin.market.error.install_failed");
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setOp(pt, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: MarketEntry) => {
|
||||
const pt = entry.provider_type;
|
||||
setOp(pt, true);
|
||||
try {
|
||||
await deletePlugin(pt);
|
||||
message.success(t("plugin.market.msg.deleted"));
|
||||
await catalogReload();
|
||||
await loadListing();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("plugin.market.error.delete_failed");
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setOp(pt, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (entry: MarketEntry) => {
|
||||
const pt = entry.provider_type;
|
||||
setOp(pt, true);
|
||||
try {
|
||||
await updatePlugin(pt);
|
||||
message.success(t("plugin.market.msg.updated"));
|
||||
await catalogReload();
|
||||
await loadListing();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t("plugin.market.error.update_failed");
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setOp(pt, false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderActions = (entry: MarketEntry) => {
|
||||
const pt = entry.provider_type;
|
||||
const busy = operating[pt];
|
||||
|
||||
switch (entry.status) {
|
||||
case "not_installed":
|
||||
return (
|
||||
<Button type="primary" icon={<IconDownload size={16} />} loading={busy} onClick={() => handleInstall(entry)}>
|
||||
{t("plugin.market.action.install")}
|
||||
</Button>
|
||||
);
|
||||
case "installed":
|
||||
return (
|
||||
<Button danger icon={<IconTrash size={16} />} loading={busy} onClick={() => handleDelete(entry)}>
|
||||
{t("plugin.market.action.delete")}
|
||||
</Button>
|
||||
);
|
||||
case "update_available":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag color="orange">{t("plugin.market.label.update_available")}</Tag>
|
||||
<Button type="primary" icon={<IconRefresh size={16} />} loading={busy} onClick={() => handleUpdate(entry)}>
|
||||
{t("plugin.market.action.update")} {entry.version}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
case "installed_manual":
|
||||
return (
|
||||
<Tag>{t("plugin.market.label.manual")}</Tag>
|
||||
);
|
||||
case "unsupported_platform":
|
||||
return (
|
||||
<Tag color="default">{t("plugin.market.label.unsupported_platform")}</Tag>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton active />
|
||||
<Skeleton active />
|
||||
<Skeleton active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
action={
|
||||
<Button size="small" onClick={loadListing}>
|
||||
{t("plugin.market.action.retry")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <Empty description={t("plugin.market.empty")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button icon={<IconRefresh size={16} />} onClick={loadListing} loading={loading}>
|
||||
{t("plugin.market.action.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{entries.map((entry) => (
|
||||
<Card
|
||||
key={entry.provider_type}
|
||||
hoverable
|
||||
className="flex flex-col"
|
||||
cover={
|
||||
entry.icon ? (
|
||||
<div className="flex h-32 items-center justify-center bg-gray-50 dark:bg-gray-800">
|
||||
<img src={entry.icon} alt={entry.provider_type} className="max-h-24 max-w-full object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center bg-gray-50 dark:bg-gray-800">
|
||||
<IconPuzzle size={48} className="text-gray-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Card.Meta
|
||||
title={entry.display_name_key ? t(entry.display_name_key, entry.provider_type) : entry.provider_type}
|
||||
description={entry.description || entry.provider_type}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>v{entry.version}</span>
|
||||
<Tag>{t(statusLabel[entry.status], entry.status)}</Tag>
|
||||
</div>
|
||||
<div className="mt-2">{renderActions(entry)}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPlugins;
|
||||
@@ -14,6 +14,7 @@ import SettingsAbout from "@/pages/settings/SettingsAbout";
|
||||
import SettingsAccount from "@/pages/settings/SettingsAccount";
|
||||
import SettingsAppearance from "@/pages/settings/SettingsAppearance";
|
||||
import SettingsDiagnostics from "@/pages/settings/SettingsDiagnostics";
|
||||
import SettingsPlugins from "@/pages/settings/SettingsPlugins";
|
||||
import SettingsPersistence from "@/pages/settings/SettingsPersistence";
|
||||
import SettingsSSLProvider from "@/pages/settings/SettingsSSLProvider";
|
||||
import WorkflowDetail from "@/pages/workflows/WorkflowDetail";
|
||||
@@ -89,6 +90,10 @@ export const router = createHashRouter([
|
||||
path: "/settings/persistence",
|
||||
element: <SettingsPersistence />,
|
||||
},
|
||||
{
|
||||
path: "/settings/plugins",
|
||||
element: <SettingsPlugins />,
|
||||
},
|
||||
{
|
||||
path: "/settings/diagnostics",
|
||||
element: <SettingsDiagnostics />,
|
||||
|
||||
Reference in New Issue
Block a user