feat: add controlled plugin marketplace lifecycle
Business Plugins CI / check (plugin-admin) (push) Successful in 1m35s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m29s

This commit is contained in:
Qiufeng
2026-08-28 00:51:34 +08:00
parent dc1c799b98
commit 028b505c36
16 changed files with 1149 additions and 45 deletions
+4
View File
@@ -12,3 +12,7 @@ PLUGIN_ALLOW_UNSIGNED=false
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"}
PLUGIN_TRUSTED_PUBLISHERS={}
# The default catalog is a local file. For a remote catalog use an HTTPS URL
# and list its exact host (including port when non-standard) below.
PLUGIN_MARKETPLACE_INDEX=/var/lib/sub2api-add/plugin-admin/marketplace/index.json
PLUGIN_MARKETPLACE_ALLOWED_HOSTS=
+52 -4
View File
@@ -4,7 +4,8 @@ This directory contains the independent administrator-only control plane for
Business Plugins. It is deliberately separate from Sub2API Core and from the
existing `.s2plugin` OpenAI OAuth transport runtime.
The control plane owns the plugin catalog, signed package verification,
The control plane owns the installed-plugin registry, a constrained marketplace
catalog, signed package verification,
revision directories, lifecycle state, encrypted configuration metadata,
menu preview/apply, and its own audit log. Core remains authoritative for
users, administrator roles, balances, subscriptions, billing, and audit
@@ -41,10 +42,13 @@ session and encrypted plugin configuration.
GET /healthz
GET /readyz
POST /login POST /login/2fa POST /logout
GET /api/marketplace POST /api/marketplace/install (JSON: plugin_id, version)
GET /api/me GET /api/plugins GET /api/plugins/{id}
POST /api/plugins/install (multipart field: package)
POST /api/plugins/{id}/install (multipart field: package)
POST /api/plugins/{id}/upgrade (multipart field: package)
POST /api/plugins/{id}/enable|disable|rollback|uninstall
DELETE /api/plugins/{id} (same delete operation as uninstall)
GET|PUT /api/plugins/{id}/config
POST /api/plugins/{id}/menu-preview|menu-apply
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
@@ -52,9 +56,53 @@ GET /api/audit
```
Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A
mutation returns an operation ID even when it completes synchronously. Failed
installation and upgrade never replace the active revision. Uninstall is
allowed only after disable and removes plugin files, not Core data.
mutation returns an operation ID even when it completes synchronously. A local
upload or marketplace download only verifies and stages the package in the
registry (`disabled` / “已入库,待启用”); it never starts a process. The
administrator must configure the service and click **enable**. Only a
successful health and readiness check changes the plugin to `healthy` and
installs its runtime/menu. Failed installation and upgrade never replace the
active revision. Delete/uninstall is allowed only after disable and removes
plugin files, not Core data.
## Marketplace catalog
The marketplace is server-side only. The browser receives metadata and sends a
plugin ID/version; it never receives an archive URL and cannot request an
arbitrary download. Set `PLUGIN_MARKETPLACE_INDEX` to a local JSON file (the
default) or an HTTPS index URL. Remote indexes and archives are restricted to
the exact hosts in `PLUGIN_MARKETPLACE_ALLOWED_HOSTS`; HTTP is accepted only
for loopback sources in `PLUGIN_ENV=development`. Redirects, credentials,
queries, fragments, oversized responses, path escapes, and hash mismatches are
rejected. The package must still pass the normal manifest signature, file hash,
and Core compatibility checks.
Catalog format (schema version 1):
```json
{
"schema_version": 1,
"source": "internal-release-catalog",
"entries": [
{
"plugin_id": "example.plugin",
"name": "Example Plugin",
"version": "1.0.0",
"description": "Administrator extension",
"archive_url": "example.plugin-1.0.0.s2plugin",
"archive_sha256": "SHA256_OF_ARCHIVE",
"archive_size": 12345,
"publisher_key_id": "publisher-key-id",
"core_api_baseline": "sub2api-0.1.183",
"tested_core_versions": ["0.1.183"],
"capabilities": ["example.v1"]
}
]
}
```
An entry is never an implicit upgrade. If the plugin ID is already registered,
use the existing upgrade flow, then enable it explicitly.
## Plugin package
+272 -9
View File
@@ -229,6 +229,7 @@ type operation struct {
RequestHash string `json:"request_hash,omitempty"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Warning string `json:"warning,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -480,12 +481,14 @@ type app struct {
pending map[string]pendingLogin
processes map[string]*exec.Cmd
pluginLocks map[string]*sync.Mutex
marketplaceConfig marketplaceService
mu sync.Mutex
}
func newApp(core *coreClient, r *registry, root string) *app {
key := sha256.Sum256([]byte(token(32)))
return &app{core: core, registry: r, root: root, cookiePath: "/", cookieSameSite: http.SameSiteLaxMode, frameAncestors: []string{"'self'"}, configKey: key[:], sessions: map[string]session{}, sessionLocks: map[string]*sync.Mutex{}, pending: map[string]pendingLogin{}, processes: map[string]*exec.Cmd{}, pluginLocks: map[string]*sync.Mutex{}}
marketplace, _ := newMarketplaceService(filepath.Join(root, "marketplace", "index.json"), "", true)
return &app{core: core, registry: r, root: root, cookiePath: "/", cookieSameSite: http.SameSiteLaxMode, frameAncestors: []string{"'self'"}, configKey: key[:], sessions: map[string]session{}, sessionLocks: map[string]*sync.Mutex{}, pending: map[string]pendingLogin{}, processes: map[string]*exec.Cmd{}, pluginLocks: map[string]*sync.Mutex{}, marketplaceConfig: marketplace}
}
func (a *app) lockPlugin(id string) func() {
@@ -899,6 +902,160 @@ func (a *app) apiPlugins(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *app) marketplace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if _, _, ok := a.authenticate(w, r); !ok {
return
}
index, _, err := a.marketplaceConfig.loadIndex(r.Context())
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "marketplace is unavailable"})
return
}
a.registry.mu.Lock()
installed := make(map[string]pluginRecord, len(a.registry.data.Plugins))
for id, plugin := range a.registry.data.Plugins {
installed[id] = clonePluginRecord(plugin)
}
a.registry.mu.Unlock()
items := make([]map[string]any, 0, len(index.Entries))
coreVersion := a.currentCoreVersion(r.Context())
for _, entry := range index.Entries {
var plugin *pluginRecord
if value, ok := installed[entry.PluginID]; ok {
copy := value
plugin = &copy
}
item := publicMarketplaceEntry(entry, plugin)
compatibility := manifest.Manifest{CoreAPIBaseline: entry.CoreAPIBaseline, TestedCoreVersions: entry.TestedCoreVersions}.EvaluateCompatibility(coreVersion)
item["compatibility"] = compatibility
items = append(items, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"schema_version": index.SchemaVersion,
"source": index.Source,
"issued_at": index.IssuedAt,
"expires_at": index.ExpiresAt,
"items": items,
})
}
func publicMarketplaceEntry(entry marketplaceEntry, installed *pluginRecord) map[string]any {
item := map[string]any{
"plugin_id": entry.PluginID,
"name": entry.Name,
"version": entry.Version,
"description": entry.Description,
"publisher_key_id": entry.PublisherKeyID,
"core_api_baseline": entry.CoreAPIBaseline,
"tested_core_versions": entry.TestedCoreVersions,
"capabilities": entry.Capabilities,
"archive_sha256": entry.ArchiveSHA256,
"archive_size": entry.ArchiveSize,
"release_notes": entry.ReleaseNotes,
"published_at": entry.PublishedAt,
"installed": installed != nil,
"installed_state": "",
"installed_status": "",
"installed_version": "",
"active_revision": "",
}
if installed != nil {
item["installed_state"] = installed.State
item["installed_status"] = installationStatus(*installed)
item["installed_version"] = installed.Manifest.Version
item["active_revision"] = installed.ActiveRevision
}
return item
}
func (a *app) marketplaceInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
raw, err := captureRequestBody(r, 32<<10)
if err != nil {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body exceeds size limit"})
return
}
var input struct {
PluginID string `json:"plugin_id"`
Version string `json:"version"`
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id and version are required"})
return
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF || !marketplaceIDPattern.MatchString(strings.TrimSpace(input.PluginID)) || !marketplaceVersionPattern.MatchString(marketplaceEntryVersion(marketplaceEntry{Version: input.Version})) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "plugin_id and version are invalid"})
return
}
input.PluginID = strings.TrimSpace(input.PluginID)
input.Version = marketplaceEntryVersion(marketplaceEntry{Version: input.Version})
r.Body = io.NopCloser(bytes.NewReader(raw))
op, s, ok := a.mutationAuthWithHash(w, r, "marketplace_install", input.PluginID, operationHashWithBody(r, raw))
if !ok {
return
}
var installed pluginRecord
if index, origin, loadErr := a.marketplaceConfig.loadIndex(r.Context()); loadErr != nil {
err = loadErr
} else {
var entry *marketplaceEntry
for i := range index.Entries {
candidate := &index.Entries[i]
if candidate.PluginID == input.PluginID && marketplaceEntryVersion(*candidate) == input.Version {
entry = candidate
break
}
}
if entry == nil {
err = errors.New("plugin version is not available in the marketplace")
} else {
var archive []byte
archive, err = a.marketplaceConfig.archiveBytes(r.Context(), *entry, origin)
if err == nil {
var info packageInfo
info, err = a.inspectPackage(archive)
if err == nil {
if info.Manifest.PluginID != entry.PluginID || strings.TrimPrefix(info.Manifest.Version, "v") != input.Version {
err = errors.New("marketplace package metadata does not match the catalog")
} else if info.Manifest.Name != entry.Name || !sameCapabilities(info.Manifest.Capabilities, entry.Capabilities) {
err = errors.New("marketplace package metadata does not match the catalog")
} else if !sameCoreVersions(info.Manifest.TestedCoreVersions, entry.TestedCoreVersions) {
err = errors.New("marketplace package Core test metadata does not match the catalog")
} else if info.Manifest.Publisher.KeyID != entry.PublisherKeyID {
err = errors.New("marketplace package publisher does not match the catalog")
} else if strings.TrimPrefix(strings.TrimPrefix(info.Manifest.CoreAPIBaseline, "sub2api-"), "v") != strings.TrimPrefix(strings.TrimPrefix(entry.CoreAPIBaseline, "sub2api-"), "v") {
err = errors.New("marketplace package Core baseline does not match the catalog")
} else if compat := info.Manifest.EvaluateCompatibility(a.currentCoreVersion(r.Context())); !compat.Compatible {
err = errors.New("marketplace package is incompatible with the current Core")
}
}
if err == nil {
installed, err = a.installPackage(info)
}
}
}
}
if err == nil {
op.Revision = installed.ActiveRevision
}
finished, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: "marketplace_install", PluginID: input.PluginID, ActorID: s.User["id"], RequestID: requestID(r)})
if persistErr != nil {
writeOperationPersistenceError(w, finished)
return
}
a.operationResponse(w, finished)
}
func (a *app) operationByID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@@ -929,7 +1086,27 @@ func (a *app) publicPlugin(p pluginRecord) map[string]any {
revisions = append(revisions, map[string]any{"id": rev.ID, "version": rev.Version, "archive_sha256": rev.ArchiveSHA, "verified_at": rev.VerifiedAt, "healthy_at": rev.HealthyAt})
}
compat := p.Manifest.EvaluateCompatibility(a.currentCoreVersion(context.Background()))
return map[string]any{"plugin_id": p.Manifest.PluginID, "name": p.Manifest.Name, "version": p.Manifest.Version, "capabilities": p.Manifest.SortedCapabilities(), "state": p.State, "active_revision": p.ActiveRevision, "pending_revision": p.PendingRevision, "revisions": revisions, "compatibility": compat, "endpoint": p.Endpoint, "last_error": p.LastError, "updated_at": p.UpdatedAt, "menu": p.Manifest.UI.Menu}
return map[string]any{"plugin_id": p.Manifest.PluginID, "name": p.Manifest.Name, "version": p.Manifest.Version, "capabilities": p.Manifest.SortedCapabilities(), "state": p.State, "installation_status": installationStatus(p), "active_revision": p.ActiveRevision, "pending_revision": p.PendingRevision, "revisions": revisions, "compatibility": compat, "endpoint": p.Endpoint, "last_error": p.LastError, "updated_at": p.UpdatedAt, "menu": p.Manifest.UI.Menu}
}
func installationStatus(p pluginRecord) string {
switch p.State {
case "healthy", "enabled":
return "installed"
case "disabled":
for _, revision := range p.Revisions {
if !revision.HealthyAt.IsZero() {
return "stopped"
}
}
return "staged"
case "incompatible":
return "staged"
case "starting", "draining", "upgrading", "rollback_pending":
return "transitioning"
default:
return "failed"
}
}
func (a *app) currentCoreVersion(ctx context.Context) string {
@@ -986,7 +1163,7 @@ func (a *app) getPlugin(w http.ResponseWriter, r *http.Request) {
}
func (a *app) operationResponse(w http.ResponseWriter, op operation) {
writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": op.ID, "state": op.State, "error": op.Error})
writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": op.ID, "state": op.State, "error": op.Error, "warning": op.Warning})
}
func (a *app) finalizeOperation(op operation, operationErr error, event auditEvent) (operation, error) {
@@ -1621,6 +1798,7 @@ func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn
}
old := clonePluginRecord(p)
var err error
var warning string
if !found {
err = errors.New("plugin not found")
} else {
@@ -1669,15 +1847,16 @@ func (a *app) withPlugin(w http.ResponseWriter, r *http.Request, kind string, fn
}
_ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r))
} else if cleanupErr := removeStagedRevisionPaths(moves); cleanupErr != nil {
// The registry commit is already complete; keep the failed cleanup
// visible without restoring a record that may point at partially
// removed files. The private tombstones are safe to clean manually.
err = fmt.Errorf("plugin uninstalled but resource cleanup failed: %w", cleanupErr)
// The registry commit is already complete. Report a warning while
// keeping the deletion completed; a later startup pass removes the
// private tombstones without requiring a second uninstall operation.
warning = sanitizeError(fmt.Errorf("plugin files remain pending cleanup: %w", cleanupErr))
}
} else {
_ = a.restoreOwnMenu(r.Context(), s.AccessToken, old, requestID(r))
}
}
op.Warning = warning
op, persistErr := a.finalizeOperation(op, err, auditEvent{Time: time.Now().UTC(), Action: kind, PluginID: id, ActorID: s.User["id"], RequestID: requestID(r)})
if persistErr != nil {
writeOperationPersistenceError(w, op)
@@ -1845,7 +2024,7 @@ func (a *app) rollback(w http.ResponseWriter, r *http.Request) {
}
func (a *app) uninstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
@@ -2244,6 +2423,11 @@ func (a *app) promoteProcess(from, to string) {
// healthy with no corresponding runtime.
func (a *app) recoverPlugins() error {
a.registry.mu.Lock()
// A prior delete may have committed the registry before the filesystem
// cleanup failed. Remove only tombstones whose original revision is no
// longer referenced; an interrupted delete still needs its tombstone to
// recover the registry record safely.
_ = a.cleanupUninstallTombstonesLocked()
ids := make([]string, 0, len(a.registry.data.Plugins))
for id := range a.registry.data.Plugins {
ids = append(ids, id)
@@ -2318,6 +2502,66 @@ func (a *app) recoverPlugins() error {
return nil
}
func (a *app) cleanupUninstallTombstonesLocked() error {
live := map[string]struct{}{}
for _, plugin := range a.registry.data.Plugins {
for _, revision := range plugin.Revisions {
if revision.Path == "" {
continue
}
if absolute, err := filepath.Abs(revision.Path); err == nil {
live[filepath.Clean(absolute)] = struct{}{}
}
}
}
installedRoot := filepath.Join(a.root, "installed")
pluginDirs, err := os.ReadDir(installedRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var firstErr error
for _, pluginDir := range pluginDirs {
if !pluginDir.IsDir() {
continue
}
entries, readErr := os.ReadDir(filepath.Join(installedRoot, pluginDir.Name()))
if readErr != nil {
if firstErr == nil {
firstErr = readErr
}
continue
}
for _, entry := range entries {
if !entry.IsDir() || !strings.Contains(entry.Name(), ".uninstall-") {
continue
}
originalName := strings.SplitN(entry.Name(), ".uninstall-", 2)[0]
originalPath, pathErr := filepath.Abs(filepath.Join(installedRoot, pluginDir.Name(), originalName))
if pathErr == nil {
if _, referenced := live[filepath.Clean(originalPath)]; referenced {
if _, statErr := os.Lstat(originalPath); errors.Is(statErr, os.ErrNotExist) {
if restoreErr := os.Rename(filepath.Join(installedRoot, pluginDir.Name(), entry.Name()), originalPath); restoreErr != nil && firstErr == nil {
firstErr = restoreErr
}
} else if statErr == nil {
if removeErr := os.RemoveAll(filepath.Join(installedRoot, pluginDir.Name(), entry.Name())); removeErr != nil && firstErr == nil {
firstErr = removeErr
}
}
continue
}
}
if removeErr := os.RemoveAll(filepath.Join(installedRoot, pluginDir.Name(), entry.Name())); removeErr != nil && firstErr == nil {
firstErr = removeErr
}
}
}
return firstErr
}
func (a *app) audit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@@ -2500,6 +2744,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/audit", a.audit)
mux.HandleFunc("/api/marketplace", a.marketplace)
mux.HandleFunc("/api/marketplace/install", a.marketplaceInstall)
mux.HandleFunc("/api/menu-items/preview", func(w http.ResponseWriter, r *http.Request) { a.menuGlobal(w, r, false) })
mux.HandleFunc("/api/menu-items/apply", func(w http.ResponseWriter, r *http.Request) { a.menuGlobal(w, r, true) })
mux.HandleFunc("/api/operations/", a.operationByID)
@@ -2512,7 +2758,11 @@ func (a *app) routes() http.Handler {
return
}
if action == "" {
a.getPlugin(w, r)
if r.Method == http.MethodDelete {
a.uninstall(w, r)
} else {
a.getPlugin(w, r)
}
return
}
switch action {
@@ -2528,6 +2778,8 @@ func (a *app) routes() http.Handler {
a.rollback(w, r)
case "uninstall":
a.uninstall(w, r)
case "delete":
a.uninstall(w, r)
case "config":
a.config(w, r)
case "menu-preview":
@@ -2682,6 +2934,17 @@ func main() {
}
a.frameAncestors = parseFrameAncestors(os.Getenv("PLUGIN_FRAME_ANCESTORS"))
a.trustedPublishers = loadTrustedPublishers(os.Getenv("PLUGIN_TRUSTED_PUBLISHERS"))
marketplaceSource := strings.TrimSpace(os.Getenv("PLUGIN_MARKETPLACE_INDEX"))
if marketplaceSource == "" {
marketplaceSource = filepath.Join(registryDir, "marketplace", "index.json")
}
allowLoopbackMarketplace := environment == "development" && isLoopbackHost(host)
marketplace, marketplaceErr := newMarketplaceService(marketplaceSource, os.Getenv("PLUGIN_MARKETPLACE_ALLOWED_HOSTS"), allowLoopbackMarketplace)
if marketplaceErr != nil {
slog.Error("invalid marketplace configuration", "error", marketplaceErr)
os.Exit(2)
}
a.marketplaceConfig = marketplace
if err := a.recoverPlugins(); err != nil {
slog.Error("recover plugins", "error", err)
os.Exit(2)
+166
View File
@@ -3,6 +3,7 @@ package main
import (
"archive/zip"
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
@@ -14,6 +15,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -254,6 +256,170 @@ func TestPackageInspectionAndAtomicInstall(t *testing.T) {
}
}
func TestMarketplaceInstallStagesPackageWithoutStartingAndDeleteSupportsHTTPDelete(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
root := t.TempDir()
marketplaceDir := filepath.Join(root, "marketplace")
if err := os.MkdirAll(marketplaceDir, 0o700); err != nil {
t.Fatal(err)
}
archive := validPackage(t, "market.example")
archivePath := filepath.Join(marketplaceDir, "market.example-1.0.0.s2plugin")
if err := os.WriteFile(archivePath, archive, 0o600); err != nil {
t.Fatal(err)
}
index := marketplaceIndex{SchemaVersion: 1, Source: "test", Entries: []marketplaceEntry{{
PluginID: "market.example", Name: "Example Plugin", Version: "1.0.0",
Description: "test package", ArchiveURL: filepath.Base(archivePath), ArchiveSHA256: sha256Hex(archive), ArchiveSize: int64(len(archive)),
PublisherKeyID: "dev", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Capabilities: []string{"example.v1"},
}}}
indexRaw, err := json.Marshal(index)
if err != nil {
t.Fatal(err)
}
indexPath := filepath.Join(marketplaceDir, "index.json")
if err := os.WriteFile(indexPath, indexRaw, 0o600); err != nil {
t.Fatal(err)
}
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
case "/api/v1/admin/settings":
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[]}}`)
default:
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
}
}))
defer coreServer.Close()
reg, err := openRegistry(filepath.Join(root, "registry"))
if err != nil {
t.Fatal(err)
}
a := newApp(core, reg, root)
a.allowUnsigned = true
a.marketplaceConfig, err = newMarketplaceService(indexPath, "", true)
if err != nil {
t.Fatal(err)
}
cookie := adminSession(a)
listReq := httptest.NewRequest(http.MethodGet, "/api/marketplace", nil)
listReq.AddCookie(cookie)
listRec := httptest.NewRecorder()
a.routes().ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), "market.example") || strings.Contains(listRec.Body.String(), filepath.Base(archivePath)) {
t.Fatalf("marketplace listing was not metadata-only: %d %s", listRec.Code, listRec.Body.String())
}
req := httptest.NewRequest(http.MethodPost, "/api/marketplace/install", strings.NewReader(`{"plugin_id":"market.example","version":"1.0.0"}`))
req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "CSRF")
req.Header.Set("Idempotency-Key", "market-install-1")
rec := httptest.NewRecorder()
a.marketplaceInstall(rec, req)
if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), `"completed"`) {
t.Fatalf("marketplace install failed: %d %s", rec.Code, rec.Body.String())
}
reg.mu.Lock()
p, ok := reg.data.Plugins["market.example"]
reg.mu.Unlock()
if !ok || p.State != "disabled" || p.ActiveRevision == "" {
t.Fatalf("marketplace package was not staged as disabled: exists=%v record=%#v", ok, p)
}
a.mu.Lock()
processCount := len(a.processes)
a.mu.Unlock()
if processCount != 0 {
t.Fatalf("marketplace install unexpectedly started %d processes", processCount)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/plugins/market.example", nil)
deleteReq.AddCookie(cookie)
deleteReq.Header.Set("X-CSRF-Token", "CSRF")
deleteReq.Header.Set("Idempotency-Key", "market-delete-1")
deleteRec := httptest.NewRecorder()
a.routes().ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusAccepted {
t.Fatalf("DELETE plugin failed: %d %s", deleteRec.Code, deleteRec.Body.String())
}
reg.mu.Lock()
_, exists := reg.data.Plugins["market.example"]
reg.mu.Unlock()
if exists {
t.Fatal("plugin remained in registry after DELETE")
}
}
func TestMarketplaceRejectsExpiredIndexAndArchiveHashMismatch(t *testing.T) {
expired := marketplaceIndex{SchemaVersion: 1, ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)}
if err := validateMarketplaceIndex(expired); err == nil {
t.Fatal("expected expired marketplace index rejection")
}
entry := marketplaceEntry{PluginID: "example.plugin", Version: "1.0.0", ArchiveSHA256: strings.Repeat("0", 64), ArchiveSize: 3}
if _, err := verifyMarketplaceArchive(entry, []byte("bad")); err == nil {
t.Fatal("expected marketplace archive hash mismatch")
}
}
func TestRecoverRestoresInterruptedDeleteTombstone(t *testing.T) {
root := t.TempDir()
reg, err := openRegistry(filepath.Join(root, "registry"))
if err != nil {
t.Fatal(err)
}
original := filepath.Join(root, "installed", "recover.plugin", "rev-1")
if err := os.MkdirAll(filepath.Dir(original), 0o700); err != nil {
t.Fatal(err)
}
tombstone := original + ".uninstall-test"
if err := os.MkdirAll(tombstone, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tombstone, "marker"), []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
reg.data.Plugins["recover.plugin"] = pluginRecord{
Manifest: manifest.Manifest{PluginID: "recover.plugin", Name: "Recover", Version: "1.0.0"},
State: "disabled",
ActiveRevision: "rev-1",
Revisions: []revision{{ID: "rev-1", Path: original}},
}
a := newApp(nil, reg, root)
if err := a.recoverPlugins(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(original, "marker")); err != nil {
t.Fatalf("interrupted uninstall was not restored: %v", err)
}
if _, err := os.Stat(tombstone); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("tombstone remained after restoration: %v", err)
}
}
func TestRemoteMarketplaceRequiresAllowlistAndExpiry(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"schema_version":1,"source":"test","expires_at":"2099-01-01T00:00:00Z","entries":[]}`)
}))
defer server.Close()
if _, err := newMarketplaceService(server.URL, "", true); err == nil {
t.Fatal("expected remote marketplace allowlist requirement")
}
u, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
service, err := newMarketplaceService(server.URL, u.Host, true)
if err != nil {
t.Fatal(err)
}
index, _, err := service.loadIndex(context.Background())
if err != nil || index.SchemaVersion != 1 {
t.Fatalf("remote marketplace index failed: %#v %v", index, err)
}
}
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
t.Setenv("CORE_VERSION", "0.1.183")
reg, _ := openRegistry(t.TempDir())
+535
View File
@@ -0,0 +1,535 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const (
maxMarketplaceIndexBytes = 2 << 20
maxMarketplaceEntries = 256
)
var (
marketplaceIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
marketplaceVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
// marketplaceEntry is deliberately metadata-only. The browser never receives
// the archive URL; downloads are performed by this server after catalog and
// transport policy validation.
type marketplaceEntry struct {
PluginID string `json:"plugin_id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
ArchiveURL string `json:"archive_url"`
ArchiveSHA256 string `json:"archive_sha256"`
ArchiveSize int64 `json:"archive_size,omitempty"`
PublisherKeyID string `json:"publisher_key_id"`
CoreAPIBaseline string `json:"core_api_baseline"`
TestedCoreVersions []string `json:"tested_core_versions,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
ReleaseNotes string `json:"release_notes,omitempty"`
PublishedAt string `json:"published_at,omitempty"`
}
type marketplaceIndex struct {
SchemaVersion int `json:"schema_version"`
Source string `json:"source,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Entries []marketplaceEntry `json:"entries"`
}
type marketplaceService struct {
localPath string
remoteURL *url.URL
allowedHosts map[string]struct{}
allowLoopback bool
client *http.Client
}
type marketplaceOrigin struct {
localPath string
remoteURL *url.URL
}
func (m marketplaceService) httpClient() *http.Client {
if m.client != nil {
return m.client
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(m.allowLoopback)},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func newMarketplaceService(source, allowedHosts string, allowLoopback bool) (marketplaceService, error) {
if strings.TrimSpace(source) == "" {
source = "./marketplace/index.json"
}
service := marketplaceService{
allowedHosts: map[string]struct{}{},
allowLoopback: allowLoopback,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(allowLoopback)},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
parsed, err := url.Parse(strings.TrimSpace(source))
if err == nil && parsed.Scheme != "" {
if parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must be an HTTPS URL without credentials, query or fragment")
}
if parsed.Scheme != "https" && !(allowLoopback && isLoopbackHost(parsed.Hostname())) {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must use HTTPS unless it targets loopback in development")
}
service.remoteURL = parsed
for _, host := range strings.FieldsFunc(allowedHosts, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
host = canonicalAllowedMarketplaceHost(host)
if host != "" {
service.allowedHosts[host] = struct{}{}
}
}
if len(service.allowedHosts) == 0 {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_ALLOWED_HOSTS is required for remote marketplace indexes")
}
if !service.hostAllowed(parsed) {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX host is not allowlisted")
}
return service, nil
}
if strings.Contains(source, "\x00") {
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX contains an invalid path")
}
absolute, err := filepath.Abs(source)
if err != nil {
return marketplaceService{}, fmt.Errorf("resolve marketplace index: %w", err)
}
service.localPath = filepath.Clean(absolute)
return service, nil
}
func (m marketplaceService) hostAllowed(u *url.URL) bool {
if u == nil {
return false
}
_, ok := m.allowedHosts[canonicalMarketplaceHost(u)]
return ok
}
func canonicalMarketplaceHost(u *url.URL) string {
if u == nil {
return ""
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(u.Hostname()), "."))
if host == "" {
return ""
}
port := u.Port()
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
port = ""
}
if port == "" {
return host
}
return net.JoinHostPort(host, port)
}
func canonicalAllowedMarketplaceHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse("//" + raw)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return strings.ToLower(strings.TrimSuffix(raw, "."))
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
port := parsed.Port()
if port == "80" || port == "443" {
port = ""
}
if port == "" {
return host
}
return net.JoinHostPort(host, port)
}
func (m marketplaceService) loadIndex(ctx context.Context) (marketplaceIndex, marketplaceOrigin, error) {
var raw []byte
origin := marketplaceOrigin{localPath: m.localPath, remoteURL: m.remoteURL}
if m.remoteURL != nil {
if !m.hostAllowed(m.remoteURL) {
return marketplaceIndex{}, origin, errors.New("marketplace index host is not allowlisted")
}
if err := m.validateRemoteHost(ctx, m.remoteURL); err != nil {
return marketplaceIndex{}, origin, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.remoteURL.String(), nil)
if err != nil {
return marketplaceIndex{}, origin, err
}
res, err := m.httpClient().Do(req)
if err != nil {
return marketplaceIndex{}, origin, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return marketplaceIndex{}, origin, fmt.Errorf("marketplace index returned HTTP %d", res.StatusCode)
}
if res.ContentLength > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
raw, err = io.ReadAll(io.LimitReader(res.Body, maxMarketplaceIndexBytes+1))
if err != nil {
return marketplaceIndex{}, origin, err
}
if len(raw) > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
} else {
if m.localPath == "" {
return marketplaceIndex{}, origin, errors.New("marketplace index is not configured")
}
file, err := os.Open(m.localPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return marketplaceIndex{SchemaVersion: 1, Entries: []marketplaceEntry{}}, origin, nil
}
return marketplaceIndex{}, origin, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return marketplaceIndex{}, origin, err
}
if info.Size() > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
raw, err = io.ReadAll(io.LimitReader(file, maxMarketplaceIndexBytes+1))
if err != nil {
return marketplaceIndex{}, origin, err
}
if len(raw) > maxMarketplaceIndexBytes {
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
}
}
var index marketplaceIndex
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&index); err != nil {
return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return marketplaceIndex{}, origin, errors.New("marketplace index must contain one JSON value")
}
return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index trailing data: %w", err)
}
if err := validateMarketplaceIndex(index); err != nil {
return marketplaceIndex{}, origin, err
}
if m.remoteURL != nil && strings.TrimSpace(index.ExpiresAt) == "" {
return marketplaceIndex{}, origin, errors.New("remote marketplace index must include expires_at")
}
return index, origin, nil
}
func (m marketplaceService) validateRemoteHost(ctx context.Context, u *url.URL) error {
if u == nil || u.Hostname() == "" {
return errors.New("marketplace URL host is required")
}
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(lookupCtx, "ip", u.Hostname())
if err != nil {
return errors.New("marketplace host DNS lookup failed")
}
if len(ips) == 0 {
return errors.New("marketplace host has no address")
}
for _, ip := range ips {
if isForbiddenMarketplaceIP(ip) && !(m.allowLoopback && ip.IsLoopback()) {
return errors.New("marketplace host resolves to a private address")
}
}
return nil
}
func isForbiddenMarketplaceIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
}
func validateMarketplaceIndex(index marketplaceIndex) error {
if index.SchemaVersion != 1 {
return errors.New("marketplace schema_version must be 1")
}
if len(index.Entries) > maxMarketplaceEntries {
return errors.New("marketplace contains too many entries")
}
if value := strings.TrimSpace(index.IssuedAt); value != "" {
if issued, err := time.Parse(time.RFC3339, value); err != nil || issued.After(time.Now().UTC().Add(5*time.Minute)) {
return errors.New("marketplace issued_at is invalid")
}
}
if value := strings.TrimSpace(index.ExpiresAt); value != "" {
expires, err := time.Parse(time.RFC3339, value)
if err != nil {
return errors.New("marketplace expires_at is invalid")
}
if !expires.After(time.Now().UTC()) {
return errors.New("marketplace index has expired")
}
}
seen := map[string]struct{}{}
for _, entry := range index.Entries {
if !marketplaceIDPattern.MatchString(entry.PluginID) || len(entry.PluginID) > 160 {
return fmt.Errorf("invalid marketplace plugin_id: %s", entry.PluginID)
}
if strings.TrimSpace(entry.Name) == "" || len(entry.Name) > 160 {
return fmt.Errorf("invalid marketplace name for %s", entry.PluginID)
}
version := strings.TrimPrefix(strings.TrimSpace(entry.Version), "v")
if !marketplaceVersionPattern.MatchString(version) {
return fmt.Errorf("invalid marketplace version for %s", entry.PluginID)
}
if strings.TrimSpace(entry.ArchiveURL) == "" || len(entry.ArchiveURL) > 2048 || strings.ContainsAny(entry.ArchiveURL, "\x00\r\n") {
return fmt.Errorf("archive_url is required for %s", entry.PluginID)
}
if len(entry.Description) > 4096 || len(entry.ReleaseNotes) > 16384 {
return fmt.Errorf("marketplace description or release notes are too long for %s", entry.PluginID)
}
if !marketplaceSHA256Pattern.MatchString(strings.ToLower(strings.TrimSpace(entry.ArchiveSHA256))) {
return fmt.Errorf("invalid archive_sha256 for %s", entry.PluginID)
}
if entry.ArchiveSize < 0 || entry.ArchiveSize > maxPackageBytes {
return fmt.Errorf("invalid archive_size for %s", entry.PluginID)
}
if strings.TrimSpace(entry.PublisherKeyID) == "" || len(entry.PublisherKeyID) > 160 {
return fmt.Errorf("publisher_key_id is required for %s", entry.PluginID)
}
baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(entry.CoreAPIBaseline), "sub2api-"), "v")
if !marketplaceVersionPattern.MatchString(baseline) {
return fmt.Errorf("invalid core_api_baseline for %s", entry.PluginID)
}
if len(entry.TestedCoreVersions) > 64 || len(entry.Capabilities) > 64 {
return fmt.Errorf("marketplace metadata contains too many values for %s", entry.PluginID)
}
if len(entry.TestedCoreVersions) == 0 {
return fmt.Errorf("tested_core_versions are required for %s", entry.PluginID)
}
for _, tested := range entry.TestedCoreVersions {
if !marketplaceVersionPattern.MatchString(strings.TrimPrefix(strings.TrimSpace(tested), "v")) {
return fmt.Errorf("invalid tested_core_versions for %s", entry.PluginID)
}
}
for _, capability := range entry.Capabilities {
if !marketplaceIDPattern.MatchString(capability) {
return fmt.Errorf("invalid capability for %s", entry.PluginID)
}
}
if len(entry.Capabilities) == 0 {
return fmt.Errorf("capabilities are required for %s", entry.PluginID)
}
key := entry.PluginID + "@" + version
if _, exists := seen[key]; exists {
return fmt.Errorf("duplicate marketplace entry: %s", key)
}
seen[key] = struct{}{}
}
return nil
}
func marketplaceDialContext(allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, errors.New("marketplace host DNS lookup failed")
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
var lastErr error
for _, ip := range ips {
if isForbiddenMarketplaceIP(ip) && !(allowLoopback && ip.IsLoopback()) {
lastErr = errors.New("marketplace host resolves to a private address")
continue
}
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("marketplace host has no address")
}
}
func sameCapabilities(left, right []string) bool {
left = append([]string(nil), left...)
right = append([]string(nil), right...)
sort.Strings(left)
sort.Strings(right)
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func sameCoreVersions(left, right []string) bool {
normalize := func(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(value), "sub2api-"), "v"))
}
sort.Strings(out)
return out
}
return sameCapabilities(normalize(left), normalize(right))
}
func marketplaceEntryVersion(entry marketplaceEntry) string {
return strings.TrimPrefix(strings.TrimSpace(entry.Version), "v")
}
func (m marketplaceService) archiveBytes(ctx context.Context, entry marketplaceEntry, origin marketplaceOrigin) ([]byte, error) {
if origin.remoteURL != nil {
relative, err := url.Parse(strings.TrimSpace(entry.ArchiveURL))
if err != nil || relative.IsAbs() || relative.Host != "" || relative.User != nil || relative.RawQuery != "" || relative.Fragment != "" || relative.Path == "" || strings.HasPrefix(relative.Path, "/") || strings.Contains(relative.Path, "..") {
return nil, errors.New("marketplace archive URL must be a relative path without traversal")
}
archiveURL := origin.remoteURL.ResolveReference(relative)
if archiveURL.Scheme != "https" && !(m.allowLoopback && archiveURL.Scheme == "http" && isLoopbackHost(archiveURL.Hostname())) {
return nil, errors.New("marketplace archive URL must use HTTPS unless it targets loopback in development")
}
if !m.hostAllowed(archiveURL) {
return nil, errors.New("marketplace archive host is not allowlisted")
}
if err := m.validateRemoteHost(ctx, archiveURL); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL.String(), nil)
if err != nil {
return nil, err
}
res, err := m.httpClient().Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("marketplace archive returned HTTP %d", res.StatusCode)
}
if res.ContentLength > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
data, err := io.ReadAll(io.LimitReader(res.Body, maxPackageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
return verifyMarketplaceArchive(entry, data)
}
archivePath, err := localMarketplaceArchivePath(origin.localPath, entry.ArchiveURL)
if err != nil {
return nil, err
}
file, err := os.Open(archivePath)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
data, err := io.ReadAll(io.LimitReader(file, maxPackageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxPackageBytes {
return nil, errors.New("marketplace archive exceeds package size limit")
}
return verifyMarketplaceArchive(entry, data)
}
func localMarketplaceArchivePath(indexPath, raw string) (string, error) {
if indexPath == "" {
return "", errors.New("local marketplace index is not configured")
}
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("local marketplace archive URL must be a relative path")
}
base := filepath.Dir(indexPath)
candidate := filepath.Clean(filepath.Join(base, filepath.FromSlash(parsed.Path)))
rel, err := filepath.Rel(base, candidate)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errors.New("local marketplace archive path escapes the index directory")
}
baseResolved, err := filepath.EvalSymlinks(base)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return "", err
}
rel, err = filepath.Rel(baseResolved, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errors.New("local marketplace archive symlink escapes the index directory")
}
return resolved, nil
}
func verifyMarketplaceArchive(entry marketplaceEntry, archive []byte) ([]byte, error) {
if entry.ArchiveSize > 0 && int64(len(archive)) != entry.ArchiveSize {
return nil, errors.New("marketplace archive size mismatch")
}
sum := sha256.Sum256(archive)
hash := hex.EncodeToString(sum[:])
if !strings.EqualFold(hash, strings.TrimSpace(entry.ArchiveSHA256)) {
return nil, errors.New("marketplace archive hash mismatch")
}
return archive, nil
}
@@ -0,0 +1,5 @@
{
"schema_version": 1,
"source": "replace-with-your-controlled-catalog",
"entries": []
}
+36 -6
View File
@@ -5,6 +5,7 @@
let csrf = ''
let pendingToken = ''
let configPluginId = ''
let installedPlugins = new Map()
const notice = (message, error = false) => {
const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice'
}
@@ -41,25 +42,54 @@
const logout = async () => { try { await api('/logout', { method: 'POST' }) } catch (_) {} csrf = ''; setLoggedIn(null); $('#login-form').hidden = false; $('#twofa-form').hidden = true }
const badge = (state) => `<span class="badge badge-${String(state || '').replace(/[^a-z_]/g, '')}">${escapeHtml(state || 'unknown')}</span>`
const escapeHtml = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]))
const marketplaceInstalled = (item) => {
const installed = installedPlugins.get(String(item.plugin_id || ''))
return !!(item.installed || installed)
}
const marketplaceStatus = (item) => marketplaceInstalled(item)
? `<span class="market-status">已入库 · ${escapeHtml(item.installed_status === 'installed' ? '运行中' : item.installed_status === 'stopped' ? '已停用' : item.installed_state === 'error' ? '启用失败' : item.installed_state === 'incompatible' ? 'Core 不兼容' : '待启用')}</span>`
: '<span class="market-status market-status-available">可安装</span>'
const loadMarketplace = async () => {
const data = await api('/api/marketplace'); const root = $('#marketplace'); root.textContent = ''
const items = Array.isArray(data.items) ? data.items : []
if (!items.length) { root.innerHTML = '<div class="empty">暂无可用插件</div>'; return }
for (const item of items) {
const pluginId = String(item.plugin_id || ''); const version = String(item.version || '')
const installed = marketplaceInstalled(item); const sameVersion = installed && String(item.installed_version || '') === version; const card = document.createElement('article'); card.className = 'market-card'
const marketButtonLabel = installed ? (sameVersion ? '已入库' : '请在已入库卡片中升级') : '下载并入库'
card.innerHTML = `<div class="market-title"><div><h3>${escapeHtml(item.name || pluginId)}</h3><p class="muted mono">${escapeHtml(pluginId)} · v${escapeHtml(version)}</p></div>${marketplaceStatus(item)}</div><p class="market-description">${escapeHtml(item.description || '由受控插件目录提供的 Business Plugin')}</p><dl class="market-meta"><div><dt>发布者</dt><dd>${escapeHtml(item.publisher || item.publisher_key_id || '-')}</dd></div><div><dt>兼容性</dt><dd>${escapeHtml(item.compatibility && item.compatibility.status || item.compatibility_status || 'unknown')}</dd></div><div><dt>包 SHA-256</dt><dd class="mono">${escapeHtml(item.archive_sha256 || item.sha256 || '-')}</dd></div></dl><div class="market-actions"><button data-market-action="install" data-id="${escapeHtml(pluginId)}" data-version="${escapeHtml(version)}" class="button" ${installed ? 'disabled' : ''}>${marketButtonLabel}</button></div>`
root.appendChild(card)
}
}
const loadPlugins = async () => {
const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = ''
installedPlugins = new Map((data.items || []).map((plugin) => [String(plugin.plugin_id || ''), plugin]))
if (!data.items || !data.items.length) { root.innerHTML = '<div class="empty">还没有登记业务插件</div>'; return }
for (const plugin of data.items) {
const card = document.createElement('article'); card.className = 'plugin-card'
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div>${badge(plugin.state)}</div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>卸载</button></div>`
const staged = plugin.installation_status === 'staged' || plugin.state === 'disabled' || plugin.state === 'incompatible'
const installedLabel = plugin.state === 'healthy' || plugin.state === 'enabled' ? '运行中' : plugin.installation_status === 'stopped' ? '已停用' : plugin.state === 'incompatible' ? 'Core 不兼容' : plugin.state === 'error' ? '启用失败' : staged ? '已入库 · 待启用' : ''
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div><div class="state-stack">${badge(plugin.state)}${installedLabel ? `<span class="pending-label">${installedLabel}</span>` : ''}</div></div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>删除</button></div>`
root.appendChild(card)
}
}
const loadAudit = async () => { const data = await api('/api/audit'); const root = $('#audit'); root.textContent = ''; for (const item of (data.items || []).slice().reverse()) { const row = document.createElement('div'); row.className = 'audit-row'; row.innerHTML = `<span>${escapeHtml(item.action)}</span><span class="mono">${escapeHtml(item.plugin_id || '-')}</span><span>${escapeHtml(item.result)}</span><time>${escapeHtml(item.time)}</time>`; root.appendChild(row) } }
const loadAll = async () => { try { await Promise.all([loadPlugins(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } }
const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
const loadAll = async () => { try { await loadPlugins(); await Promise.all([loadMarketplace(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } }
const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}${result.warning ? `;${result.warning}` : ''}`); await loadAll() } catch (error) { notice(error.message, true) } }
const openConfig = async (id) => { configPluginId = id; $('#config-plugin').textContent = id; $('#config-error').textContent = ''; const form = $('#config-form'); form.elements.service_url.value = ''; form.elements.public_url.value = ''; try { const data = await api(`/api/plugins/${encodeURIComponent(id)}/config`); form.elements.service_url.value = data.endpoint || ''; if (data.config && typeof data.config.public_url === 'string') form.elements.public_url.value = data.config.public_url; $('#config-dialog').showModal() } catch (error) { notice(error.message, true) } }
const saveConfig = async (event) => { event.preventDefault(); const form = event.currentTarget; const body = { service_url: form.elements.service_url.value.trim(), public_url: form.elements.public_url.value.trim() }; try { const result = await api(`/api/plugins/${encodeURIComponent(configPluginId)}/config`, { method: 'PUT', headers: { 'Idempotency-Key': `config-${configPluginId}-${Date.now()}` }, body: JSON.stringify(body) }); $('#config-dialog').close(); notice(`配置已保存:${result.operation_id || result.state}`); await loadAll() } catch (error) { $('#config-error').textContent = error.message } }
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`安装已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`插件已入库,待手动启用:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
const uploadUpgrade = async (id, file) => { const form = new FormData(); form.append('package', file); try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': `upgrade-${id}-${Date.now()}` }, body: form }); notice(`升级已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
const installFromMarketplace = async (button) => {
const pluginId = button.dataset.id; const version = button.dataset.version
if (!pluginId || !version || button.disabled) return
button.disabled = true
try { const result = await api('/api/marketplace/install', { method: 'POST', headers: { 'Idempotency-Key': `marketplace-install-${pluginId}-${version}-${Date.now()}` }, body: JSON.stringify({ plugin_id: pluginId, version }) }); notice(`插件已入库,待手动启用:${result.operation_id || result.state}`); await loadAll() } catch (error) { button.disabled = false; notice(error.message, true) }
}
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#marketplace-refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
$('#package-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) upload(file); event.target.value = '' })
$('#plugins').addEventListener('change', (event) => { const input = event.target.closest('[data-action="upgrade-file"]'); if (!input) return; const file = input.files[0]; if (file) uploadUpgrade(input.dataset.id, file); input.value = '' })
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } mutate(action, id) })
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } if (action === 'uninstall' && !window.confirm('删除后将移除插件文件,Core 数据不会删除。继续吗?')) return; mutate(action, id) })
$('#marketplace').addEventListener('click', (event) => { const button = event.target.closest('[data-market-action="install"]'); if (button) installFromMarketplace(button) })
api('/api/me').then((data) => { csrf = data.csrf_token; setLoggedIn(data.user); loadAll() }).catch(() => setLoggedIn(null))
})()
+21 -3
View File
@@ -39,15 +39,33 @@
<div class="toolbar">
<div>
<h2>已登记插件</h2>
<p class="muted">安装包会先验签、校验哈希和 Core 兼容性,再进入停用状态。</p>
<p class="muted">上传后只完成验签、哈希和 Core 兼容性校验并入库;点击启用后才会启动插件。</p>
</div>
<div class="toolbar-actions">
<label class="file-button">安装插件<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
<label class="file-button">上传并入库<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
<button id="refresh" class="button button-secondary" type="button">刷新</button>
</div>
</div>
<p id="notice" class="notice" role="status"></p>
<div id="plugins" class="plugin-list"></div>
<section class="marketplace-section" aria-labelledby="marketplace-heading">
<div class="section-heading">
<div>
<h2 id="marketplace-heading">插件市场</h2>
<p class="muted">从受控目录查看可安装版本。安装后仅入库,需在下方手动启用。</p>
</div>
<button id="marketplace-refresh" class="button button-secondary" type="button">刷新市场</button>
</div>
<div id="marketplace" class="marketplace-list" aria-live="polite"></div>
</section>
<section aria-labelledby="installed-heading">
<div class="section-heading installed-heading">
<div>
<h2 id="installed-heading">已入库插件</h2>
<p class="muted">启用、停用、升级、回滚和卸载均需在插件卡片中手动操作。</p>
</div>
</div>
<div id="plugins" class="plugin-list"></div>
</section>
<section class="panel audit-panel">
<div class="section-heading"><h2>操作审计</h2><button id="audit-refresh" class="button button-quiet" type="button">刷新</button></div>
<div id="audit" class="audit-list"></div>
+15 -2
View File
@@ -17,11 +17,24 @@ input { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #c7d0da; b
.file-button input { display: none; }
#app-panel { margin-top: 32px; } .toolbar { margin-bottom: 18px; } .toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.notice { min-height: 20px; margin: 8px 0 14px; font-size: 13px; color: #17603a; } .error { color: #b42318; }
.marketplace-section { margin: 8px 0 24px; padding: 18px 0 22px; border-bottom: 1px solid #d9dee5; }
.marketplace-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; }
.market-card { min-width: 0; padding: 16px; background: #fff; border: 1px solid #d9dee5; border-radius: 6px; }
.market-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.market-title h3 { overflow-wrap: anywhere; }
.market-description { min-height: 36px; margin: 12px 0; color: #475569; font-size: 13px; line-height: 1.5; }
.market-meta { display: grid; gap: 8px; margin: 0 0 14px; }
.market-meta div { min-width: 0; }
.market-meta dd { overflow-wrap: anywhere; }
.market-status { display: inline-flex; flex: 0 0 auto; padding: 4px 8px; border-radius: 999px; color: #146c43; background: #d9f6e5; font-size: 12px; white-space: nowrap; }
.market-status-available { color: #1e40af; background: #e5edff; }
.market-actions { display: flex; justify-content: flex-end; }
.market-actions .button { width: 100%; }
.plugin-list { display: grid; gap: 12px; }
.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.plugin-card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 18px; } .plugin-title { align-items: flex-start; } .state-stack { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; } .pending-label { color: #5a6877; font-size: 11px; white-space: nowrap; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.badge { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #334155; background: #e8edf2; font-size: 12px; } .badge-healthy { background: #d9f6e5; color: #146c43; } .badge-error, .badge-incompatible { background: #fde1df; color: #9b1c16; } .badge-starting, .badge-draining { background: #fff0c2; color: #7a4d00; }
.meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0 12px; } .meta div { min-width: 0; } dt { color: #687585; font-size: 12px; margin-bottom: 4px; } dd { margin: 0; overflow-wrap: anywhere; font-size: 13px; } .plugin-error { min-height: 18px; margin-bottom: 12px; font-size: 12px; color: #b42318; }
.card-actions { justify-content: flex-start; flex-wrap: wrap; } .empty { padding: 32px; text-align: center; color: #687585; border: 1px dashed #c7d0da; border-radius: 6px; background: #fff; }
.audit-panel { margin-top: 24px; padding: 18px; } .audit-list { display: grid; gap: 1px; } .audit-row { display: grid; grid-template-columns: 1.2fr 1.3fr .8fr 1.7fr; gap: 10px; padding: 9px 0; border-top: 1px solid #edf0f3; font-size: 12px; overflow-wrap: anywhere; }
.config-dialog { width: min(460px, calc(100% - 24px)); padding: 0; border: 0; border-radius: 6px; box-shadow: 0 18px 60px rgb(15 23 42 / 24%); } .config-dialog::backdrop { background: rgb(15 23 42 / 42%); } .config-form { display: grid; gap: 14px; padding: 22px; } .config-form .section-heading { margin-bottom: 4px; } .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .meta { grid-template-columns: 1fr; gap: 9px; } .card-actions .button, .toolbar-actions .button, .file-button { flex: 1 1 130px; } .audit-row { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .shell { width: min(100% - 20px, 560px); padding-top: 20px; } .topbar, .toolbar, .marketplace-section > .section-heading { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .meta { grid-template-columns: 1fr; gap: 9px; } .card-actions .button, .toolbar-actions .button, .file-button { flex: 1 1 130px; } .audit-row { grid-template-columns: 1fr 1fr; } .marketplace-section .button { width: 100%; } }