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:
Jakub Zych
2026-10-29 19:20:14 +01:00
parent c4b1ac4076
commit 7e8a19399d
18 changed files with 1624 additions and 79 deletions

View File

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