diff --git a/admin/tests/fixtures/lang.json b/admin/tests/fixtures/lang.json
index 95f9cb69..27452e79 100644
--- a/admin/tests/fixtures/lang.json
+++ b/admin/tests/fixtures/lang.json
@@ -648,6 +648,12 @@
"backend::lang.permissions.view_plugins": {
"other": "Podgląd wtyczek"
},
+ "backend::lang.plugins.category_all": {
+ "other": "Wszystkie kategorie"
+ },
+ "backend::lang.plugins.category_label": {
+ "other": "Kategoria"
+ },
"backend::lang.plugins.column_plugin": {
"other": "Wtyczka"
},
@@ -657,24 +663,66 @@
"backend::lang.plugins.column_version": {
"other": "Wersja"
},
+ "backend::lang.plugins.command_label": {
+ "other": "Polecenie"
+ },
+ "backend::lang.plugins.copied": {
+ "other": "Skopiowano"
+ },
+ "backend::lang.plugins.copied_announce": {
+ "other": "Skopiowano polecenie do schowka."
+ },
+ "backend::lang.plugins.copy": {
+ "other": "Kopiuj"
+ },
+ "backend::lang.plugins.copy_failed": {
+ "other": "Nie udało się skopiować. Polecenie jest zaznaczone; skopiuj je skrótem Ctrl+C."
+ },
"backend::lang.plugins.empty": {
"other": "Brak zainstalowanych wtyczek."
},
+ "backend::lang.plugins.featured": {
+ "other": "Wyróżniona"
+ },
"backend::lang.plugins.framework": {
"other": "Framework :version"
},
+ "backend::lang.plugins.install_hint": {
+ "other": "Wtyczki instaluje się i aktualizuje z wiersza poleceń: uruchom polecenie w katalogu aplikacji, potem summer build, i wdróż nowy plik binarny."
+ },
"backend::lang.plugins.load_failed": {
"other": "Nie udało się wczytać listy wtyczek."
},
+ "backend::lang.plugins.marker_installed": {
+ "other": "Zainstalowana"
+ },
+ "backend::lang.plugins.marker_needs_framework": {
+ "other": "Wymaga frameworka :version"
+ },
+ "backend::lang.plugins.marketplace_empty": {
+ "other": "Brak pasujących wtyczek."
+ },
"backend::lang.plugins.menu_description": {
"other": "Zainstalowane wtyczki, ich wersje i dostępne aktualizacje oraz katalog wtyczek."
},
"backend::lang.plugins.menu_label": {
"other": "Wtyczki"
},
+ "backend::lang.plugins.needs_framework": {
+ "other": "Nowsze wydanie wymaga frameworka :version."
+ },
"backend::lang.plugins.no_version": {
"other": "Brak wersji"
},
+ "backend::lang.plugins.registry_unavailable": {
+ "other": "Katalog wtyczek nie odpowiada, więc informacje o aktualizacjach i katalog są chwilowo niedostępne."
+ },
+ "backend::lang.plugins.search_label": {
+ "other": "Szukaj wtyczek"
+ },
+ "backend::lang.plugins.search_placeholder": {
+ "other": "Szukaj po nazwie lub opisie"
+ },
"backend::lang.plugins.source_local": {
"other": "W aplikacji"
},
@@ -696,6 +744,9 @@
"backend::lang.plugins.title": {
"other": "Wtyczki"
},
+ "backend::lang.plugins.update_available": {
+ "other": "Dostępna aktualizacja do :version"
+ },
"backend::lang.relation.add": {
"other": "Dodaj"
},
diff --git a/admin/tests/views/PluginsView.test.ts b/admin/tests/views/PluginsView.test.ts
index 0a4c6b25..d4e58c82 100644
--- a/admin/tests/views/PluginsView.test.ts
+++ b/admin/tests/views/PluginsView.test.ts
@@ -1,11 +1,12 @@
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { enableAutoUnmount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import PluginsView from '../../src/views/PluginsView.vue'
import { settingsPath } from '../../src/state/useSettings'
-import type { InstalledPlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
-import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
+import type { InstalledPlugin, MarketplacePlugin, PluginsOverview, SettingsEntry } from '../../src/api/types'
+import { API, mountApp, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
const PLUGINS = `${API}/plugins`
+const MARKET = `${API}/plugins/marketplace`
function installed(overrides: Partial & { id: string }): InstalledPlugin {
return {
@@ -56,6 +57,7 @@ beforeEach(() => {
afterEach(() => {
document.body.innerHTML = ''
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
})
enableAutoUnmount(afterEach)
@@ -107,4 +109,163 @@ describe('PluginsView', () => {
}
expect(settingsPath(entry)).toBe('/system/plugins')
})
+
+ describe('with the registry', () => {
+ const registry = { enabled: true, status: 'ok' }
+
+ function withUpdate(): PluginsOverview {
+ return overview({
+ registry,
+ plugins: [
+ installed({
+ id: 'acme.blog',
+ name: 'Blog',
+ version: 'v0.1.0',
+ source: 'module',
+ listed: true,
+ latest: 'v0.1.1',
+ latestFramework: 'v0.7.0',
+ updateAvailable: true,
+ updateCommand: 'summer plugin:update acme.blog',
+ }),
+ installed({ id: 'acme.translate', version: 'v0.1.1-3-gabc1234', source: 'submodule', listed: true, latest: 'v0.1.1' }),
+ ],
+ })
+ }
+
+ function marketPlugin(overrides: Partial & { id: string }): MarketplacePlugin {
+ return {
+ name: '',
+ description: '',
+ category: 'content',
+ featured: false,
+ iconUrl: '',
+ latestVersion: 'v0.1.0',
+ latestFramework: 'v0.6.0',
+ installed: false,
+ compatible: true,
+ installCommand: `summer plugin:install ${overrides.id}`,
+ ...overrides,
+ }
+ }
+
+ const marketBody = (data: MarketplacePlugin[], status = 'ok') => ({
+ body: { data, meta: { registry: { enabled: true, status }, total: data.length, categories: ['commerce', 'content'] } },
+ })
+
+ async function openMarketplace(wrapper: VueWrapper): Promise {
+ await wrapper.find('[data-tab="marketplace"]').trigger('mousedown', { button: 0 })
+ await flushPromises()
+ }
+
+ it('shows the update badge with a copyable update command', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+ const { wrapper } = await openPlugins(withUpdate())
+ const blog = wrapper.find('[data-plugin="acme.blog"]')
+ expect(blog.find('[data-update-badge]').text()).toBe('Dostępna aktualizacja do v0.1.1')
+ expect(blog.find('[data-command="update:acme.blog"]').element.value).toBe('summer plugin:update acme.blog')
+ expect(blog.find('[data-plugin-needs-framework]').text()).toBe('Nowsze wydanie wymaga frameworka v0.7.0.')
+ expect(wrapper.find('[data-plugin="acme.translate"] [data-plugin-update]').exists()).toBe(false)
+
+ await blog.find('[data-action="copy-command"]').trigger('click')
+ await flushPromises()
+ expect(writeText).toHaveBeenCalledWith('summer plugin:update acme.blog')
+ expect(blog.find('[data-action="copy-command"]').text()).toBe('Skopiowano')
+ expect(blog.find('[data-copy-failed]').exists()).toBe(false)
+ })
+
+ it('falls back to selecting the command without a clipboard', async () => {
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
+ const { wrapper } = await openPlugins(withUpdate())
+ const blog = wrapper.find('[data-plugin="acme.blog"]')
+ await blog.find('[data-action="copy-command"]').trigger('click')
+ await flushPromises()
+ expect(blog.find('[data-copy-failed]').text()).toContain('Nie udało się skopiować')
+ expect(document.activeElement).toBe(blog.find('[data-command="update:acme.blog"]').element)
+ expect(blog.find('[data-action="copy-command"]').text()).toBe('Kopiuj')
+ })
+
+ it('hides the marketplace tab and the badges when the registry is disabled', async () => {
+ const disabled = withUpdate()
+ disabled.registry = { enabled: false, status: 'disabled' }
+ const { wrapper, calls } = await openPlugins(disabled)
+ expect(wrapper.find('[data-tab="installed"]').exists()).toBe(true)
+ expect(wrapper.find('[data-tab="marketplace"]').exists()).toBe(false)
+ expect(wrapper.find('[data-update-badge]').exists()).toBe(false)
+ expect(wrapper.find('[data-plugin-needs-framework]').exists()).toBe(false)
+ expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(0)
+ })
+
+ it('keeps the installed list and shows a notice when the registry is unavailable', async () => {
+ const down = overview({ registry: { enabled: true, status: 'unavailable' } })
+ const { wrapper } = await openPlugins(down)
+ expect(wrapper.find('[data-registry-unavailable]').exists()).toBe(true)
+ expect(wrapper.findAll('[data-plugin]')).toHaveLength(2)
+ })
+
+ it('searches the marketplace through cabana and offers install commands only', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+ const results = [
+ marketPlugin({ id: 'acme.blog', name: 'Blog', installed: true }),
+ marketPlugin({ id: 'acme.journal', name: 'Journal', description: 'A journal.', featured: true }),
+ marketPlugin({ id: 'acme.shop', name: 'Shop', compatible: false, latestFramework: 'v0.9.0', category: 'commerce' }),
+ ]
+ const { wrapper, calls } = await openPlugins(withUpdate(), {
+ [`GET ${MARKET}`]: (request) => {
+ const q = queryOf(request).get('q') ?? ''
+ const category = queryOf(request).get('category') ?? ''
+ return marketBody(results.filter((p) => p.id.includes(q) && (category === '' || p.category === category)))
+ },
+ })
+ expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(0)
+ await openMarketplace(wrapper)
+ expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
+
+ const cards = wrapper.findAll('[data-market-plugin]')
+ expect(cards.map((card) => card.attributes('data-market-plugin'))).toEqual(['acme.blog', 'acme.journal', 'acme.shop'])
+ expect(cards[0]!.find('[data-market-marker="installed"]').text()).toBe('Zainstalowana')
+ expect(cards[2]!.find('[data-market-marker="needs-framework"]').text()).toBe('Wymaga frameworka v0.9.0')
+ expect(cards[1]!.find('[data-market-description]').text()).toBe('A journal.')
+ expect(cards[1]!.find('b').exists()).toBe(false)
+ expect(cards[1]!.find('[data-command="install:acme.journal"]').element.value).toBe(
+ 'summer plugin:install acme.journal',
+ )
+ // The only buttons on a card copy the command: nothing installs from here.
+ for (const card of cards) {
+ expect(card.findAll('button').map((button) => button.attributes('data-action'))).toEqual(['copy-command'])
+ }
+ const options = wrapper.findAll('[data-market-category] option').map((option) => option.attributes('value'))
+ expect(options).toEqual(['', 'commerce', 'content'])
+
+ await cards[1]!.find('[data-action="copy-command"]').trigger('click')
+ await flushPromises()
+ expect(writeText).toHaveBeenCalledWith('summer plugin:install acme.journal')
+
+ await wrapper.find('[data-market-search]').setValue('journal')
+ expect(requestsTo(calls, 'GET', MARKET)).toHaveLength(1)
+ await wait(350)
+ await flushPromises()
+ const searches = requestsTo(calls, 'GET', MARKET)
+ expect(searches).toHaveLength(2)
+ expect(queryOf(searches[1]!).get('q')).toBe('journal')
+ expect(wrapper.findAll('[data-market-plugin]')).toHaveLength(1)
+
+ await wrapper.find('[data-market-category]').setValue('commerce')
+ await flushPromises()
+ const filtered = requestsTo(calls, 'GET', MARKET)
+ expect(queryOf(filtered[2]!).get('category')).toBe('commerce')
+ expect(wrapper.find('[data-market-empty]').exists()).toBe(true)
+ })
+
+ it('shows the registry notice when the marketplace search fails', async () => {
+ const { wrapper } = await openPlugins(withUpdate(), {
+ [`GET ${MARKET}`]: marketBody([], 'unavailable'),
+ })
+ await openMarketplace(wrapper)
+ expect(wrapper.find('[data-market-unavailable]').exists()).toBe(true)
+ expect(wrapper.findAll('[data-market-plugin]')).toHaveLength(0)
+ })
+ })
})
diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md
index bb976c70..d5d0eb91 100644
--- a/docs/backend/admin-spa.md
+++ b/docs/backend/admin-spa.md
@@ -43,7 +43,7 @@ When the server offers more than one admin language (`meta.locales` of `GET .../
## Plugins screen
-Settings → System → Plugins opens `/system/plugins`, a read-only screen for administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`; superusers always pass). Its Installed tab lists the plugins the binary was built with, from `GET /api/v1/plugins`: the name and description from each plugin's `plugin.yaml` (only the ID for a plugin without one), the version and where the plugin came from (Go module, git submodule or the application itself), with the framework version the application was built with above the tabs. The settings entry carries a `path` instead of a controller, so the settings index, the settings panel and the breadcrumbs treat the screen like any other System entry. The SPA never calls the plugin registry itself; see [Publishing plugins](../plugins/marketplace.md#admin-plugins-screen).
+Settings → System → Plugins opens `/system/plugins`, a read-only screen for administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`; superusers always pass). Its Installed tab lists the plugins the binary was built with, from `GET /api/v1/plugins`: the name and description from each plugin's `plugin.yaml` (only the ID for a plugin without one), the version and where the plugin came from (Go module, git submodule or the application itself), with the framework version the application was built with above the tabs. When the plugin registry knows a newer release that the framework version allows, the row shows an update badge and the `summer plugin:update ` command with a copy button. The Marketplace tab searches the registry by text and category and shows each plugin's `summer plugin:install ` command with a copy button; nothing is installed from the admin. With the registry disabled (`plugins.registry_url` empty) the Marketplace tab and the badges are hidden; when it does not answer, a notice says so and the Installed tab still lists every plugin. The settings entry carries a `path` instead of a controller, so the settings index, the settings panel and the breadcrumbs treat the screen like any other System entry. The SPA never calls the plugin registry itself; see [Publishing plugins](../plugins/marketplace.md#admin-plugins-screen).
## New versions
diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md
index d1cda7fc..c296549a 100644
--- a/docs/plugins/marketplace.md
+++ b/docs/plugins/marketplace.md
@@ -182,7 +182,13 @@ Every registry call goes through `kiosk.Client`, which uses `fetchguard` with a
## Admin Plugins screen
-The admin's Settings → System → Plugins screen shows administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`) what the application was built with. Its Installed tab reads `party.Installed` through `GET /api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. The screen changes nothing: plugins are installed and updated with the commands above, followed by `summer build`. With `plugins.registry_url` set to an empty value the screen reports the registry as disabled. See [Admin SPA](../backend/admin-spa.md#plugins-screen).
+The admin's Settings → System → Plugins screen shows administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`) what the application was built with and what the registry offers. The screen changes nothing: plugins are installed and updated with the commands above, followed by `summer build` and a deploy of the new binary, and the screen shows those commands with a copy button. See [Admin SPA](../backend/admin-spa.md#plugins-screen).
+
+The Installed tab reads `party.Installed` through `GET /api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. For each plugin with a version the server asks the registry for its releases and applies the rule `summer plugin:update` uses: the newest release whose framework minimum the built framework version satisfies (`kiosk.NewestCompatible`) is an update when it is newer than the installed version, and a submodule version such as `v0.1.1-3-gabc1234` compares by its base tag (`kiosk.BaseVersion`). Such a plugin gets an update badge and the command `summer plugin:update `. A newer release that needs a newer framework is named with the framework version it needs. A plugin the registry does not list, and a plugin that is part of the application (no version), gets no badge.
+
+The Marketplace tab searches the registry through `GET /api/v1/plugins/marketplace?q=&category=` (`cabana.MarketplaceResponse`): the search text (at most 100 characters) and the category are passed on to the registry. Each result shows its name, description, newest version and icon, a marker when the application already has the plugin or when the built framework is too old for its newest release, and the command `summer plugin:install ` with a copy button. There is no install button.
+
+The SPA never calls the registry: cabana does, through `kiosk.Client`, with a 4 second timeout per call, at most four version lookups at a time, and an in-process cache (searches for 5 minutes, a plugin's releases for 15 minutes, failures for 1 minute). When the registry does not answer in time or answers with an error, the Installed tab still lists every plugin, without update information, under a notice that the registry is unavailable, and the Marketplace tab shows the same notice. With `plugins.registry_url` set to an empty value no request leaves the process, the Marketplace tab and the update badges are hidden, and the Installed tab works as before.
## Registry API v1
diff --git a/modules/cabana/README.md b/modules/cabana/README.md
index dac0d068..2bc34851 100644
--- a/modules/cabana/README.md
+++ b/modules/cabana/README.md
@@ -57,7 +57,7 @@ Use cases:
- Roles screen: Settings → System → Roles (controller `cabana.RolesControllerID`, permission `cabana.PermissionManageRoles`) lists, creates, edits and deletes backend roles with name, code (slugged from the name), description and a checkbox permission editor. System roles (`is_system`, or a code that code-declared `pact.Permission` grants attach to) keep their code and cannot be deleted, a role any administrator holds (deleted ones included) cannot be deleted, a code another role uses is refused, and a non-superuser editor cannot add or remove a permission they lack. The update form's Administrators tab lists the role's holders read-only (a form partial): superusers only for superuser editors, deleted and deactivated holders marked, the first 100 with a note naming the total, each login linked to the Administrators preview for an editor holding `cabana.PermissionManageUsers`; roles are assigned only on the Administrators form.
- Record actions on another plugin's controller: a plugin implementing `pact.HasAdminActionExtensions` adds `pact.AdminRecordAction` entries to any admin controller by its ID (`pact.AdminActionExtension`), its own, another plugin's or the admin's own Administrators screen (`cabana.AdministratorsControllerID`). They are merged at boot after the target's own record actions, in plugin activation order, offered on the target's preview screen and run through the same row-locked record action route; the target's `RequiredPermissions` and the action's own `Permissions` both apply. Contributions targeting `cabana.AdministratorsControllerID` run `guardOutranked` before `Run` (403 `user.outranked`, `Run` not called). An unknown controller ID, a target without a preview block, a name the target already registers (the owner's or an earlier contribution), a reserved or non-identifier name, a missing `Run` or an empty label stops the boot. Extensions are compiled only when the admin is active.
- Form fields on another plugin's controller: a `pact.AdminActionExtension` with `Form` set to a `pact.AdminFormExtension` places fields and tabs in any admin controller's form by its ID. Its fields document is the `fields.yaml` format plus `before:`/`after:` anchors, `zone:` (only the tab zone for now) and a `tabs:` list, compiled at boot with every `fields.yaml` check; extensions apply in plugin activation order and the form is sent in the compiled order, so the SPA adds no ordering. `Hide` removes owner fields, `Permissions` and `Visible` decide per request who sees the fields (schema, show and save alike), widget fields run the extension's `Actions` and partial fields render from its `Dir` through `PartialData`. Extension values leave the save body before any owner path reads them; `Save` runs in the record's save transaction after the owner row is written (`pact.AdminFormErrors` answers 422, its notices join `meta.notices`) and `Load` serves the values on show. A form no extension lays out compiles unchanged. Unknown anchors, duplicate names, anchor cycles, anchors outside `Requires()`, other zones, `fileupload` fields and clashing widget action or partial names stop the boot naming the plugin, the controller and the name.
-- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`).
+- Plugins screen: Settings → System → Plugins opens the admin SPA route `/system/plugins` (the settings entry carries `path` instead of a controller) for administrators holding `cabana.PermissionViewPlugins` (`backend.view_plugins`, granted to no role in code; superusers always pass). GET `/plugins` lists the plugins the application was built with from the `party.Installed` metadata `summer build` bakes (name, description, version, source, module and package), or only the IDs of `party.Activated` for an application built before that metadata existed, with the framework version and the state of the plugin registry (`plugins.registry_url`, read through `kiosk.RegistryURL`). With the registry configured, cabana (never the SPA) looks up each versioned plugin's releases through `kiosk.Client` and marks an update when `kiosk.NewestCompatible` finds a release newer than the installed version's `kiosk.BaseVersion`, with the `summer plugin:update ` command; GET `/plugins/marketplace` searches the registry and adds installed and compatibility markers and the `summer plugin:install ` command. Registry calls have a 4 second timeout, at most four run at once, and answers are cached in process (searches 5 minutes, releases 15 minutes, failures 1 minute); a failing registry gives `status` unavailable and never a non-200 answer.
- Administrators screen: when some plugin registers admin controllers, `cabana.Activate` also compiles the admin's own screens, an internal plugin with ID `summercms.cabana`. Settings → System → Administrators (controller `cabana.AdministratorsControllerID`, permission `cabana.PermissionManageUsers`) lists backend administrators with their role and last sign-in, opens a preview of each, where record actions (the admin's own and contributed ones) are offered, and creates and edits administrators: login, email, names, a password (hashed at `admin.password.bcrypt_cost`, at least 4 characters and at most 72 bytes), the role and per-administrator permission overrides. The server enforces the privilege rules in the save transaction: only a superuser sees superusers or sends the superuser switch, an administrator can neither grant permissions or roles above their own grants nor change an administrator who holds more than they do, their own role, superuser flag, permissions and password are not changed here, and the last active superuser cannot be demoted. A password set for another administrator ends their sessions (personal API tokens stay valid, as with `admin:reset-password`). Administrators are activated, deactivated and restored by record and bulk actions and deleted softly through `deleted_at` (never hard-deleted); the list shows deleted and deactivated rows with their row states and filters deleted ones, and the preview shows a status hint. Deactivating or deleting an administrator ends their sessions and stops their personal API tokens; the last active superuser cannot be demoted, deactivated or deleted on any of these paths (a transaction-scoped advisory lock serializes the check). The permission codes `cabana.PermissionManageUsers` and `cabana.PermissionManageRoles` are registered with no role grant, so after an upgrade only superusers see the screens until an administrator grants them. An application without admin controllers still activates no admin.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
@@ -93,7 +93,8 @@ Guarded routes run surf's `locale.from-principal` after the backend guard: an in
| GET `/account/contributions` | The sections plugins contribute to My account (`cabana.AccountContributionView`): translated label, description and status for the signed-in administrator, and the actions (`cabana.AccountContributionActionView`) they hold the permissions for and whose `Applies` reports true. A section whose status or one of whose actions' `Applies` failed carries `error: true`. An empty list when no plugin contributes. Interactive session only. |
| POST `/account/contributions/{name}/actions/{action}` | Run one contributed action for the signed-in administrator: 404 for an unknown section or action, 403 without the action's permissions, 409 when it no longer applies; `Run` gets the administrator's id inside a transaction and answers `cabana.AccountActionResultView` (translated `message`, optional `url`, only an `http`/`https` or same-origin URL). Interactive session only, AJAX header required. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open and, for a navigation entry with a `Visible` func, that it answers true for the request; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form, and `path` (only on the admin's own Plugins entry) names the SPA route it opens. |
-| GET `/plugins` | The Plugins screen (`cabana.PluginsOverview`): the framework version the application was built with, the registry state (`cabana.RegistryState`: `enabled`, `status` ok or disabled) and every activated plugin in summer.yaml order (`cabana.InstalledPlugin`). 403 without `cabana.PermissionViewPlugins`. |
+| GET `/plugins` | The Plugins screen (`cabana.PluginsOverview`): the framework version the application was built with, the registry state (`cabana.RegistryState`: `enabled`, `status` ok, disabled or unavailable) and every activated plugin in summer.yaml order (`cabana.InstalledPlugin`) with, for a plugin the registry lists, `latest`, `updateAvailable`, `updateCommand` and `latestFramework`. 403 without `cabana.PermissionViewPlugins`. |
+| GET `/plugins/marketplace` | The registry's search for the optional `q` (at most 100 characters) and `category` (`^[a-z0-9-]{1,40}$`; anything else is a 400 `invalid_query`), as `cabana.MarketplaceResponse`: `cabana.MarketplacePlugin` entries with `installed`, `compatible` and `installCommand`, and `meta` with the registry state, `total` and the registry's `categories`. An empty list when the registry is disabled or unavailable. 403 without `cabana.PermissionViewPlugins`. |
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
@@ -301,7 +302,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.PermissionViewPlugins` | The permission code of the Plugins screen (`backend.view_plugins`), granted to no role in code. |
| `cabana.PluginsOverview` / `cabana.InstalledPlugin` / `cabana.RegistryState` | The GET `/plugins` payload: framework version, registry state and the installed plugins with their baked metadata. |
| `cabana.SettingsEntry.Path` | The SPA route a settings entry of the admin's own opens (`/system/plugins`); empty, and omitted from JSON, for plugin entries. |
-| `cabana.AdminPluginsInstalled` | Swag annotation of GET `/plugins`. |
+| `cabana.MarketplaceResponse` / `cabana.MarketplacePlugin` / `cabana.MarketplaceMeta` | The GET `/plugins/marketplace` answer: the registry's search with installed and compatibility markers and install commands, plus the registry state, total and categories. |
+| `cabana.AdminPluginsInstalled` / `cabana.AdminPluginsMarketplace` | Swag annotations of GET `/plugins` and GET `/plugins/marketplace`. |
| `cabana.AdministratorsControllerID` / `cabana.RolesControllerID` | The admin controller IDs of the Administrators (`summercms.cabana.users`) and Roles (`summercms.cabana.roles`) screens; a plugin names the first in `pact.AdminActionExtension.Controller` to add record actions to it. |
| `cabana.BackendUser.Fillable` / `cabana.BackendUser.Rules` | The columns the Administrators screen fills from a form (login, email, first and last name) and their base rules. |
| `cabana.BackendUserRole.Fillable` / `cabana.BackendUserRole.Rules` | The columns a role form fills (name, code, description) and their base rules. |
@@ -404,7 +406,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [diary](../diary/README.md) (component logger `cabana`; the `admin.auth` audit record keeps its message), [kiosk](../kiosk/README.md) (the plugin registry URL and client of the Plugins screen), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md).
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
-- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
+- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `net/url`, `path`, `reflect`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture.
## Testing
@@ -429,4 +431,4 @@ The localization of refusals is covered by Docker-free unit tests in `localize_e
The Roles form's holders list is covered by the Docker-free TestRolesHoldersPartial in `backend_screens_test.go` (the users partial field on the update form's Administrators tab, the view model guard, links, state lines, escaped holder strings, the empty line and the note) and the Postgres test TestRolesHolders in `roles_postgres_test.go` (superusers hidden from non-superuser editors, D-03; deleted and deactivated holders; preview links only with `cabana.PermissionManageUsers`; the empty role; the 100-row cap with its note; 403 without `cabana.PermissionManageRoles`).
-The Plugins screen is covered by the Docker-free TestPluginsScreenSmoke in `plugins_smoke_test.go`: the installed list from `party.Installed` and the `party.Activated` fallback, 401 and 403 without `cabana.PermissionViewPlugins`, and the settings entry with its `path` only for allowed administrators.
+The Plugins screen is covered by the Docker-free TestPluginsScreenSmoke in `plugins_smoke_test.go`: the installed list from `party.Installed` and the `party.Activated` fallback, 401 and 403 without `cabana.PermissionViewPlugins`, the settings entry with its `path` only for allowed administrators, and against a fake registry the update rule (a git describe version compares by its base tag), unlisted and local plugins, a failing or slow registry, a disabled one (no request), the marketplace search with its validation and the cache windows.
diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go
index f3c4f201..2b3cf0ef 100644
--- a/modules/cabana/admin_openapi.go
+++ b/modules/cabana/admin_openapi.go
@@ -482,7 +482,7 @@ func AdminSettingsList() {}
// AdminPluginsInstalled documents GET /plugins.
//
// @Summary Installed plugins
-// @Description The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and whether the plugin registry is configured (registry.enabled false and registry.status disabled when plugins.registry_url is empty). Requires backend.view_plugins (superusers always pass).
+// @Description The plugins the application was built with, in summer.yaml order, with the name, description, version and source summer build baked in (only the ID for an application built before plugin metadata existed), the framework version the application was built with and the state of the plugin registry. For a plugin the registry lists (listed), latest is the newest release the built framework version allows, updateAvailable says whether it is newer than the installed version (a git describe version compares by its base tag) and updateCommand is then the summer command that installs it; latestFramework names the framework a newer, incompatible release needs. Registry calls are made by the server with a 4 second timeout and cached (versions 15 minutes, failures 1 minute). A registry that is disabled (plugins.registry_url empty, registry.enabled false) or does not answer leaves the list intact and sets registry.status to disabled or unavailable. Requires backend.view_plugins (superusers always pass).
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -492,6 +492,22 @@ func AdminSettingsList() {}
// @Router /plugins [get]
func AdminPluginsInstalled() {}
+// AdminPluginsMarketplace documents GET /plugins/marketplace.
+//
+// @Summary Plugin marketplace search
+// @Description The plugin registry's search for q (matched against ID, name and description, at most 100 characters) and category (^[a-z0-9-]{1,40}$), both optional and forwarded to the registry; other values are a 400 invalid_query. Each plugin carries installed (the application already has it), compatible (the built framework version satisfies the newest release's framework minimum) and installCommand, the summer command that installs it; the admin only shows that command. meta.registry is the registry state, meta.categories the registry's categories for the filter. Searches are cached for 5 minutes. A disabled registry (registry.status disabled) or one that does not answer (unavailable) gives an empty list, never an error. Requires backend.view_plugins (superusers always pass).
+// @Tags admin
+// @Produce json
+// @Security BackendBearer
+// @Param q query string false "Search text"
+// @Param category query string false "Category"
+// @Success 200 {object} MarketplaceResponse
+// @Failure 400 {object} ErrorEnvelope
+// @Failure 401 {object} ErrorEnvelope
+// @Failure 403 {object} ErrorEnvelope
+// @Router /plugins/marketplace [get]
+func AdminPluginsMarketplace() {}
+
// AdminSettingsSchema documents GET /settings/{code}/schema.
//
// @Summary Admin settings schema
diff --git a/modules/cabana/http.go b/modules/cabana/http.go
index e27f5ad2..4f65d455 100644
--- a/modules/cabana/http.go
+++ b/modules/cabana/http.go
@@ -54,6 +54,9 @@ type service struct {
// bodies, since surf applies no body limit to raw routes.
uploadBytes int64
defaultBytes int64
+ // catalog is the Plugins screen's registry view with its cache, built
+ // by Activate.
+ catalog *pluginCatalog
}
// adminPrefix returns the mount path; a zero service uses the default.
@@ -238,6 +241,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
return nil, fmt.Errorf("cabana: admin SPA: %w", err)
}
svc.spa = spa
+ svc.catalog = newPluginCatalog(app.Config)
return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil
}
@@ -339,6 +343,7 @@ func (s *service) mount(r pact.Router) {
g.Get("/settings", s.settingsList)
// The read-only Plugins screen (backend.view_plugins).
g.Get("/plugins", s.pluginsInstalled)
+ g.Get("/plugins/marketplace", s.pluginsMarketplace)
g.Get("/settings/{code}/schema", s.settingsSchema)
constrainSetting(g)
g.Get("/settings/{code}", s.settingsGet)
diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go
index e8281ef8..3ecf49e4 100644
--- a/modules/cabana/openapi_conformance_test.go
+++ b/modules/cabana/openapi_conformance_test.go
@@ -415,6 +415,9 @@ func conformCases() []conformCase {
{"GET /plugins", 200, "cabana.Envelope-cabana_PluginsOverview", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/plugins", nil, true)
}, into[cabana.Envelope[cabana.PluginsOverview]](), nil},
+ {"GET /plugins/marketplace", 200, "cabana.MarketplaceResponse", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, "/plugins/marketplace?q=blog", nil, true)
+ }, into[cabana.MarketplaceResponse](), nil},
{"GET /settings/{code}/schema", 200, "cabana.Envelope-cabana_FormView", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/settings/conform/schema", nil, true)
}, into[cabana.Envelope[cabana.FormView]](), nil},
diff --git a/modules/cabana/plugins.go b/modules/cabana/plugins.go
index 8bc3844f..b09a51a5 100644
--- a/modules/cabana/plugins.go
+++ b/modules/cabana/plugins.go
@@ -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
diff --git a/modules/cabana/plugins_smoke_test.go b/modules/cabana/plugins_smoke_test.go
index 75ce8d3d..d9c44f67 100644
--- a/modules/cabana/plugins_smoke_test.go
+++ b/modules/cabana/plugins_smoke_test.go
@@ -4,11 +4,16 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
"git.golem15.com/golem15/summercms/modules/backpack"
"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"
)
@@ -46,7 +51,7 @@ func pluginsService(t *testing.T, cfg *compass.Config, installed *party.Installe
t.Fatal(err)
}
}
- return &service{app: app, reg: reg}
+ return &service{app: app, reg: reg, catalog: newPluginCatalog(cfg)}
}
// pluginsCall runs handler for principal on GET target and returns the
@@ -161,4 +166,317 @@ func TestPluginsScreenSmoke(t *testing.T) {
t.Fatalf("configured registry reported %+v", old.Registry)
}
})
+
+ pluginsRegistrySubtests(t)
+}
+
+// fakeRegistry is a registry API v1 server: versions per plugin ID, the
+// plugin list, a request log, and switches for failures and slowness.
+type fakeRegistry struct {
+ srv *httptest.Server
+ mu sync.Mutex
+ requests []string
+ versions map[string][]kiosk.RegistryVersion
+ list []kiosk.RegistryPlugin
+ fail atomic.Bool
+ delay atomic.Int64
+}
+
+func newFakeRegistry(t *testing.T) *fakeRegistry {
+ t.Helper()
+ f := &fakeRegistry{versions: map[string][]kiosk.RegistryVersion{}}
+ f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ f.requests = append(f.requests, r.URL.RequestURI())
+ f.mu.Unlock()
+ if d := time.Duration(f.delay.Load()); d > 0 {
+ select {
+ case <-time.After(d):
+ case <-r.Context().Done():
+ return
+ }
+ }
+ if f.fail.Load() {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ return
+ }
+ path := strings.TrimPrefix(r.URL.Path, kiosk.RegistryAPIPath)
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case path == "/plugins":
+ out := []kiosk.RegistryPlugin{}
+ q, category := r.URL.Query().Get("q"), r.URL.Query().Get("category")
+ for _, p := range f.list {
+ if (category == "" || p.Category == category) && (q == "" || strings.Contains(p.ID+" "+p.Name, q)) {
+ out = append(out, p)
+ }
+ }
+ _ = json.NewEncoder(w).Encode(kiosk.ListResponse{Data: out, Meta: kiosk.ListMeta{Total: len(out)}})
+ case strings.HasPrefix(path, "/plugins/") && strings.HasSuffix(path, "/versions"):
+ id := strings.TrimSuffix(strings.TrimPrefix(path, "/plugins/"), "/versions")
+ versions, ok := f.versions[id]
+ if !ok {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(kiosk.ErrorResponse{Error: kiosk.RegistryError{Code: kiosk.RegistryErrorNotFound, Message: "no such plugin"}})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(kiosk.VersionsResponse{Data: versions})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(f.srv.Close)
+ return f
+}
+
+// log returns the request URIs so far and clears the log.
+func (f *fakeRegistry) log() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := f.requests
+ f.requests = nil
+ return out
+}
+
+// registryFixture lists acme.blog (v0.1.1 for this framework, v0.2.0 for
+// a newer one), acme.translate (newest compatible v0.1.1) and a
+// marketplace of three plugins.
+func registryFixture(t *testing.T) *fakeRegistry {
+ t.Helper()
+ f := newFakeRegistry(t)
+ f.versions["acme.blog"] = []kiosk.RegistryVersion{
+ {Version: "v0.2.0", Framework: "v0.7.0"},
+ {Version: "v0.1.1", Framework: "v0.6.0"},
+ {Version: "v0.1.0", Framework: "v0.5.0"},
+ }
+ f.versions["acme.translate"] = []kiosk.RegistryVersion{
+ {Version: "v0.1.1", Framework: "v0.6.0"},
+ {Version: "v0.1.0", Framework: "v0.6.0"},
+ }
+ f.list = []kiosk.RegistryPlugin{
+ {ID: "acme.blog", Name: "Blog", Description: "Posts.", Category: "content", LatestVersion: "v0.2.0", LatestFramework: "v0.7.0"},
+ {ID: "acme.journal", Name: "Journal", Description: "A journal.", Category: "content", Featured: true, IconURL: "https://registry.example.test/icon.png", LatestVersion: "v0.3.0", LatestFramework: "v0.6.0"},
+ {ID: "acme.shop", Name: "Shop", Description: "Orders.", Category: "commerce", LatestVersion: "v1.0.0", LatestFramework: "v0.5.0"},
+ }
+ return f
+}
+
+func pluginByID(t *testing.T, overview PluginsOverview, id string) InstalledPlugin {
+ t.Helper()
+ for _, p := range overview.Plugins {
+ if p.ID == id {
+ return p
+ }
+ }
+ t.Fatalf("plugin %s missing from %+v", id, overview.Plugins)
+ return InstalledPlugin{}
+}
+
+func decodeMarketplace(t *testing.T, rec *httptest.ResponseRecorder) MarketplaceResponse {
+ t.Helper()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var body MarketplaceResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode %s: %v", rec.Body.String(), err)
+ }
+ return body
+}
+
+// pluginsRegistrySubtests are TestPluginsScreenSmoke's update badge and
+// marketplace subtests, run against a fake registry.
+func pluginsRegistrySubtests(t *testing.T) {
+ t.Run("updates", func(t *testing.T) {
+ f := registryFixture(t)
+ svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
+ overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ if !overview.Registry.Enabled || overview.Registry.Status != registryStatusOK {
+ t.Fatalf("registry = %+v", overview.Registry)
+ }
+ blog := pluginByID(t, overview, "acme.blog")
+ if !blog.Listed || blog.Latest != "v0.1.1" || !blog.UpdateAvailable || blog.UpdateCommand != "summer plugin:update acme.blog" || blog.LatestFramework != "v0.7.0" {
+ t.Fatalf("blog = %+v", blog)
+ }
+ })
+
+ t.Run("describe_base", func(t *testing.T) {
+ f := registryFixture(t)
+ svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
+ overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ translate := pluginByID(t, overview, "acme.translate")
+ if !translate.Listed || translate.Latest != "v0.1.1" || translate.UpdateAvailable || translate.UpdateCommand != "" || translate.LatestFramework != "" {
+ t.Fatalf("translate at v0.1.1-3-gabc1234 = %+v", translate)
+ }
+ })
+
+ t.Run("unlisted", func(t *testing.T) {
+ f := registryFixture(t)
+ delete(f.versions, "acme.translate")
+ svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
+ overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ translate := pluginByID(t, overview, "acme.translate")
+ if translate.Listed || translate.Latest != "" || translate.UpdateAvailable || translate.UpdateCommand != "" {
+ t.Fatalf("unlisted translate = %+v", translate)
+ }
+ if overview.Registry.Status != registryStatusOK {
+ t.Fatalf("a 404 for one plugin made the registry %+v", overview.Registry)
+ }
+ // The local plugin (no version) is never looked up.
+ for _, uri := range f.log() {
+ if strings.Contains(uri, "acme.local") {
+ t.Fatalf("local plugin looked up: %s", uri)
+ }
+ }
+ })
+
+ t.Run("unavailable", func(t *testing.T) {
+ f := registryFixture(t)
+ f.fail.Store(true)
+ svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
+ overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ if !overview.Registry.Enabled || overview.Registry.Status != registryStatusUnavailable || len(overview.Plugins) != 3 {
+ t.Fatalf("500 registry overview = %+v", overview)
+ }
+ if blog := pluginByID(t, overview, "acme.blog"); blog.Version != "v0.1.0" || blog.UpdateAvailable || blog.Listed {
+ t.Fatalf("blog with the registry down = %+v", blog)
+ }
+ market := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=journal"))
+ if market.Meta.Registry.Status != registryStatusUnavailable || len(market.Data) != 0 {
+ t.Fatalf("500 registry marketplace = %+v", market)
+ }
+
+ // A registry slower than the call timeout is unavailable too.
+ slow := registryFixture(t)
+ slow.delay.Store(int64(2 * time.Second))
+ svc = pluginsService(t, pluginsConfig(t, slow.srv.URL), testInstalled(), nil)
+ svc.pluginCatalog().timeout = 50 * time.Millisecond
+ start := time.Now()
+ overview = decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ if overview.Registry.Status != registryStatusUnavailable || len(overview.Plugins) != 3 {
+ t.Fatalf("slow registry overview = %+v", overview)
+ }
+ if elapsed := time.Since(start); elapsed > time.Second {
+ t.Fatalf("slow registry held the answer for %v", elapsed)
+ }
+ })
+
+ t.Run("disabled", func(t *testing.T) {
+ f := registryFixture(t)
+ svc := pluginsService(t, pluginsConfig(t, ""), testInstalled(), nil)
+ overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ if overview.Registry.Enabled || overview.Registry.Status != registryStatusDisabled {
+ t.Fatalf("disabled registry = %+v", overview.Registry)
+ }
+ for _, p := range overview.Plugins {
+ if p.Listed || p.UpdateAvailable || p.Latest != "" {
+ t.Fatalf("disabled registry still reported %+v", p)
+ }
+ }
+ market := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace"))
+ if market.Meta.Registry.Enabled || market.Meta.Registry.Status != registryStatusDisabled || len(market.Data) != 0 {
+ t.Fatalf("disabled marketplace = %+v", market)
+ }
+ if got := f.log(); len(got) != 0 {
+ t.Fatalf("requests with the registry disabled: %v", got)
+ }
+ })
+
+ t.Run("marketplace", func(t *testing.T) {
+ f := registryFixture(t)
+ svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
+ market := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?q=journal&category=content"))
+ if market.Meta.Registry.Status != registryStatusOK || len(market.Data) != 1 {
+ t.Fatalf("marketplace = %+v", market)
+ }
+ journal := market.Data[0]
+ if journal.ID != "acme.journal" || journal.Name != "Journal" || journal.Description != "A journal." || !journal.Featured ||
+ journal.LatestVersion != "v0.3.0" || journal.Installed || !journal.Compatible || journal.InstallCommand != "summer plugin:install acme.journal" ||
+ journal.IconURL != "https://registry.example.test/icon.png" || journal.Category != "content" {
+ t.Fatalf("journal = %+v", journal)
+ }
+ forwarded := false
+ for _, uri := range f.log() {
+ if strings.Contains(uri, "q=journal") && strings.Contains(uri, "category=content") {
+ forwarded = true
+ }
+ }
+ if !forwarded {
+ t.Fatal("q and category were not forwarded to the registry")
+ }
+ if strings.Join(market.Meta.Categories, ",") != "commerce,content" {
+ t.Fatalf("categories = %v", market.Meta.Categories)
+ }
+
+ all := decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace"))
+ if len(all.Data) != 3 || all.Meta.Total != 3 {
+ t.Fatalf("unfiltered marketplace = %+v", all)
+ }
+ blog := all.Data[0]
+ if blog.ID != "acme.blog" || !blog.Installed || blog.Compatible || blog.LatestFramework != "v0.7.0" {
+ t.Fatalf("installed blog needing a newer framework = %+v", blog)
+ }
+
+ for _, target := range []string{
+ "/plugins/marketplace?q=" + strings.Repeat("a", 101),
+ "/plugins/marketplace?category=Content",
+ "/plugins/marketplace?category=" + strings.Repeat("a", 41),
+ } {
+ rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, target)
+ if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_query"`) {
+ t.Fatalf("%s: status=%d body=%s", target, rec.Code, rec.Body.String())
+ }
+ }
+ if rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsDenied, "/plugins/marketplace"); rec.Code != http.StatusForbidden {
+ t.Fatalf("marketplace without %s: status=%d", PermissionViewPlugins, rec.Code)
+ }
+ })
+
+ t.Run("cache", func(t *testing.T) {
+ f := registryFixture(t)
+ svc := pluginsService(t, pluginsConfig(t, f.srv.URL), testInstalled(), nil)
+ now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
+ svc.pluginCatalog().now = func() time.Time { return now }
+
+ decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog"))
+ if first := f.log(); len(first) == 0 {
+ t.Fatal("no registry request on the first load")
+ }
+ decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog"))
+ if again := f.log(); len(again) != 0 {
+ t.Fatalf("cached answers still called the registry: %v", again)
+ }
+
+ // After the marketplace window (5 minutes) the search is asked
+ // again; the versions (15 minutes) are not.
+ now = now.Add(6 * time.Minute)
+ decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins"))
+ decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog"))
+ for _, uri := range f.log() {
+ if strings.Contains(uri, "/versions") {
+ t.Fatalf("versions asked again inside their window: %s", uri)
+ }
+ }
+
+ // A failure is cached for a minute only.
+ f.fail.Store(true)
+ now = now.Add(16 * time.Minute)
+ if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")); got.Registry.Status != registryStatusUnavailable {
+ t.Fatalf("failing registry = %+v", got.Registry)
+ }
+ f.fail.Store(false)
+ f.log()
+ if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")); got.Registry.Status != registryStatusUnavailable {
+ t.Fatalf("failure not cached: %+v", got.Registry)
+ }
+ if got := f.log(); len(got) != 0 {
+ t.Fatalf("cached failure still called the registry: %v", got)
+ }
+ now = now.Add(2 * time.Minute)
+ if got := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")); got.Registry.Status != registryStatusOK {
+ t.Fatalf("recovered registry = %+v", got.Registry)
+ }
+ })
}
diff --git a/modules/cabana/security_coverage_test.go b/modules/cabana/security_coverage_test.go
index 983bc501..c8fc1459 100644
--- a/modules/cabana/security_coverage_test.go
+++ b/modules/cabana/security_coverage_test.go
@@ -54,6 +54,7 @@ var phase09Routes = []adminRoute{
{key: "GET /field-types"},
{key: "GET /settings"},
{key: "GET /plugins"},
+ {key: "GET /plugins/marketplace"},
{key: "GET /settings/{code}/schema"},
{key: "GET /settings/{code}"},
{key: "PUT /settings/{code}"},
@@ -309,6 +310,7 @@ type phase09Call struct {
func phase09ProtectedCalls() []phase09Call {
return []phase09Call{
{"plugins-installed", (*service).pluginsInstalled},
+ {"plugins-marketplace", (*service).pluginsMarketplace},
{"list", (*service).list},
{"create", (*service).create},
{"bulk-delete", (*service).bulkDelete},
diff --git a/modules/cabana/token_routes_test.go b/modules/cabana/token_routes_test.go
index 131dfa25..61b5f95a 100644
--- a/modules/cabana/token_routes_test.go
+++ b/modules/cabana/token_routes_test.go
@@ -50,6 +50,7 @@ var tokenRouteClasses = map[string]tokenRouteClass{
"GET /field-types": classExplicitGate,
"GET /settings": classAllowsSettings,
"GET /plugins": classAllowsProtect,
+ "GET /plugins/marketplace": classAllowsProtect,
"GET /settings/{code}/schema": classAllowsSettings,
"GET /settings/{code}": classAllowsSettings,
"PUT /settings/{code}": classAllowsSettings,
diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml
index 81a8e04a..bb215504 100644
--- a/modules/phrasebook/backend/lang/en/lang.yaml
+++ b/modules/phrasebook/backend/lang/en/lang.yaml
@@ -560,6 +560,23 @@ plugins:
no_version: "No version"
empty: "No plugins are installed."
load_failed: "The plugin list could not be loaded."
+ registry_unavailable: "The plugin registry is not answering, so update information and the marketplace are unavailable for now."
+ update_available: "Update to :version available"
+ needs_framework: "A newer release needs framework :version."
+ command_label: "Command"
+ copy: "Copy"
+ copied: "Copied"
+ copied_announce: "Command copied to the clipboard."
+ copy_failed: "Copying failed. The command is selected; copy it with Ctrl+C."
+ search_label: "Search plugins"
+ search_placeholder: "Search by name or description"
+ category_label: "Category"
+ category_all: "All categories"
+ install_hint: "Plugins are installed and updated from the command line: run the command in the application's directory, then summer build, and deploy the new binary."
+ marketplace_empty: "No plugins match."
+ marker_installed: "Installed"
+ marker_needs_framework: "Needs framework :version"
+ featured: "Featured"
permissions:
tab_system: "System"
manage_users: "Manage administrators"
diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml
index 5d354574..cc6eaf7c 100644
--- a/modules/phrasebook/backend/lang/pl/lang.yaml
+++ b/modules/phrasebook/backend/lang/pl/lang.yaml
@@ -596,6 +596,23 @@ plugins:
no_version: "Brak wersji"
empty: "Brak zainstalowanych wtyczek."
load_failed: "Nie udało się wczytać listy wtyczek."
+ registry_unavailable: "Katalog wtyczek nie odpowiada, więc informacje o aktualizacjach i katalog są chwilowo niedostępne."
+ update_available: "Dostępna aktualizacja do :version"
+ needs_framework: "Nowsze wydanie wymaga frameworka :version."
+ command_label: "Polecenie"
+ copy: "Kopiuj"
+ copied: "Skopiowano"
+ copied_announce: "Skopiowano polecenie do schowka."
+ copy_failed: "Nie udało się skopiować. Polecenie jest zaznaczone; skopiuj je skrótem Ctrl+C."
+ search_label: "Szukaj wtyczek"
+ search_placeholder: "Szukaj po nazwie lub opisie"
+ category_label: "Kategoria"
+ category_all: "Wszystkie kategorie"
+ install_hint: "Wtyczki instaluje się i aktualizuje z wiersza poleceń: uruchom polecenie w katalogu aplikacji, potem summer build, i wdróż nowy plik binarny."
+ marketplace_empty: "Brak pasujących wtyczek."
+ marker_installed: "Zainstalowana"
+ marker_needs_framework: "Wymaga frameworka :version"
+ featured: "Wyróżniona"
permissions:
tab_system: "System"
manage_users: "Zarządzanie administratorami"