Files
summercms/modules/cabana/plugins.go
Jakub Zych 7b193032c8 fix(34): WR-06 bound the Plugins screen's search cache
pluginCatalog.lists was keyed by every distinct query and category and
never dropped an expired entry, so searching distinct strings grew the
process without limit (each entry can hold a 4 MiB answer). Storing a
search into a full cache now drops the expired searches and then the
ones that expire first, keeping at most 256.
2026-10-29 22:10:41 +01:00

557 lines
19 KiB
Go

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"
)
// The admin's read-only Plugins screen (Settings → System → Plugins): the
// plugins the application was built with, their versions and, when the
// plugin registry is configured, available updates and a marketplace
// search. The SPA calls only these endpoints; cabana calls the registry.
// PermissionViewPlugins opens the Plugins screen. No role is granted it in
// code: until an administrator grants it, only superusers see the screen.
const PermissionViewPlugins = "backend.view_plugins"
// pluginsSettingsCode is the settings code of the Plugins screen entry and
// pluginsScreenPath the SPA route it opens.
const (
pluginsSettingsCode = "plugins"
pluginsScreenPath = "/system/plugins"
)
// States of the plugin registry on the Plugins screen.
const (
// registryStatusOK: the registry answered (or is configured and was
// not needed for this answer).
registryStatusOK = "ok"
// registryStatusDisabled: plugins.registry_url is empty; no request
// leaves the process.
registryStatusDisabled = "disabled"
// registryStatusUnavailable: the registry did not answer in time or
// answered with an error.
registryStatusUnavailable = "unavailable"
)
// PluginsOverview is the GET /plugins payload: the framework version the
// application was built with, the state of the plugin registry and every
// activated plugin in summer.yaml order.
type PluginsOverview struct {
Framework string `json:"framework"`
Registry RegistryState `json:"registry"`
Plugins []InstalledPlugin `json:"plugins"`
}
// RegistryState tells the admin whether the plugin registry is configured
// (Enabled, false when plugins.registry_url is empty) and how it answered:
// Status is ok, disabled or unavailable.
type RegistryState struct {
Enabled bool `json:"enabled"`
Status string `json:"status"`
}
// InstalledPlugin is one activated plugin with the metadata summer build
// baked in (party.Info) and, for a plugin the registry lists, the newest
// release the built framework version allows. Name, Description, Version,
// Source, Module and Package are empty for an application built before
// plugin metadata existed and for a plugin without plugin.yaml (Name and
// Description). Latest is the newest compatible release; UpdateAvailable
// is true when it is newer than the installed version, and UpdateCommand
// is then the summer command that installs it. LatestFramework names the
// framework version a newer, incompatible release needs.
type InstalledPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Source string `json:"source"`
Module string `json:"module"`
Package string `json:"package"`
Listed bool `json:"listed"`
Latest string `json:"latest"`
LatestFramework string `json:"latestFramework"`
UpdateAvailable bool `json:"updateAvailable"`
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
// marketplaceCacheSize bounds the cached searches: every distinct
// query is a key, so the map must not grow with them (WR-06).
marketplaceCacheSize = 256
)
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.storeList(key, cachedList{resp: resp, err: err, expires: c.now().Add(ttl)})
c.mu.Unlock()
return resp, err
}
// storeList caches a search under key with c.mu held. It first drops the
// expired searches and then, while the cache is full, the one that
// expires first, so it never holds more than marketplaceCacheSize.
func (c *pluginCatalog) storeList(key string, entry cachedList) {
if _, ok := c.lists[key]; !ok && len(c.lists) >= marketplaceCacheSize {
now := c.now()
for k, v := range c.lists {
if !now.Before(v.expires) {
delete(c.lists, k)
}
}
for len(c.lists) >= marketplaceCacheSize {
oldest, first := "", true
for k, v := range c.lists {
if first || v.expires.Before(c.lists[oldest].expires) {
oldest, first = k, false
}
}
delete(c.lists, oldest)
}
}
c.lists[key] = entry
}
// 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 by kiosk.Newer (a git describe string
// or a pseudo-version compares by the release it builds on, so a commit
// after a release is never offered that release or an older one), 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
}
if compatible && kiosk.Newer(latest.Version, plugin.Version) {
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 kiosk.Comparable(plugin.Version) && !kiosk.Newer(newest.Version, plugin.Version) {
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 {
return SettingsEntry{
Code: pluginsSettingsCode, Label: label, Description: description,
Category: category, Icon: "puzzle", Order: 120, Keywords: []string{},
Path: pluginsScreenPath,
}
}
// pluginsPrincipal answers 401 without a backend principal and 403 when
// the principal may not open the Plugins screen; ok is false after either.
func pluginsPrincipal(w http.ResponseWriter, r *http.Request) (*bouncer.Principal, bool) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return nil, false
}
if !Allows(principal, []string{PermissionViewPlugins}) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return nil, false
}
return principal, true
}
// 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()
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)
}
// 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
}
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 the plugins the
// marketplace offers (valid IDs only), sorted.
func categoriesOf(plugins []kiosk.RegistryPlugin) []string {
seen := map[string]bool{}
out := []string{}
for _, p := range plugins {
if !marketplaceIDRe.MatchString(p.ID) {
continue
}
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
// build published (party.Installed). An application built before that
// metadata existed lists the IDs of party.Activated instead, and its
// framework version comes from the binary's build information.
func (s *service) installedPlugins() PluginsOverview {
overview := PluginsOverview{Plugins: []InstalledPlugin{}}
if s == nil || s.app == nil {
overview.Framework = bonfire.BuildVersion().Framework
return overview
}
if installed, ok := s.app.Lookup[party.Installed](); ok {
overview.Framework = installed.Framework
for _, info := range installed.Plugins {
overview.Plugins = append(overview.Plugins, InstalledPlugin{
ID: info.ID, Name: info.Name, Description: info.Description,
Version: info.Version, Source: info.Source,
Module: info.Module, Package: info.Package,
})
}
} else if activated, ok := s.app.Lookup[party.Activated](); ok {
for _, plugin := range activated {
if plugin == nil || plugin.ID() == backendScreensPluginID {
continue
}
overview.Plugins = append(overview.Plugins, InstalledPlugin{ID: plugin.ID()})
}
}
if overview.Framework == "" {
overview.Framework = bonfire.BuildVersion().Framework
}
return overview
}