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.
557 lines
19 KiB
Go
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
|
|
}
|