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 ) 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 { 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 }