Files
summercms/modules/cabana/plugins.go
Jakub Zych c4b1ac4076 feat(cabana): read-only Plugins screen with installed plugins
- GET /plugins (backend.view_plugins, superusers always pass) lists the
  plugins from party.Installed, or party.Activated IDs for older builds,
  with the built framework version and whether the registry is configured
- Settings -> System -> Plugins entry with the new SettingsEntry.path,
  added only for principals allowed to open it
- PluginsView at /system/plugins with an Installed tab (reka-ui Tabs);
  settings panel, rail, breadcrumbs and page title know the route
- OpenAPI and SPA types regenerated; contract, token-class and
  conformance inventories list the route; en/pl strings; docs
2026-10-29 19:05:46 +01:00

157 lines
6.0 KiB
Go

package cabana
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"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"`
}
// 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.
func (s *service) pluginsInstalled(w http.ResponseWriter, r *http.Request) {
if _, ok := pluginsPrincipal(w, r); !ok {
return
}
overview := s.installedPlugins()
overview.Registry = s.registryState()
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}
}
return RegistryState{Enabled: true, Status: registryStatusOK}
}
// 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
}