feat: plugin marketplace

This commit is contained in:
Yoan.liu
2026-07-27 09:53:50 +08:00
parent 5ec4a49cb3
commit 09f452a7e9
21 changed files with 1298 additions and 3 deletions

View File

@@ -5,7 +5,10 @@ import (
"sync"
)
const SourcePlugin = "plugin"
const (
SourcePlugin = "plugin"
SourceMarketplace = "marketplace"
)
type CatalogEntry struct {
Source string `json:"source"`

View 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
}

View File

@@ -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
}

View 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)
}

View File

@@ -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())
}

View File

@@ -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)

View 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
}

View 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
View 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)
}

View 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;
};

View File

@@ -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;

View File

@@ -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,

View 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"
}
}
}

View File

@@ -96,6 +96,10 @@
}
},
"plugins": {
"tab": "Plugins"
},
"sslprovider": {
"tab": "Certificate authority",
"ca": {

View File

@@ -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,

View 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": "不支持"
}
}
}

View File

@@ -96,6 +96,10 @@
}
},
"plugins": {
"tab": "插件"
},
"sslprovider": {
"tab": "证书颁发机构",
"ca": {

View File

@@ -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"

View File

@@ -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" />],

View 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;

View File

@@ -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 />,