feat(cabana): plugin updates and marketplace search on the Plugins screen
- GET /plugins looks up each versioned plugin's releases through kiosk.Client and applies plugin:update's rule (NewestCompatible above the BaseVersion), with the update command and the framework a newer release needs - GET /plugins/marketplace forwards q and category (validated like the registry), marks installed and incompatible plugins and returns the install command; ids outside the plugin ID shape are dropped - 4 s per-call timeout, at most 4 lookups at once, in-process cache (searches 5 min, releases 15 min, failures 1 min); a failing registry gives status unavailable, an empty plugins.registry_url sends nothing - SPA: update badges and Marketplace tab with copy buttons (clipboard fallback), hidden when the registry is disabled; no install button - OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
@@ -79,6 +89,270 @@ type InstalledPlugin struct {
|
||||
UpdateCommand string `json:"updateCommand"`
|
||||
}
|
||||
|
||||
// MarketplacePlugin is one plugin of the registry's search on the Plugins
|
||||
// screen: the registry's listing (name, description, category, featured
|
||||
// flag, https icon URL, newest release and its framework minimum), whether
|
||||
// the application already has it (Installed), whether the built framework
|
||||
// satisfies the newest release's minimum (Compatible) and the summer
|
||||
// command that installs it.
|
||||
type MarketplacePlugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Featured bool `json:"featured"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
LatestFramework string `json:"latestFramework"`
|
||||
Installed bool `json:"installed"`
|
||||
Compatible bool `json:"compatible"`
|
||||
InstallCommand string `json:"installCommand"`
|
||||
}
|
||||
|
||||
// MarketplaceResponse is the GET /plugins/marketplace answer.
|
||||
type MarketplaceResponse struct {
|
||||
Data []MarketplacePlugin `json:"data"`
|
||||
Meta MarketplaceMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// MarketplaceMeta carries the registry state, the number of plugins in
|
||||
// data and the registry's categories (sorted) for the category filter.
|
||||
type MarketplaceMeta struct {
|
||||
Registry RegistryState `json:"registry"`
|
||||
Total int `json:"total"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// Limits of the Plugins screen's registry calls (T-34-31): a short
|
||||
// per-call timeout, an in-process cache that also keeps failures for a
|
||||
// while, and few concurrent version lookups.
|
||||
const (
|
||||
registryCallTimeout = 4 * time.Second
|
||||
marketplaceTTL = 5 * time.Minute
|
||||
versionsTTL = 15 * time.Minute
|
||||
registryFailureTTL = time.Minute
|
||||
registryLookups = 4
|
||||
marketplaceMaxQuery = 100
|
||||
)
|
||||
|
||||
var (
|
||||
// marketplaceCategoryRe is the registry's category filter rule.
|
||||
marketplaceCategoryRe = regexp.MustCompile(`^[a-z0-9-]{1,40}$`)
|
||||
// marketplaceIDRe is the plugin ID shape; IDs end up in a command.
|
||||
marketplaceIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
|
||||
)
|
||||
|
||||
// pluginRegistry is the part of kiosk.Client the Plugins screen uses.
|
||||
type pluginRegistry interface {
|
||||
List(ctx context.Context, q, category string) (kiosk.ListResponse, error)
|
||||
Versions(ctx context.Context, id string) ([]kiosk.RegistryVersion, error)
|
||||
}
|
||||
|
||||
// pluginCatalog is the Plugins screen's view of the registry: the client
|
||||
// built from plugins.registry_url (nil when it is empty or unusable) and
|
||||
// a TTL cache of searches and per-plugin versions.
|
||||
type pluginCatalog struct {
|
||||
disabled bool
|
||||
registry pluginRegistry
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
lists map[string]cachedList
|
||||
versions map[string]cachedVersions
|
||||
}
|
||||
|
||||
type cachedList struct {
|
||||
resp kiosk.ListResponse
|
||||
err error
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type cachedVersions struct {
|
||||
versions []kiosk.RegistryVersion
|
||||
listed bool
|
||||
err error
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// newPluginCatalog builds the catalog for cfg. An empty registry URL
|
||||
// disables it; a URL kiosk refuses leaves it enabled with no client, so
|
||||
// every lookup reports the registry unavailable.
|
||||
func newPluginCatalog(cfg *compass.Config) *pluginCatalog {
|
||||
c := &pluginCatalog{
|
||||
timeout: registryCallTimeout,
|
||||
now: time.Now,
|
||||
lists: map[string]cachedList{},
|
||||
versions: map[string]cachedVersions{},
|
||||
}
|
||||
base := kiosk.RegistryURL(cfg)
|
||||
if base == "" {
|
||||
c.disabled = true
|
||||
return c
|
||||
}
|
||||
if client, err := kiosk.NewClient(base, cfg); err == nil {
|
||||
c.registry = client
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// pluginCatalog returns the catalog Activate built. A service assembled
|
||||
// without Activate gets a fresh, uncached one per call.
|
||||
func (s *service) pluginCatalog() *pluginCatalog {
|
||||
if s.catalog != nil {
|
||||
return s.catalog
|
||||
}
|
||||
return newPluginCatalog(s.config())
|
||||
}
|
||||
|
||||
func (c *pluginCatalog) enabled() bool { return !c.disabled }
|
||||
|
||||
// state is the registry state before any call: disabled or ok.
|
||||
func (c *pluginCatalog) state() RegistryState {
|
||||
if c.disabled {
|
||||
return RegistryState{Enabled: false, Status: registryStatusDisabled}
|
||||
}
|
||||
return RegistryState{Enabled: true, Status: registryStatusOK}
|
||||
}
|
||||
|
||||
var errRegistryUnusable = errors.New("cabana: plugins.registry_url is not a usable registry URL")
|
||||
|
||||
// callContext detaches a registry call from the request's cancellation,
|
||||
// so a closed browser tab does not cache a failure, and bounds it with
|
||||
// the catalog's timeout.
|
||||
func (c *pluginCatalog) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), c.timeout)
|
||||
}
|
||||
|
||||
// list returns the registry's search for q and category, cached.
|
||||
func (c *pluginCatalog) list(ctx context.Context, q, category string) (kiosk.ListResponse, error) {
|
||||
key := q + "\x00" + category
|
||||
c.mu.Lock()
|
||||
if hit, ok := c.lists[key]; ok && c.now().Before(hit.expires) {
|
||||
c.mu.Unlock()
|
||||
return hit.resp, hit.err
|
||||
}
|
||||
c.mu.Unlock()
|
||||
var (
|
||||
resp kiosk.ListResponse
|
||||
err = errRegistryUnusable
|
||||
)
|
||||
if c.registry != nil {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
resp, err = c.registry.List(callCtx, q, category)
|
||||
cancel()
|
||||
}
|
||||
ttl := marketplaceTTL
|
||||
if err != nil {
|
||||
resp, ttl = kiosk.ListResponse{}, registryFailureTTL
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.lists[key] = cachedList{resp: resp, err: err, expires: c.now().Add(ttl)}
|
||||
c.mu.Unlock()
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// releases returns the releases of plugin id, cached. listed is false when
|
||||
// the registry does not know the ID (a 404 is an answer, not a failure).
|
||||
func (c *pluginCatalog) releases(ctx context.Context, id string) ([]kiosk.RegistryVersion, bool, error) {
|
||||
c.mu.Lock()
|
||||
if hit, ok := c.versions[id]; ok && c.now().Before(hit.expires) {
|
||||
c.mu.Unlock()
|
||||
return hit.versions, hit.listed, hit.err
|
||||
}
|
||||
c.mu.Unlock()
|
||||
var (
|
||||
versions []kiosk.RegistryVersion
|
||||
listed bool
|
||||
err = errRegistryUnusable
|
||||
)
|
||||
if c.registry != nil {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
versions, err = c.registry.Versions(callCtx, id)
|
||||
cancel()
|
||||
listed = err == nil
|
||||
if errors.Is(err, kiosk.ErrNotFound) {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
ttl := versionsTTL
|
||||
if err != nil {
|
||||
versions, ttl = nil, registryFailureTTL
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.versions[id] = cachedVersions{versions: versions, listed: listed, err: err, expires: c.now().Add(ttl)}
|
||||
c.mu.Unlock()
|
||||
return versions, listed, err
|
||||
}
|
||||
|
||||
// applyUpdates fills the registry fields of every plugin that has a
|
||||
// version, at most registryLookups lookups at a time, and reports false
|
||||
// when any lookup failed.
|
||||
func (c *pluginCatalog) applyUpdates(ctx context.Context, overview *PluginsOverview) bool {
|
||||
framework := frameworkRelease(overview.Framework)
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
failed bool
|
||||
)
|
||||
slots := make(chan struct{}, registryLookups)
|
||||
for i := range overview.Plugins {
|
||||
plugin := &overview.Plugins[i]
|
||||
if plugin.Version == "" || plugin.Source == party.SourceLocal || !marketplaceIDRe.MatchString(plugin.ID) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
slots <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-slots }()
|
||||
versions, listed, err := c.releases(ctx, plugin.ID)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
failed = true
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
if listed {
|
||||
applyUpdate(plugin, versions, framework)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return !failed
|
||||
}
|
||||
|
||||
// applyUpdate sets the registry fields of a listed plugin: Latest is the
|
||||
// newest release framework allows, an update is available when Latest is
|
||||
// newer than the installed version's base tag, and LatestFramework names
|
||||
// the minimum of a newer release that framework does not satisfy. The rule
|
||||
// is summer plugin:update's.
|
||||
func applyUpdate(plugin *InstalledPlugin, versions []kiosk.RegistryVersion, framework string) {
|
||||
plugin.Listed = true
|
||||
latest, compatible := kiosk.NewestCompatible(versions, framework)
|
||||
if compatible {
|
||||
plugin.Latest = latest.Version
|
||||
}
|
||||
base := kiosk.BaseVersion(plugin.Version)
|
||||
if compatible && base != "" && kiosk.CompareVersions(latest.Version, base) > 0 {
|
||||
plugin.UpdateAvailable = true
|
||||
plugin.UpdateCommand = "summer plugin:update " + plugin.ID
|
||||
}
|
||||
var newest kiosk.RegistryVersion
|
||||
for _, v := range versions {
|
||||
if kiosk.ValidVersion(v.Version) && (newest.Version == "" || kiosk.CompareVersions(v.Version, newest.Version) > 0) {
|
||||
newest = v
|
||||
}
|
||||
}
|
||||
if newest.Version == "" || (compatible && kiosk.CompareVersions(newest.Version, latest.Version) <= 0) {
|
||||
return
|
||||
}
|
||||
if base != "" && kiosk.CompareVersions(newest.Version, base) <= 0 {
|
||||
return
|
||||
}
|
||||
plugin.LatestFramework = newest.Framework
|
||||
}
|
||||
|
||||
// pluginsSettingsEntry is the Settings → System link to the Plugins
|
||||
// screen. It opens an SPA route, not an admin controller.
|
||||
func pluginsSettingsEntry(label, description, category string) SettingsEntry {
|
||||
@@ -104,22 +378,116 @@ func pluginsPrincipal(w http.ResponseWriter, r *http.Request) (*bouncer.Principa
|
||||
return principal, true
|
||||
}
|
||||
|
||||
// pluginsInstalled answers GET /plugins.
|
||||
// pluginsInstalled answers GET /plugins. With the registry configured,
|
||||
// each plugin that has a version is looked up (cached, at most
|
||||
// registryLookups at a time); a registry that fails leaves the list intact
|
||||
// and reports the registry unavailable.
|
||||
func (s *service) pluginsInstalled(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := pluginsPrincipal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
overview := s.installedPlugins()
|
||||
overview.Registry = s.registryState()
|
||||
catalog := s.pluginCatalog()
|
||||
overview.Registry = catalog.state()
|
||||
if catalog.enabled() && !catalog.applyUpdates(r.Context(), &overview) {
|
||||
overview.Registry.Status = registryStatusUnavailable
|
||||
}
|
||||
WriteData(w, http.StatusOK, overview, nil)
|
||||
}
|
||||
|
||||
// registryState reports whether plugins.registry_url is set.
|
||||
func (s *service) registryState() RegistryState {
|
||||
if kiosk.RegistryURL(s.config()) == "" {
|
||||
return RegistryState{Enabled: false, Status: registryStatusDisabled}
|
||||
// pluginsMarketplace answers GET /plugins/marketplace?q=&category=: the
|
||||
// registry's search, with the plugins the application already has and the
|
||||
// ones the built framework is too old for marked, and the command that
|
||||
// installs each. q and category follow the registry's own rules (400
|
||||
// otherwise). A disabled or failing registry answers an empty list.
|
||||
func (s *service) pluginsMarketplace(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := pluginsPrincipal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
return RegistryState{Enabled: true, Status: registryStatusOK}
|
||||
query := r.URL.Query()
|
||||
q := strings.TrimSpace(query.Get("q"))
|
||||
category := query.Get("category")
|
||||
if utf8.RuneCountInString(q) > marketplaceMaxQuery {
|
||||
WriteErrorDetails(w, http.StatusBadRequest, "invalid_query", "Invalid query", map[string]any{"q": []string{"The search may have at most 100 characters."}})
|
||||
return
|
||||
}
|
||||
if category != "" && !marketplaceCategoryRe.MatchString(category) {
|
||||
WriteErrorDetails(w, http.StatusBadRequest, "invalid_query", "Invalid query", map[string]any{"category": []string{"The category must match ^[a-z0-9-]{1,40}$."}})
|
||||
return
|
||||
}
|
||||
catalog := s.pluginCatalog()
|
||||
out := MarketplaceResponse{Data: []MarketplacePlugin{}, Meta: MarketplaceMeta{Registry: catalog.state(), Categories: []string{}}}
|
||||
if !catalog.enabled() {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
list, err := catalog.list(r.Context(), q, category)
|
||||
if err != nil {
|
||||
out.Meta.Registry.Status = registryStatusUnavailable
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
overview := s.installedPlugins()
|
||||
installed := make(map[string]bool, len(overview.Plugins))
|
||||
for _, p := range overview.Plugins {
|
||||
installed[p.ID] = true
|
||||
}
|
||||
framework := frameworkRelease(overview.Framework)
|
||||
for _, p := range list.Data {
|
||||
if !marketplaceIDRe.MatchString(p.ID) {
|
||||
// The ID ends up in a shell command; never offer an odd one.
|
||||
continue
|
||||
}
|
||||
out.Data = append(out.Data, MarketplacePlugin{
|
||||
ID: p.ID, Name: p.Name, Description: p.Description, Category: p.Category,
|
||||
Featured: p.Featured, IconURL: httpsURL(p.IconURL),
|
||||
LatestVersion: p.LatestVersion, LatestFramework: p.LatestFramework,
|
||||
Installed: installed[p.ID],
|
||||
Compatible: kiosk.Satisfies(framework, p.LatestFramework),
|
||||
InstallCommand: "summer plugin:install " + p.ID,
|
||||
})
|
||||
}
|
||||
out.Meta.Total = len(out.Data)
|
||||
if q == "" && category == "" {
|
||||
out.Meta.Categories = categoriesOf(list.Data)
|
||||
} else if all, err := catalog.list(r.Context(), "", ""); err == nil {
|
||||
out.Meta.Categories = categoriesOf(all.Data)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// categoriesOf returns the distinct valid categories of plugins, sorted.
|
||||
func categoriesOf(plugins []kiosk.RegistryPlugin) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, p := range plugins {
|
||||
if p.Category != "" && marketplaceCategoryRe.MatchString(p.Category) && !seen[p.Category] {
|
||||
seen[p.Category] = true
|
||||
out = append(out, p.Category)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// httpsURL returns raw when it is an absolute https URL and "" otherwise,
|
||||
// so the admin never loads an icon over another scheme.
|
||||
func httpsURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// frameworkRelease is the release a framework version string stands for:
|
||||
// the base tag of a git describe string, the string itself otherwise
|
||||
// ("devel" satisfies only releases without a framework minimum).
|
||||
func frameworkRelease(framework string) string {
|
||||
if base := kiosk.BaseVersion(framework); base != "" {
|
||||
return base
|
||||
}
|
||||
return framework
|
||||
}
|
||||
|
||||
// installedPlugins lists the activated plugins from the metadata summer
|
||||
|
||||
Reference in New Issue
Block a user