diff --git a/docs/superpowers/plans/2026-07-09-plugin-hub-approved-community-catalog.md b/docs/superpowers/plans/2026-07-09-plugin-hub-approved-community-catalog.md new file mode 100644 index 00000000..1fea977f --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-plugin-hub-approved-community-catalog.md @@ -0,0 +1,477 @@ +# Plugin Hub and Approved Community Catalog Implementation Plan + +> **For implementation:** Use the `executing-plans` skill and complete the +> phases in dependency order. Commands assume the repository root is the cwd. + +**Goal:** Replace the current raw plugin cards with an operator-focused Plugin +Hub that explains what each plugin does, shows setup and release information, +links to its source, and lets a server administrator opt into Silo-approved +community plugins without manually managing repository URLs. + +**Audience:** Server administrators and casual homelab operators. This is not an +end-user plugin surface. Developer identifiers and repository details remain +available, but they are secondary to plain-language purpose, setup, health, and +update information. + +**Approval meaning:** An **Approved community** plugin has been reviewed by Silo +maintainers, validated to install and work as described, and considered safe for +its documented use at the time of approval. It remains community-maintained; +approval does not transfer maintenance or support ownership to the Silo core +team and is not a permanent guarantee against future vulnerabilities. + +**Architecture:** Add typed presentation fields to `PluginManifest`; keep +version-specific GitHub release notes in catalog packages; create a separately +owned `silo-community/silo-plugins` catalog with an allowlisted approval gate; +and teach Silo to manage official, approved-community, and custom sources as +different provenance classes. The admin UI consumes additive `/api/v1` fields, +uses a default-off community-channel setting, and opens URL-addressable plugin +details from the Installed and Catalog views. + +**Delivery shape:** This is one feature with coordinated PRs across +`silo-plugin-sdk`, `silo-plugins`, the new community catalog, `silo-server`, and +the affected plugin repositories. Do not combine all repositories into one +commit or PR. + +--- + +## Product Decisions and Defaults + +- The primary tabs remain **Installed** and **Catalog**. +- The catalog contains Silo-maintained plugins by default. +- **Include approved community plugins** is a server-wide setting and defaults + to `false` for both fresh installs and upgrades. +- Enabling the setting manages approved community catalog sources on the + administrator's behalf; it does not ask for a GitHub URL. +- Disabling the setting hides community catalog entries and pauses update + discovery for community installations. It never disables or uninstalls an + installed plugin. +- When community installations exist, disabling requires confirmation and + states how many plugins will stop receiving update discovery. +- Manual catalog repositories and archive uploads remain available under + **Manage sources → Advanced**. +- Cards use **Silo maintained**, **Approved community**, and **External source** + as the provenance labels. Do not use one ambiguous `Verified` badge. +- The approval badge is catalog/host-derived. A plugin manifest cannot declare + itself approved. +- In-app release content is labeled **What's new**. The first implementation + stores the latest release notes and links to the complete external changelog; + it does not retain a full in-app version history. +- The Plugin Hub is web-admin-only. No Android or Apple client changes are + required. + +## Explicitly Out of Scope + +- An end-user plugin marketplace or plugin controls outside the admin UI. +- Selecting and transferring the first batch of repositories into + `silo-community`; that migration should follow this infrastructure plan with + an explicit repository list. +- Plugin rollback or installation of arbitrary historical versions. +- Cryptographic artifact signing or reproducible-build attestation beyond the + existing release checksum contract. +- A remote kill switch. Removing a plugin from the approved catalog stops new + installs and updates, but does not remotely stop already-installed code. +- Automated proof that a plugin is safe. Approval includes human review and + runtime validation. + +--- + +## Verified Baseline + +- `PluginManifest` has `metadata` and `category` but no plugin-level display + name, summary, description, setup guide, publisher, or source links. + `CapabilityDescriptor.description` exists but describes individual + capabilities rather than the plugin as a whole. +- `silo-plugins` already writes `repo_url` into each catalog package, but + `silo-server/internal/plugins/catalog_service.go` does not decode or return + it. +- The catalog updater fetches the GitHub release tag and assets but does not + preserve the release page URL, publication time, or release body. +- The server seeds one official repository only when *no* repository rows + exist. This is not sufficient for multiple managed channels or for adopting + an existing installation that already has custom repositories. +- Catalog fetches are live and unbounded, and a failing source is skipped. No + last-known-good catalog payload is available to keep browsing and release + notes usable during an outage. +- Auto-update selection currently chooses the highest version for a plugin ID + across all enabled repositories. Updates must instead stay pinned to the + installation's repository so a custom source cannot shadow an official or + approved plugin. +- `AdminPlugins.tsx` is a single large page. Installed and available cards show + raw plugin IDs, versions, capability chips, and operational controls but no + plugin-level description, release notes, or source provenance. + +--- + +## Public Contracts + +### SDK manifest presentation + +In `silo-plugin-sdk/proto/silo/plugin/v1/common.proto`, add a new additive +message and field: + +```text +PluginPresentation presentation = 13; + +PluginPresentation: + display_name + summary + description_markdown + setup_markdown + homepage_url + source_url + support_url + changelog_url + publisher_name + publisher_url + license_spdx +``` + +Contract rules: + +- Existing manifests without `presentation` remain valid. +- When `presentation` is present, validate lengths and accept only absolute + `http` or `https` links. Reject control characters and unsafe schemes. +- Recommended limits: `display_name` 120 characters, `summary` 240 characters, + and each Markdown field 32 KiB. +- SDK validation does not require presentation fields globally during the + compatibility window. Official and approved-community catalog CI applies the + stricter publishing requirement. +- Markdown is CommonMark-style text. Raw HTML is not part of the contract. +- `publisher_*` and source links are self-declared identity information; they + never determine Silo approval. + +Regenerate the Go protobuf output, update manifest fixtures and documentation, +and publish a new additive SDK minor release before downstream repositories +consume these fields. + +### Catalog release and approval metadata + +Extend the catalog package JSON shared by the official and community catalogs: + +```text +repo_url +release: + url + published_at + notes_markdown +approval: # community catalog only + approved_at + review_url +``` + +- `repo_url` remains the canonical source-code link generated from the GitHub + repository that produced the release. +- `release` is generated from the GitHub Releases API. Bound + `notes_markdown` to 64 KiB before writing the catalog. +- `approval` comes from the community catalog's reviewed allowlist, never from + the plugin's manifest or release payload. +- `changelog_url` remains in `PluginPresentation`; when absent, the web UI may + link to the repository's Releases page derived from `repo_url`. +- Preserve the complete protobuf manifest during catalog generation. Do not + reduce it to a hand-built subset. + +### Additive server API fields + +Add `GET` and `PUT /api/v1/admin/plugins/catalog-settings`: + +```text +include_approved_community_plugins: boolean +approved_community_plugin_count: number +installed_community_plugin_count: number +community_updates_paused: boolean +``` + +The `PUT` body accepts only +`include_approved_community_plugins`. It persists the preference, reconciles +managed repository rows, and returns the new state. A source refresh failure +does not roll the preference back; the repository status reports the failure +and the catalog falls back to cached data when available. + +Add `GET /api/v1/admin/plugins/capabilities` for feature detection. It reports +support for typed presentation metadata, release notes, approved community +catalogs, and last-known catalog caching. + +Extend existing responses additively: + +- `PluginRepository`: `source_kind`, `managed`, `last_fetch_error`, + `last_fetch_error_at`. +- `PluginCatalogEntry`: typed `presentation`, `source_kind`, repository display + name, `repo_url`, `release`, optional `approval`, and `stale`. +- `PluginInstallation`: typed `presentation`, source/repository provenance, + the matching current or available release metadata when known, and + `updates_paused`. + +Keep existing `metadata`, capability, route, asset, and configuration fields +unchanged for `/api/v1` compatibility. + +--- + +## Phase 1 — SDK Presentation Contract + +Repository: `silo-plugin-sdk` + +- [ ] Add `PluginPresentation` and field 13 to the protobuf contract; regenerate + `pkg/pluginproto` with `make proto`. +- [ ] Add reusable URL and length validation in + `pkg/pluginsdk/manifest/manifest.go` without making presentation mandatory for + older plugins. +- [ ] Add decode/round-trip/validation tests for complete, partial, absent, and + unsafe presentation blocks. +- [ ] Document every presentation field and provide one complete example + manifest for plugin authors. +- [ ] Publish an additive SDK minor release and verify the tag is consumable + from a clean downstream module. + +Verification: `GOWORK=off go test ./...`, `make proto`, and a clean-tree check +after regeneration. + +## Phase 2 — Catalog Tooling and Community Approval Gate + +Repositories: `Silo-Server/silo-plugins` and new +`silo-community/silo-plugins` + +- [ ] Extend the shared catalog generator's GitHub `Release` DTO and + `CatalogPackage` to preserve source, publication, release-note, and approval + fields. +- [ ] Keep the update workflow's shared concurrency group so simultaneous + plugin releases cannot race on `manifest.json`. +- [ ] Add tests proving the generator preserves the full manifest, bounds + release notes, derives safe URLs, and produces deterministic JSON. +- [ ] Create `silo-community/silo-plugins` with the same catalog package and + release-asset contract as the official catalog. +- [ ] Add `approved-plugins.json` to the community catalog. Each active entry + contains `plugin_id`, exact GitHub repository, `approved_at`, and a URL to the + approval review/evidence. +- [ ] Make community catalog updates reject releases whose repository or + plugin ID is not active in `approved-plugins.json`. +- [ ] Protect the community catalog's main branch and approval registry with + CODEOWNERS/required review. Release dispatch alone must not grant approval. +- [ ] Add a rebuild/check command that removes packages no longer present in + the active allowlist so catalog membership cannot remain stale indefinitely. +- [ ] Publish an approval policy documenting the minimum review: successful + builds/tests, checksum-bearing release artifacts, manifest/setup accuracy, + runtime validation on a supported Silo version, source/release-workflow + review, license, support path, and no known unsafe behavior. + +Verification: `GOWORK=off go test ./...`; run the updater against fixtures for +an approved and rejected repository; run the catalog rebuild/check twice to +prove idempotence. + +## Phase 3 — Server Repository Provenance, Preference, and Cache + +Repository: `silo-server` + +- [ ] Create a timestamped Goose migration with + `make migrate-create NAME=plugin_catalog_provenance`. +- [ ] Extend `plugin_repositories` with a nullable unique `managed_key`, a + constrained `source_kind` (`silo`, `approved_community`, `external`), and + last-fetch error fields. Existing rows default to `external` and the known + official URL is adopted as `silo` during reconciliation. +- [ ] Add a `plugin_catalog_cache` table keyed by repository, plugin ID, and + version, storing the normalized catalog package JSON plus fetch time. Delete + cache rows with the repository. +- [ ] Add a plugin-owned setting key + `plugins.include_approved_community_plugins`; missing/invalid values resolve + to `false`. +- [ ] Replace `seedDefaultRepository` with an idempotent managed-repository + reconciler. It always adopts/upserts the official catalog and upserts + `https://raw.githubusercontent.com/silo-community/silo-plugins/main/manifest.json` + enabled according to the community setting. Structure the registry as a list + so another approved community catalog can be added without a second toggle. +- [ ] Managed repository URLs, names, keys, and provenance are read-only through + manual repository CRUD. Custom repositories retain existing create/edit/delete + behavior. +- [ ] Bound repository-index downloads before JSON decoding. Validate and cache + only accepted packages; on a successful refresh transactionally replace that + repository's cache and clear its error state. +- [ ] On a fetch failure, record a concise error and serve last-known cached + entries with `stale=true`. An enabled source with no cache returns no entries + but remains enabled for retry. +- [ ] Resolve catalog duplicates deterministically by source precedence + (`silo` → `approved_community` → `external`) before comparing versions within + a source. A higher-version custom package must never shadow the same official + or approved plugin ID. +- [ ] Make update discovery repository-pinned: installations with a + `repository_id` compare only against that repository's entries. Direct uploads + without a repository do not gain catalog updates accidentally. +- [ ] When a source is disabled, retain `available_version` but return + `updates_paused=true`; applying the update is blocked until the source is + enabled again. +- [ ] Enrich installed responses from the on-disk manifest first and matching + cached catalog metadata second, so descriptions and source links survive a + network outage and release notes remain available after the community channel + is disabled. + +Tests: + +- managed-source reconciliation is idempotent and adopts an existing official + row without duplication; +- the community preference defaults off and toggles all managed community + sources without touching custom rows; +- stale cache is served after fetch failure and replaced after recovery; +- official/community/external duplicate precedence is deterministic; +- update discovery cannot switch an installation to another repository; +- disabling the community channel leaves installations enabled and marks their + updates paused; +- migration Up/Down and repository constraints behave correctly against the + test database. + +## Phase 4 — Server HTTP API + +Repository: `silo-server` + +- [ ] Add typed catalog-settings and capability handlers to + `internal/api/handlers/plugins.go` or focused files in the same handler + package; mount them under the existing acting-admin plugin route group. +- [ ] Extend repository, catalog, and installation serializers with the + additive presentation, release, provenance, approval, stale, and paused + fields. +- [ ] Compute `installed_community_plugin_count` by repository provenance, not + by parsing plugin IDs or GitHub URLs. +- [ ] Validate the `PUT` body strictly and make preference persistence plus + managed-row reconciliation atomic from the caller's perspective. +- [ ] Keep catalog source failures per-repository: one broken community or + custom source must not fail the entire catalog response. +- [ ] Add handler tests for default-off state, enable/disable, malformed input, + community installation counts, additive response fields, stale-source + reporting, and acting-admin authorization. + +Verification: +`GOWORK=off go test ./internal/plugins/... ./internal/api/handlers/...`. + +## Phase 5 — Plugin Hub Web UI + +Repository: `silo-server` + +- [ ] Split `AdminPlugins.tsx` into focused components under + `web/src/components/admin/plugins/` or `web/src/pages/admin-plugins/`: page + shell, Installed list, Catalog grid, catalog controls, detail sheet, + configuration content, Markdown renderer, and advanced source manager. +- [ ] Add typed DTOs and TanStack Query hooks for plugin capabilities and + catalog settings. Mutations invalidate settings, repositories, catalog, and + installations together. +- [ ] Keep **Installed** as the default tab and rename **Available** to + **Catalog**. +- [ ] Place **Include approved community plugins** in the Catalog header with + this helper meaning: these plugins are validated by Silo to work as described + and are considered safe, but are maintained and supported by community + contributors. +- [ ] Enabling shows a refresh state and then community entries. Disabling with + installed community plugins opens a confirmation that reports the count and + explains that plugins keep running while update discovery pauses. +- [ ] Add search plus provenance/capability filters. Preserve the active tab, + search, and filters in URL search parameters. +- [ ] Catalog cards show display name, publisher/provenance, two-line summary, + capabilities, version, configuration requirement, and Install. Installed + rows show operational status, summary, version, Configure/Open, and + Review update; move update policy, disable, and uninstall into secondary + controls. +- [ ] Use URL search parameters for addressable details: + `installation=` for installed plugins and + `repository=&plugin=` for catalog entries. Browser Back closes + the sheet and focus returns to the originating card. Use a full-width sheet + on small screens. +- [ ] Detail sections are **About**, **Setup**, **What's new**, and + **Technical details**. Technical details include plugin ID, API/platform + compatibility, publisher, license, source catalog, source repository, support, + and changelog links. +- [ ] Change update behavior from immediate `Update` to `Review update`; show + the target release notes before the administrator confirms the update. +- [ ] Add a constrained Markdown renderer. Raw HTML and images are disabled; + only safe `http`/`https` links are rendered, and external links use + `rel="noopener noreferrer"`. +- [ ] Move repository CRUD and archive upload beneath **Manage sources → + Advanced**. Managed official/community sources show status and fetch errors + but cannot have their system URL edited or be deleted. +- [ ] Backward-compatible UI fallbacks: humanize `plugin_id` when display name + is absent; use the first capability description when summary is absent; show + “No setup guide provided” or “No release notes were published” rather than an + empty panel. +- [ ] Add accessible labels, keyboard navigation, focus management, loading + skeletons, empty states, and per-source stale/error messaging. + +Frontend tests: + +- default-off toggle and opt-in catalog refresh; +- disable confirmation with installed community plugins; +- provenance filters and badges; +- legacy-manifest fallback rendering; +- detail deep link and Back behavior; +- safe Markdown links with raw HTML/images suppressed; +- Review update requires confirmation and displays release notes; +- stale catalog and missing release-note states. + +Verification: `cd web && pnpm run lint`, `cd web && pnpm run format:check`, +`cd web && pnpm test`, and `cd web && pnpm run build`. Capture desktop and +mobile screenshots for the UI PR. + +## Phase 6 — Manifest Backfill and Community Migration Readiness + +Repositories: current first-party plugins and future community plugin repos + +- [ ] Update every cataloged plugin to the released SDK version and add complete + presentation metadata. Use plain language for homelab operators; capability + descriptions remain technical and specific. +- [ ] Require each repository to publish meaningful GitHub release notes so + What's new is not an empty surface. +- [ ] Standardize README setup instructions, license, security/support policy, + CODEOWNERS or named maintainers, and the existing checksum-bearing release + workflow. +- [ ] Before transferring a selected plugin to `silo-community`, update its + manifest publisher/source links and release dispatch target, add it to the + community approval registry through review, and remove it from the official + catalog in the same rollout window. +- [ ] Verify a transferred plugin appears only once, installs from the community + repository, remains repository-pinned for updates, and is hidden for admins + who have not enabled the community channel. + +--- + +## Rollout Order + +1. Merge and release the SDK contract. +2. Upgrade official catalog tooling and create the approved community catalog + plus approval policy/registry. +3. Land the server migration, managed-source reconciliation, cache, update + pinning, and additive APIs. +4. Land the Plugin Hub UI and keep the community switch default-off. +5. Backfill presentation data and release notes in existing official plugins. +6. Transfer selected plugins one at a time only after both catalogs and the + deployed server understand provenance. + +Older servers ignore new catalog/manifest fields; newer servers retain fallbacks +for older manifests. Do not transfer a plugin out of the official catalog before +the community catalog and server opt-in path are deployed. + +## Final Acceptance Scenarios + +- A fresh server shows Silo-maintained catalog entries with friendly names, + descriptions, setup instructions, source links, and release notes; approved + community plugins are absent. +- An admin enables **Include approved community plugins**, sees the approved + entries without entering a URL, and can identify their community maintenance + and Silo approval clearly. +- A community catalog outage leaves cached entries visible with a stale warning + and does not break the official catalog. +- Disabling the community channel with installed community plugins requires + confirmation, leaves those plugins running, and clearly reports paused update + discovery. +- A custom repository publishing the same plugin ID or a higher version cannot + replace or update an official/approved installation. +- Review update presents the exact target release notes before installation. +- An old plugin without presentation fields remains manageable with readable + fallbacks. +- Removing a plugin from the approved catalog stops new discovery and updates + without remotely disabling existing installations. + +## Cross-Repository Verification Gate + +- `silo-plugin-sdk`: `GOWORK=off go test ./...` and `make proto`. +- Official and community catalogs: `GOWORK=off go test ./...` plus catalog + rebuild/check idempotence. +- `silo-server`: focused plugin/API Go tests, web lint/format/test/build, + `make lint`, and `make verify-local-paths`. +- End-to-end dev smoke: toggle community off/on, install one approved plugin, + configure it, simulate a catalog outage, review an update, disable the + community channel, and confirm the installed plugin continues running while + updates are paused. diff --git a/go.mod b/go.mod index a92be25d..4205c237 100644 --- a/go.mod +++ b/go.mod @@ -108,7 +108,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.9.0 + github.com/Silo-Server/silo-plugin-sdk v0.10.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect diff --git a/go.sum b/go.sum index 7cbd552b..80bffd6f 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= -github.com/Silo-Server/silo-plugin-sdk v0.9.0 h1:fo4vUz3AcHO9tr5MYHdk+O3kLxIckf7rIBqv9Z5Ys+0= -github.com/Silo-Server/silo-plugin-sdk v0.9.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= +github.com/Silo-Server/silo-plugin-sdk v0.10.0 h1:OU2PfQwnUQAnf8FEZSTfWI+3x0OBiVfjKHHaVWhD/E0= +github.com/Silo-Server/silo-plugin-sdk v0.10.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= diff --git a/internal/api/handlers/plugins.go b/internal/api/handlers/plugins.go index ed53be34..463af5c2 100644 --- a/internal/api/handlers/plugins.go +++ b/internal/api/handlers/plugins.go @@ -77,6 +77,10 @@ type pluginRepositoryRequest struct { Enabled *bool `json:"enabled,omitempty"` } +type pluginCatalogSettingsRequest struct { + IncludeApprovedCommunityPlugins *bool `json:"include_approved_community_plugins"` +} + type pluginInstallationCreateRequest struct { RepositoryID *int `json:"repository_id,omitempty"` PluginID string `json:"plugin_id,omitempty"` @@ -122,6 +126,8 @@ type pluginRepositoryResponse struct { URL string `json:"url"` DisplayName string `json:"display_name"` Enabled bool `json:"enabled"` + SourceKind string `json:"source_kind"` + Managed bool `json:"managed"` LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -132,6 +138,10 @@ type pluginCatalogResponse struct { PluginID string `json:"plugin_id"` Version string `json:"version"` ArchiveURL string `json:"archive_url"` + SourceKind string `json:"source_kind"` + RepositoryName string `json:"repository_name"` + RepoURL string `json:"repo_url,omitempty"` + Presentation *pluginPresentationJSON `json:"presentation,omitempty"` Capabilities []pluginCapabilityJSON `json:"capabilities"` GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"` UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"` @@ -149,6 +159,11 @@ type pluginInstallationResponse struct { Enabled bool `json:"enabled"` UpdatePolicy string `json:"update_policy"` AvailableVersion *string `json:"available_version,omitempty"` + SourceKind string `json:"source_kind"` + RepositoryName string `json:"repository_name,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + Presentation *pluginPresentationJSON `json:"presentation,omitempty"` + UpdatesPaused bool `json:"updates_paused"` Capabilities []pluginCapabilityJSON `json:"capabilities"` GlobalConfigSchema []pluginConfigSchemaJSON `json:"global_config_schema"` UserConfigSchema []pluginConfigSchemaJSON `json:"user_config_schema"` @@ -162,6 +177,28 @@ type pluginInstallationResponse struct { UpdatedAt time.Time `json:"updated_at"` } +type pluginCatalogSettingsResponse struct { + IncludeApprovedCommunityPlugins bool `json:"include_approved_community_plugins"` + ApprovedCommunityPluginCount int `json:"approved_community_plugin_count"` + InstalledCommunityPluginCount int `json:"installed_community_plugin_count"` + MigratedPluginCount int `json:"migrated_plugin_count"` + CommunityUpdatesPaused bool `json:"community_updates_paused"` +} + +type pluginPresentationJSON struct { + DisplayName string `json:"display_name"` + Summary string `json:"summary"` + DescriptionMarkdown string `json:"description_markdown"` + SetupMarkdown string `json:"setup_markdown"` + HomepageURL string `json:"homepage_url"` + SourceURL string `json:"source_url"` + SupportURL string `json:"support_url"` + ChangelogURL string `json:"changelog_url"` + PublisherName string `json:"publisher_name"` + PublisherURL string `json:"publisher_url"` + LicenseSPDX string `json:"license_spdx"` +} + type pluginConfigSchemaJSON struct { Key string `json:"key"` Title string `json:"title"` @@ -335,6 +372,10 @@ func (h *PluginHandler) HandleCreateRepository(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, "bad_request", "url and display_name are required") return } + if req.URL == plugins.DefaultRepositoryURL || req.URL == plugins.ApprovedCommunityRepositoryURL { + writeError(w, http.StatusBadRequest, "managed_repository", "Use catalog settings to manage built-in plugin repositories") + return + } repository, err := h.repositories.Create(r.Context(), plugins.CreateRepositoryInput{ URL: req.URL, @@ -378,6 +419,10 @@ func (h *PluginHandler) HandleUpdateRepository(w http.ResponseWriter, r *http.Re writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found") return } + if errors.Is(err, plugins.ErrManagedRepositoryReadOnly) { + writeError(w, http.StatusConflict, "managed_repository", "Managed plugin repositories are controlled by catalog settings") + return + } slog.ErrorContext(r.Context(), "updating plugin repository", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin repository") return @@ -405,6 +450,10 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found") return } + if errors.Is(err, plugins.ErrManagedRepositoryReadOnly) { + writeError(w, http.StatusConflict, "managed_repository", "Managed plugin repositories cannot be deleted") + return + } slog.ErrorContext(r.Context(), "deleting plugin repository", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin repository") return @@ -413,6 +462,42 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re w.WriteHeader(http.StatusNoContent) } +func (h *PluginHandler) HandleGetCatalogSettings(w http.ResponseWriter, r *http.Request) { + settings, err := h.repositories.GetCatalogSettings(r.Context()) + if err != nil { + slog.ErrorContext(r.Context(), "loading plugin catalog settings", "component", "api", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin catalog settings") + return + } + writeJSON(w, http.StatusOK, toPluginCatalogSettingsResponse(settings)) +} + +func (h *PluginHandler) HandlePutCatalogSettings(w http.ResponseWriter, r *http.Request) { + var req pluginCatalogSettingsRequest + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "bad_request", "Request body must contain one JSON object") + return + } + if req.IncludeApprovedCommunityPlugins == nil { + writeError(w, http.StatusBadRequest, "bad_request", "include_approved_community_plugins is required") + return + } + + settings, err := h.repositories.SetIncludeApprovedCommunity(r.Context(), *req.IncludeApprovedCommunityPlugins) + if err != nil { + slog.ErrorContext(r.Context(), "updating plugin catalog settings", "component", "api", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin catalog settings") + return + } + writeJSON(w, http.StatusOK, toPluginCatalogSettingsResponse(settings)) +} + func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { entries, err := h.service.FetchCatalog(r.Context()) if err != nil { @@ -423,11 +508,20 @@ func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { response := make([]pluginCatalogResponse, 0, len(entries)) for _, entry := range entries { + presentation := toPluginPresentationJSON(entry.Manifest.GetPresentation()) + repoURL := entry.RepoURL + if repoURL == "" && presentation != nil { + repoURL = presentation.SourceURL + } response = append(response, pluginCatalogResponse{ RepositoryID: entry.RepositoryID, PluginID: entry.Manifest.GetPluginId(), Version: entry.Manifest.GetVersion(), ArchiveURL: entry.ArchiveURL, + SourceKind: entry.SourceKind, + RepositoryName: entry.RepositoryDisplayName, + RepoURL: repoURL, + Presentation: presentation, Capabilities: capabilitiesToJSON(entry.Manifest.GetCapabilities()), GlobalConfigSchema: configSchemasToJSON(entry.Manifest.GetGlobalConfigSchema()), UserConfigSchema: configSchemasToJSON(entry.Manifest.GetUserConfigSchema()), @@ -1162,6 +1256,17 @@ func (h *PluginHandler) buildInstallationResponses( ctx context.Context, installations []*plugins.Installation, ) ([]pluginInstallationResponse, error) { + repositories, err := h.repositories.List(ctx) + if err != nil { + return nil, err + } + repositoriesByID := make(map[int]*plugins.Repository, len(repositories)) + for _, repository := range repositories { + if repository != nil { + repositoriesByID[repository.ID] = repository + } + } + authBindings, err := h.configs.ListAuthBindings(ctx) if err != nil { return nil, err @@ -1178,6 +1283,7 @@ func (h *PluginHandler) buildInstallationResponses( nil, authBindings, taskBindings, + repositoriesByID, ) if err != nil { return nil, err @@ -1192,6 +1298,17 @@ func (h *PluginHandler) buildInstallationResponse( installation *plugins.Installation, manifest *pluginv1.PluginManifest, ) (pluginInstallationResponse, error) { + repositoriesByID := make(map[int]*plugins.Repository, 1) + if installation.RepositoryID != nil { + repository, err := h.repositories.GetByID(ctx, *installation.RepositoryID) + if err != nil && !errors.Is(err, plugins.ErrRepositoryNotFound) { + return pluginInstallationResponse{}, err + } + if repository != nil { + repositoriesByID[repository.ID] = repository + } + } + authBindings, err := h.configs.ListAuthBindings(ctx) if err != nil { return pluginInstallationResponse{}, err @@ -1206,6 +1323,7 @@ func (h *PluginHandler) buildInstallationResponse( manifest, authBindings, taskBindings, + repositoriesByID, ) } @@ -1215,6 +1333,7 @@ func (h *PluginHandler) buildInstallationResponseWithBindings( manifest *pluginv1.PluginManifest, authBindings []*plugins.AuthBinding, taskBindings []*plugins.TaskBinding, + repositoriesByID map[int]*plugins.Repository, ) (pluginInstallationResponse, error) { if manifest == nil { var err error @@ -1239,7 +1358,18 @@ func (h *PluginHandler) buildInstallationResponseWithBindings( routes []pluginRouteJSON assets []pluginAssetJSON metadata map[string]any + sourceKind = plugins.RepositorySourceExternal + repositoryName string + updatesPaused bool ) + if installation.RepositoryID != nil { + repository := repositoriesByID[*installation.RepositoryID] + if repository != nil { + sourceKind = repository.SourceKind + repositoryName = repository.DisplayName + updatesPaused = repository.SourceKind == plugins.RepositorySourceApprovedCommunity && !repository.Enabled + } + } if manifest != nil { globalConfigSchema = configSchemasToJSON(manifest.GetGlobalConfigSchema()) userConfigSchema = configSchemasToJSON(manifest.GetUserConfigSchema()) @@ -1247,6 +1377,11 @@ func (h *PluginHandler) buildInstallationResponseWithBindings( assets = assetsToJSON(manifest.GetAssets()) metadata = structToMap(manifest.GetMetadata()) } + presentation := toPluginPresentationJSON(manifest.GetPresentation()) + repoURL := "" + if presentation != nil { + repoURL = presentation.SourceURL + } return pluginInstallationResponse{ ID: installation.ID, @@ -1257,6 +1392,11 @@ func (h *PluginHandler) buildInstallationResponseWithBindings( Enabled: installation.Enabled, UpdatePolicy: installation.UpdatePolicy, AvailableVersion: installation.AvailableVersion, + SourceKind: sourceKind, + RepositoryName: repositoryName, + RepoURL: repoURL, + Presentation: presentation, + UpdatesPaused: updatesPaused, Capabilities: capabilities, GlobalConfigSchema: globalConfigSchema, UserConfigSchema: userConfigSchema, @@ -1271,18 +1411,49 @@ func (h *PluginHandler) buildInstallationResponseWithBindings( }, nil } +func toPluginPresentationJSON(presentation *pluginv1.PluginPresentation) *pluginPresentationJSON { + if presentation == nil { + return nil + } + return &pluginPresentationJSON{ + DisplayName: presentation.GetDisplayName(), + Summary: presentation.GetSummary(), + DescriptionMarkdown: presentation.GetDescriptionMarkdown(), + SetupMarkdown: presentation.GetSetupMarkdown(), + HomepageURL: presentation.GetHomepageUrl(), + SourceURL: presentation.GetSourceUrl(), + SupportURL: presentation.GetSupportUrl(), + ChangelogURL: presentation.GetChangelogUrl(), + PublisherName: presentation.GetPublisherName(), + PublisherURL: presentation.GetPublisherUrl(), + LicenseSPDX: presentation.GetLicenseSpdx(), + } +} + func toPluginRepositoryResponse(repository *plugins.Repository) pluginRepositoryResponse { return pluginRepositoryResponse{ ID: repository.ID, URL: repository.URL, DisplayName: repository.DisplayName, Enabled: repository.Enabled, + SourceKind: repository.SourceKind, + Managed: repository.ManagedKey != nil, LastFetchedAt: repository.LastFetchedAt, CreatedAt: repository.CreatedAt, UpdatedAt: repository.UpdatedAt, } } +func toPluginCatalogSettingsResponse(settings plugins.CatalogSettings) pluginCatalogSettingsResponse { + return pluginCatalogSettingsResponse{ + IncludeApprovedCommunityPlugins: settings.IncludeApprovedCommunityPlugins, + ApprovedCommunityPluginCount: settings.ApprovedCommunityPluginCount, + InstalledCommunityPluginCount: settings.InstalledCommunityPluginCount, + MigratedPluginCount: settings.MigratedPluginCount, + CommunityUpdatesPaused: settings.CommunityUpdatesPaused, + } +} + func toUserPluginSettingsSummary( installation *plugins.Installation, manifest *pluginv1.PluginManifest, diff --git a/internal/api/handlers/plugins_presentation_test.go b/internal/api/handlers/plugins_presentation_test.go new file mode 100644 index 00000000..c08ff3d9 --- /dev/null +++ b/internal/api/handlers/plugins_presentation_test.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +func TestToPluginPresentationJSONPreservesOperatorMetadata(t *testing.T) { + t.Parallel() + + got := toPluginPresentationJSON(&pluginv1.PluginPresentation{ + DisplayName: "Example Plugin", + Summary: "Explains the example.", + DescriptionMarkdown: "Longer description.", + SetupMarkdown: "Configure the example.", + HomepageUrl: "https://example.com", + SourceUrl: "https://github.com/Silo-Server/example-plugin", + SupportUrl: "https://github.com/Silo-Server/example-plugin/issues", + ChangelogUrl: "https://github.com/Silo-Server/example-plugin/releases", + PublisherName: "Silo", + PublisherUrl: "https://github.com/Silo-Server", + LicenseSpdx: "AGPL-3.0-or-later", + }) + + if got == nil { + t.Fatal("toPluginPresentationJSON() = nil") + } + if got.DisplayName != "Example Plugin" || got.Summary != "Explains the example." { + t.Fatalf("identity fields = %#v", got) + } + if got.SourceURL != "https://github.com/Silo-Server/example-plugin" { + t.Fatalf("source_url = %q", got.SourceURL) + } + if got.ChangelogURL != "https://github.com/Silo-Server/example-plugin/releases" { + t.Fatalf("changelog_url = %q", got.ChangelogURL) + } +} + +func TestToPluginPresentationJSONKeepsLegacyManifestOptional(t *testing.T) { + t.Parallel() + + if got := toPluginPresentationJSON(nil); got != nil { + t.Fatalf("toPluginPresentationJSON(nil) = %#v, want nil", got) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index c841ffa8..7b4ffbfb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2598,6 +2598,8 @@ func NewRouter(deps Dependencies) chi.Router { restartStatus, ) r.Route("/plugins", func(r chi.Router) { + r.Get("/catalog-settings", pluginHandler.HandleGetCatalogSettings) + r.Put("/catalog-settings", pluginHandler.HandlePutCatalogSettings) r.Get("/repositories", pluginHandler.HandleListRepositories) r.Post("/repositories", pluginHandler.HandleCreateRepository) r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository) diff --git a/internal/plugins/auto_update.go b/internal/plugins/auto_update.go index 210f4467..408090e5 100644 --- a/internal/plugins/auto_update.go +++ b/internal/plugins/auto_update.go @@ -55,11 +55,6 @@ func compareVersions(a, b string) int { return 0 } -const ( - DefaultRepositoryURL = "https://raw.githubusercontent.com/Silo-Server/silo-plugins/main/manifest.json" - DefaultRepositoryName = "Silo Official Plugins" -) - var defaultPluginIDs = []string{"silo.tmdb", "silo.tvdb"} type autoUpdateRepositoryStore interface { @@ -67,6 +62,10 @@ type autoUpdateRepositoryStore interface { Create(ctx context.Context, input CreateRepositoryInput) (*Repository, error) } +type managedRepositoryReconciler interface { + ReconcileManaged(ctx context.Context) (ManagedRepositoryReconcileResult, error) +} + type autoUpdateInstallationStore interface { List(ctx context.Context) ([]*Installation, error) Update(ctx context.Context, id int, input UpdateInstallationInput) error @@ -105,8 +104,8 @@ type AutoUpdateSummary struct { Failures []string `json:"failures,omitempty"` } -// AutoUpdateService seeds the default plugin repository, auto-installs default -// plugins, and auto-updates installed plugins at server startup. +// AutoUpdateService reconciles managed plugin repositories, auto-installs +// default plugins, and auto-updates installed plugins at server startup. type AutoUpdateService struct { repositories autoUpdateRepositoryStore installations autoUpdateInstallationStore @@ -157,13 +156,11 @@ func (s *AutoUpdateService) Check(ctx context.Context, opts AutoUpdateOptions) ( var summary AutoUpdateSummary if opts.SeedDefaultRepository { - seeded, err := s.seedDefaultRepository(ctx) + seeded, err := s.ensureManagedRepositoryRows(ctx) if err != nil { return summary, err } - if seeded { - summary.RepositoriesSeeded++ - } + summary.RepositoriesSeeded += seeded } entries, err := s.catalog.Fetch(ctx) @@ -178,33 +175,29 @@ func (s *AutoUpdateService) Check(ctx context.Context, opts AutoUpdateOptions) ( } summary.InstalledPlugins = len(installed) - installedByPluginID := make(map[string]*Installation, len(installed)) + installedPluginIDs := make(map[string]struct{}, len(installed)) for _, inst := range installed { if inst == nil { continue } - installedByPluginID[inst.PluginID] = inst + installedPluginIDs[inst.PluginID] = struct{}{} } - latestByPluginID := latestCatalogEntries(entries) - for pluginID, entry := range latestByPluginID { - existing, isInstalled := installedByPluginID[pluginID] - - if !isInstalled { - if opts.AutoInstallDefaults { - installedDefault, err := s.handleNewPlugin(ctx, pluginID, entry) - if err != nil { - summary.recordFailure("auto-install default plugin %s: %v", pluginID, err) - } else if installedDefault { - summary.DefaultPluginsInstalled++ - } - } + latestByRepositoryPlugin := latestCatalogEntriesByRepository(entries) + for _, existing := range installed { + if existing == nil || existing.RepositoryID == nil { + continue + } + entry, ok := latestByRepositoryPlugin[repositoryPluginKey{ + RepositoryID: *existing.RepositoryID, + PluginID: existing.PluginID, + }] + if !ok { continue } - outcome, err := s.handleExistingPlugin(ctx, existing, entry) if err != nil { - summary.recordFailure("process plugin update %s: %v", pluginID, err) + summary.recordFailure("process plugin update %s: %v", existing.PluginID, err) continue } switch outcome { @@ -215,6 +208,25 @@ func (s *AutoUpdateService) Check(ctx context.Context, opts AutoUpdateOptions) ( } } + if opts.AutoInstallDefaults { + latestOfficial := latestCatalogEntriesForSource(entries, RepositorySourceSilo) + for _, pluginID := range defaultPluginIDs { + if _, installed := installedPluginIDs[pluginID]; installed { + continue + } + entry, ok := latestOfficial[pluginID] + if !ok { + continue + } + installedDefault, err := s.handleNewPlugin(ctx, pluginID, entry) + if err != nil { + summary.recordFailure("auto-install default plugin %s: %v", pluginID, err) + } else if installedDefault { + summary.DefaultPluginsInstalled++ + } + } + } + // Any of these outcomes wrote to a plugin_installations row: installing a // default plugin creates one, an applied auto-update rewrites the version- // specific InstallPath/Version (and deletes the old install dir), and a @@ -239,10 +251,10 @@ func (s *AutoUpdateService) notifyChanged(ctx context.Context) { s.onChange(ctx) } -// Run seeds the default repository if needed, fetches the catalog, auto-installs -// default plugins that are not yet installed, and processes updates for installed -// plugins according to their update policy. All errors are logged rather than -// returned so that startup is never blocked. +// Run reconciles managed repositories, fetches the catalog, auto-installs +// default plugins that are not yet installed, and processes updates for +// installed plugins according to their update policy. All errors are logged +// rather than returned so that startup is never blocked. func (s *AutoUpdateService) Run(ctx context.Context) error { summary, err := s.Check(ctx, AutoUpdateOptions{ SeedDefaultRepository: true, @@ -258,15 +270,28 @@ func (s *AutoUpdateService) Run(ctx context.Context) error { return nil } -// seedDefaultRepository creates the official plugin repository when no -// repositories are configured. -func (s *AutoUpdateService) seedDefaultRepository(ctx context.Context) (bool, error) { +// ensureManagedRepositoryRows reconciles built-in repositories. The fallback +// preserves the legacy fake-store contract used by isolated unit tests. +func (s *AutoUpdateService) ensureManagedRepositoryRows(ctx context.Context) (int, error) { + if reconciler, ok := s.repositories.(managedRepositoryReconciler); ok { + result, err := reconciler.ReconcileManaged(ctx) + if err != nil { + return 0, err + } + if result.RepositoriesCreated > 0 { + s.logger.InfoContext(ctx, "reconciled managed plugin repositories", + "repositories_created", result.RepositoriesCreated, + ) + } + return result.RepositoriesCreated, nil + } + repos, err := s.repositories.List(ctx) if err != nil { - return false, err + return 0, err } if len(repos) > 0 { - return false, nil + return 0, nil } enabled := true @@ -276,14 +301,14 @@ func (s *AutoUpdateService) seedDefaultRepository(ctx context.Context) (bool, er Enabled: &enabled, }) if err != nil { - return false, err + return 0, err } s.logger.InfoContext(ctx, "seeded default plugin repository", "url", DefaultRepositoryURL, "name", DefaultRepositoryName, ) - return true, nil + return 1, nil } // handleNewPlugin auto-installs a plugin if it is in the default plugin list. @@ -398,17 +423,39 @@ func (s *AutoUpdateService) notifyPluginUpdate(ctx context.Context, existing *In return nil } -// latestCatalogEntries returns a map from plugin ID to the catalog entry with -// the highest version string for that plugin. -func latestCatalogEntries(entries []CatalogEntry) map[string]CatalogEntry { - latest := make(map[string]CatalogEntry, len(entries)) +type repositoryPluginKey struct { + RepositoryID int + PluginID string +} + +func latestCatalogEntriesByRepository(entries []CatalogEntry) map[repositoryPluginKey]CatalogEntry { + latest := make(map[repositoryPluginKey]CatalogEntry, len(entries)) for _, entry := range entries { + if entry.Manifest == nil { + continue + } pluginID := entry.Manifest.GetPluginId() - if existing, ok := latest[pluginID]; ok { + key := repositoryPluginKey{RepositoryID: entry.RepositoryID, PluginID: pluginID} + if existing, ok := latest[key]; ok { if compareVersions(entry.Manifest.GetVersion(), existing.Manifest.GetVersion()) <= 0 { continue } } + latest[key] = entry + } + return latest +} + +func latestCatalogEntriesForSource(entries []CatalogEntry, sourceKind string) map[string]CatalogEntry { + latest := make(map[string]CatalogEntry, len(entries)) + for _, entry := range entries { + if entry.Manifest == nil || entry.SourceKind != sourceKind { + continue + } + pluginID := entry.Manifest.GetPluginId() + if existing, ok := latest[pluginID]; ok && compareVersions(entry.Manifest.GetVersion(), existing.Manifest.GetVersion()) <= 0 { + continue + } latest[pluginID] = entry } return latest diff --git a/internal/plugins/auto_update_replace_test.go b/internal/plugins/auto_update_replace_test.go index 8e34645d..8cf378e5 100644 --- a/internal/plugins/auto_update_replace_test.go +++ b/internal/plugins/auto_update_replace_test.go @@ -10,7 +10,7 @@ import ( func TestAutoUpdateServiceCheckReplacesInstalledPluginsInPlace(t *testing.T) { installations := &fakeAutoUpdateInstallations{ list: []*Installation{ - {ID: 41, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}, + {ID: 41, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}, }, } host := &fakeAutoUpdateHost{} @@ -96,7 +96,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) { t.Run("applied auto-update fires onChange", func(t *testing.T) { var calls int installations := &fakeAutoUpdateInstallations{ - list: []*Installation{{ID: 41, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}}, + list: []*Installation{{ID: 41, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}}, } summary, err := newOnChangeService(installations, AutoUpdateOptions{}, &calls) if err != nil { @@ -113,7 +113,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) { t.Run("notify update fires onChange", func(t *testing.T) { var calls int installations := &fakeAutoUpdateInstallations{ - list: []*Installation{{ID: 42, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "notify", Enabled: true}}, + list: []*Installation{{ID: 42, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "notify", Enabled: true}}, } summary, err := newOnChangeService(installations, AutoUpdateOptions{}, &calls) if err != nil { @@ -130,7 +130,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) { t.Run("no mutation does not fire onChange", func(t *testing.T) { var calls int installations := &fakeAutoUpdateInstallations{ - list: []*Installation{{ID: 43, PluginID: "silo.tmdb", Version: "1.1.0", UpdatePolicy: "auto", Enabled: true}}, + list: []*Installation{{ID: 43, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.1.0", UpdatePolicy: "auto", Enabled: true}}, } summary, err := newOnChangeService(installations, AutoUpdateOptions{}, &calls) if err != nil { @@ -146,7 +146,7 @@ func TestAutoUpdateServiceCheckFiresOnChangeAfterMutation(t *testing.T) { t.Run("nil onChange is safe after mutation", func(t *testing.T) { installations := &fakeAutoUpdateInstallations{ - list: []*Installation{{ID: 44, PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}}, + list: []*Installation{{ID: 44, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.0.0", UpdatePolicy: "auto", Enabled: true}}, } service := NewAutoUpdateService( &fakeAutoUpdateRepositories{list: []*Repository{{ID: 7, Enabled: true}}}, @@ -197,7 +197,7 @@ func TestCompareVersions(t *testing.T) { func TestAutoUpdateMultiDigitVersion(t *testing.T) { installations := &fakeAutoUpdateInstallations{ - list: []*Installation{{ID: 50, PluginID: "silo.tmdb", Version: "1.2.9", UpdatePolicy: "auto", Enabled: true}}, + list: []*Installation{{ID: 50, RepositoryID: pluginRepositoryID(7), PluginID: "silo.tmdb", Version: "1.2.9", UpdatePolicy: "auto", Enabled: true}}, } host := &fakeAutoUpdateHost{} installer := &fakeAutoUpdateInstaller{} @@ -237,6 +237,79 @@ func TestAutoUpdateMultiDigitVersion(t *testing.T) { } } +func TestAutoUpdateServiceKeepsUpdatesPinnedToInstallationRepository(t *testing.T) { + installations := &fakeAutoUpdateInstallations{ + list: []*Installation{{ + ID: 60, + RepositoryID: pluginRepositoryID(7), + PluginID: "silo.requests.arr", + Version: "1.0.0", + UpdatePolicy: "auto", + Enabled: true, + }}, + } + catalog := &fakeAutoUpdateCatalog{ + entries: []CatalogEntry{ + {RepositoryID: 7, Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "1.1.0"}}, + {RepositoryID: 8, Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "9.0.0"}}, + }, + resolved: &ResolvedCatalogInstall{RepositoryID: 7, ArchiveURL: "https://plugins.example.test/arr", Checksum: "deadbeef"}, + } + service := NewAutoUpdateService( + &fakeAutoUpdateRepositories{list: []*Repository{{ID: 7, Enabled: true}, {ID: 8, Enabled: true}}}, + installations, + catalog, + &fakeAutoUpdateInstaller{}, + &fakeAutoUpdateHost{}, + nil, + nil, + ) + + summary, err := service.Check(context.Background(), AutoUpdateOptions{}) + if err != nil { + t.Fatalf("Check() returned error: %v", err) + } + if summary.UpdatesApplied != 1 { + t.Fatalf("UpdatesApplied = %d, want 1", summary.UpdatesApplied) + } + if len(catalog.resolveRequests) != 1 { + t.Fatalf("resolve requests = %d, want 1", len(catalog.resolveRequests)) + } + request := catalog.resolveRequests[0] + if request.RepositoryID != 7 || request.Version != "1.1.0" { + t.Fatalf("resolved repository/version = %d/%s, want 7/1.1.0", request.RepositoryID, request.Version) + } +} + +func TestAutoUpdateServiceDoesNotAttachUploadsToCatalogRepositories(t *testing.T) { + service := NewAutoUpdateService( + &fakeAutoUpdateRepositories{list: []*Repository{{ID: 7, Enabled: true}}}, + &fakeAutoUpdateInstallations{list: []*Installation{{ + ID: 61, + PluginID: "silo.requests.arr", + Version: "1.0.0", + UpdatePolicy: "auto", + Enabled: true, + }}}, + &fakeAutoUpdateCatalog{entries: []CatalogEntry{{ + RepositoryID: 7, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "2.0.0"}, + }}}, + &fakeAutoUpdateInstaller{}, + &fakeAutoUpdateHost{}, + nil, + nil, + ) + + summary, err := service.Check(context.Background(), AutoUpdateOptions{}) + if err != nil { + t.Fatalf("Check() returned error: %v", err) + } + if summary.UpdatesApplied != 0 || summary.UpdatesAvailable != 0 { + t.Fatalf("upload unexpectedly received catalog update: %+v", summary) + } +} + type fakeAutoUpdateRepositories struct { list []*Repository } @@ -277,18 +350,24 @@ func (f *fakeAutoUpdateInstallations) Delete(_ context.Context, id int) error { } type fakeAutoUpdateCatalog struct { - entries []CatalogEntry - resolved *ResolvedCatalogInstall + entries []CatalogEntry + resolved *ResolvedCatalogInstall + resolveRequests []InstallCatalogRequest } func (f *fakeAutoUpdateCatalog) Fetch(context.Context) ([]CatalogEntry, error) { return f.entries, nil } -func (f *fakeAutoUpdateCatalog) ResolveInstall(_ context.Context, _ InstallCatalogRequest) (*ResolvedCatalogInstall, error) { +func (f *fakeAutoUpdateCatalog) ResolveInstall(_ context.Context, request InstallCatalogRequest) (*ResolvedCatalogInstall, error) { + f.resolveRequests = append(f.resolveRequests, request) return f.resolved, nil } +func pluginRepositoryID(id int) *int { + return &id +} + type replaceBinaryCall struct { existingID int req InstallBinaryRequest diff --git a/internal/plugins/catalog_channels.go b/internal/plugins/catalog_channels.go new file mode 100644 index 00000000..8e05df20 --- /dev/null +++ b/internal/plugins/catalog_channels.go @@ -0,0 +1,51 @@ +package plugins + +const ( + DefaultRepositoryURL = "https://raw.githubusercontent.com/Silo-Server/silo-plugins/main/manifest.json" + DefaultRepositoryName = "Silo maintained" + + ApprovedCommunityRepositoryURL = "https://raw.githubusercontent.com/Silo-Community/silo-plugins/main/manifest.json" + ApprovedCommunityRepositoryName = "Approved community" + + OfficialRepositoryManagedKey = "official" + ApprovedCommunityRepositoryManagedKey = "approved-community" + + RepositorySourceSilo = "silo" + RepositorySourceApprovedCommunity = "approved_community" + RepositorySourceExternal = "external" + + IncludeApprovedCommunityPluginsSetting = "plugins.include_approved_community_plugins" + MigratedApprovedCommunityCountSetting = "plugins.approved_community_migrated_plugin_count" +) + +type managedRepositoryDefinition struct { + Key string + URL string + DisplayName string + SourceKind string +} + +var managedRepositoryDefinitions = []managedRepositoryDefinition{ + { + Key: OfficialRepositoryManagedKey, + URL: DefaultRepositoryURL, + DisplayName: DefaultRepositoryName, + SourceKind: RepositorySourceSilo, + }, + { + Key: ApprovedCommunityRepositoryManagedKey, + URL: ApprovedCommunityRepositoryURL, + DisplayName: ApprovedCommunityRepositoryName, + SourceKind: RepositorySourceApprovedCommunity, + }, +} + +var approvedCommunityPluginIDs = map[string]struct{}{ + "silo.requests.arr": {}, + "silo.requests.seerr": {}, +} + +func isApprovedCommunityPlugin(pluginID string) bool { + _, ok := approvedCommunityPluginIDs[pluginID] + return ok +} diff --git a/internal/plugins/catalog_community_migration_test.go b/internal/plugins/catalog_community_migration_test.go new file mode 100644 index 00000000..ed5227c1 --- /dev/null +++ b/internal/plugins/catalog_community_migration_test.go @@ -0,0 +1,50 @@ +package plugins + +import ( + "os" + "strings" + "testing" +) + +func TestApprovedCommunityMigrationKeepsFreshInstallsOffAndScopesLegacyMoves(t *testing.T) { + data, err := os.ReadFile("../../migrations/sql/20260709191109_plugin_catalog_community_migration.sql") + if err != nil { + t.Fatalf("read approved community migration: %v", err) + } + sql := string(data) + + required := []string{ + "EXISTS (SELECT 1 FROM public.users)", + "OR EXISTS (SELECT 1 FROM public.plugin_repositories)", + "OR EXISTS (SELECT 1 FROM public.plugin_installations)", + "CASE WHEN legacy_instance THEN 'true' ELSE 'false' END", + "'plugins.include_approved_community_plugins'", + "'approved-community'", + "'approved_community'", + "WHERE repository_id = official_repository_id", + "plugin_id IN ('silo.requests.arr', 'silo.requests.seerr')", + "available_version = NULL", + } + for _, fragment := range required { + if !strings.Contains(sql, fragment) { + t.Fatalf("migration missing %q", fragment) + } + } + + legacyCheck := strings.Index(sql, "legacy_instance BOOLEAN :=") + officialInsert := strings.Index(sql, "INSERT INTO public.plugin_repositories") + if legacyCheck < 0 || officialInsert < 0 || legacyCheck > officialInsert { + t.Fatal("legacy-instance state must be captured before managed repositories are inserted") + } + + for _, dependentTable := range []string{ + "plugin_runtime_configs", + "plugin_task_bindings", + "plugin_auth_bindings", + "plugin_capabilities", + } { + if strings.Contains(sql, "UPDATE public."+dependentTable) || strings.Contains(sql, "DELETE FROM public."+dependentTable) { + t.Fatalf("migration must preserve installation-owned data in %s", dependentTable) + } + } +} diff --git a/internal/plugins/catalog_discovery_test.go b/internal/plugins/catalog_discovery_test.go new file mode 100644 index 00000000..d38b2936 --- /dev/null +++ b/internal/plugins/catalog_discovery_test.go @@ -0,0 +1,83 @@ +package plugins + +import ( + "testing" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" +) + +func TestCatalogEntriesForDiscoveryUsesApprovedCommunityOwnerForTransferredPlugins(t *testing.T) { + entries := []CatalogEntry{ + { + RepositoryID: 1, + SourceKind: RepositorySourceSilo, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "0.1.0"}, + }, + { + RepositoryID: 2, + SourceKind: RepositorySourceApprovedCommunity, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.arr", Version: "0.1.1"}, + }, + } + + result := catalogEntriesForDiscovery(entries) + if len(result) != 1 { + t.Fatalf("catalog entries = %d, want 1", len(result)) + } + if result[0].RepositoryID != 2 || result[0].Manifest.GetVersion() != "0.1.1" { + t.Fatalf("selected repository/version = %d/%s, want 2/0.1.1", result[0].RepositoryID, result[0].Manifest.GetVersion()) + } +} + +func TestCatalogEntriesForDiscoveryHidesTransferredPluginWhenCommunityIsDisabled(t *testing.T) { + result := catalogEntriesForDiscovery([]CatalogEntry{{ + RepositoryID: 1, + SourceKind: RepositorySourceSilo, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.requests.seerr", Version: "0.1.0"}, + }}) + if len(result) != 0 { + t.Fatalf("catalog entries = %d, want transferred plugin hidden", len(result)) + } +} + +func TestCatalogEntriesForDiscoveryPreventsExternalSourceShadowing(t *testing.T) { + result := catalogEntriesForDiscovery([]CatalogEntry{ + { + RepositoryID: 9, + SourceKind: RepositorySourceExternal, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "99.0.0"}, + }, + { + RepositoryID: 1, + SourceKind: RepositorySourceSilo, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "1.0.0"}, + }, + }) + if len(result) != 1 { + t.Fatalf("catalog entries = %d, want 1", len(result)) + } + if result[0].RepositoryID != 1 { + t.Fatalf("selected repository = %d, want official repository 1", result[0].RepositoryID) + } +} + +func TestCatalogEntriesForDiscoveryShowsLatestVersionFromSelectedRepository(t *testing.T) { + result := catalogEntriesForDiscovery([]CatalogEntry{ + { + RepositoryID: 1, + SourceKind: RepositorySourceSilo, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "1.2.9"}, + }, + { + RepositoryID: 1, + SourceKind: RepositorySourceSilo, + Manifest: &pluginv1.PluginManifest{PluginId: "silo.tmdb", Version: "1.2.10"}, + }, + }) + if len(result) != 1 { + t.Fatalf("catalog entries = %d, want 1", len(result)) + } + if result[0].Manifest.GetVersion() != "1.2.10" { + t.Fatalf("selected version = %s, want 1.2.10", result[0].Manifest.GetVersion()) + } +} diff --git a/internal/plugins/catalog_service.go b/internal/plugins/catalog_service.go index 688d0404..639eb6ee 100644 --- a/internal/plugins/catalog_service.go +++ b/internal/plugins/catalog_service.go @@ -25,6 +25,7 @@ type PlatformBinary struct { type CatalogPackage struct { Manifest *pluginv1.PluginManifest `json:"manifest"` + RepoURL string `json:"repo_url,omitempty"` ArchiveURL string `json:"archive_url,omitempty"` ChecksumsURL string `json:"checksums_url,omitempty"` Binaries map[string]PlatformBinary `json:"binaries,omitempty"` @@ -35,10 +36,13 @@ type RepositoryIndex struct { } type CatalogEntry struct { - RepositoryID int - Manifest *pluginv1.PluginManifest - ArchiveURL string - Checksum string + RepositoryID int + RepositoryDisplayName string + SourceKind string + Manifest *pluginv1.PluginManifest + RepoURL string + ArchiveURL string + Checksum string } type InstallCatalogRequest struct { @@ -141,7 +145,7 @@ func (s *CatalogService) Fetch(ctx context.Context) ([]CatalogEntry, error) { continue } - key := capabilityKey(entry.Manifest.GetPluginId(), entry.Manifest.GetVersion()) + key := fmt.Sprintf("%d:%s", entry.RepositoryID, capabilityKey(entry.Manifest.GetPluginId(), entry.Manifest.GetVersion())) if _, exists := catalogByVersion[key]; exists { continue } @@ -310,10 +314,13 @@ func (s *CatalogService) catalogEntryFromPackage(repository *Repository, pkg Cat } return CatalogEntry{ - RepositoryID: repository.ID, - Manifest: pkg.Manifest, - ArchiveURL: resolvedURL, - Checksum: checksum, + RepositoryID: repository.ID, + RepositoryDisplayName: repository.DisplayName, + SourceKind: repository.SourceKind, + Manifest: pkg.Manifest, + RepoURL: pkg.RepoURL, + ArchiveURL: resolvedURL, + Checksum: checksum, }, true, nil } @@ -332,9 +339,12 @@ func (s *CatalogService) catalogEntryFromPackage(repository *Repository, pkg Cat return CatalogEntry{}, false, err } return CatalogEntry{ - RepositoryID: repository.ID, - Manifest: pkg.Manifest, - ArchiveURL: resolvedURL, + RepositoryID: repository.ID, + RepositoryDisplayName: repository.DisplayName, + SourceKind: repository.SourceKind, + Manifest: pkg.Manifest, + RepoURL: pkg.RepoURL, + ArchiveURL: resolvedURL, }, true, nil } diff --git a/internal/plugins/catalog_service_presentation_test.go b/internal/plugins/catalog_service_presentation_test.go new file mode 100644 index 00000000..027e50db --- /dev/null +++ b/internal/plugins/catalog_service_presentation_test.go @@ -0,0 +1,55 @@ +package plugins + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchRepositoryIndexPreservesPresentationAndRepositoryURL(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "plugins": [{ + "repo_url": "https://github.com/Silo-Server/example-plugin", + "manifest": { + "plugin_id": "silo.example", + "version": "1.0.0", + "presentation": { + "display_name": "Example Plugin", + "summary": "Explains the example.", + "source_url": "https://github.com/Silo-Server/example-plugin", + "changelog_url": "https://github.com/Silo-Server/example-plugin/releases" + } + } + }] + }`)) + })) + defer server.Close() + + service := NewCatalogService(nil, CatalogServiceOptions{HTTPClient: server.Client()}) + index, err := service.fetchRepositoryIndex(t.Context(), server.URL) + if err != nil { + t.Fatalf("fetchRepositoryIndex() error = %v", err) + } + if len(index.Plugins) != 1 { + t.Fatalf("plugins length = %d, want 1", len(index.Plugins)) + } + + plugin := index.Plugins[0] + if plugin.RepoURL != "https://github.com/Silo-Server/example-plugin" { + t.Fatalf("repo_url = %q", plugin.RepoURL) + } + presentation := plugin.Manifest.GetPresentation() + if presentation.GetDisplayName() != "Example Plugin" { + t.Fatalf("display_name = %q", presentation.GetDisplayName()) + } + if presentation.GetSummary() != "Explains the example." { + t.Fatalf("summary = %q", presentation.GetSummary()) + } + if presentation.GetChangelogUrl() != "https://github.com/Silo-Server/example-plugin/releases" { + t.Fatalf("changelog_url = %q", presentation.GetChangelogUrl()) + } +} diff --git a/internal/plugins/catalog_settings.go b/internal/plugins/catalog_settings.go new file mode 100644 index 00000000..a9aa5125 --- /dev/null +++ b/internal/plugins/catalog_settings.go @@ -0,0 +1,185 @@ +package plugins + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type CatalogSettings struct { + IncludeApprovedCommunityPlugins bool + ApprovedCommunityPluginCount int + InstalledCommunityPluginCount int + MigratedPluginCount int + CommunityUpdatesPaused bool +} + +type ManagedRepositoryReconcileResult struct { + RepositoriesCreated int +} + +func (s *RepositoryStore) ReconcileManaged(ctx context.Context) (ManagedRepositoryReconcileResult, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return ManagedRepositoryReconcileResult{}, fmt.Errorf("begin managed plugin repository reconciliation: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, ` + INSERT INTO server_settings (key, value) + VALUES ($1, 'false') + ON CONFLICT (key) DO NOTHING + `, IncludeApprovedCommunityPluginsSetting); err != nil { + return ManagedRepositoryReconcileResult{}, fmt.Errorf("seed approved community plugin setting: %w", err) + } + + includeCommunity, err := readIncludeApprovedCommunityForUpdate(ctx, tx) + if err != nil { + return ManagedRepositoryReconcileResult{}, err + } + created, err := reconcileManagedRepositories(ctx, tx, includeCommunity) + if err != nil { + return ManagedRepositoryReconcileResult{}, err + } + + if err := tx.Commit(ctx); err != nil { + return ManagedRepositoryReconcileResult{}, fmt.Errorf("commit managed plugin repository reconciliation: %w", err) + } + return ManagedRepositoryReconcileResult{RepositoriesCreated: created}, nil +} + +func (s *RepositoryStore) GetCatalogSettings(ctx context.Context) (CatalogSettings, error) { + includeCommunity, err := readIncludeApprovedCommunity(ctx, s.pool) + if err != nil { + return CatalogSettings{}, err + } + + var installedCommunityCount int + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM plugin_installations AS installation + JOIN plugin_repositories AS repository ON repository.id = installation.repository_id + WHERE repository.source_kind = $1 + `, RepositorySourceApprovedCommunity).Scan(&installedCommunityCount); err != nil { + return CatalogSettings{}, fmt.Errorf("count installed approved community plugins: %w", err) + } + + migratedPluginCount, err := readIntegerSetting(ctx, s.pool, MigratedApprovedCommunityCountSetting) + if err != nil { + return CatalogSettings{}, err + } + + return CatalogSettings{ + IncludeApprovedCommunityPlugins: includeCommunity, + ApprovedCommunityPluginCount: len(approvedCommunityPluginIDs), + InstalledCommunityPluginCount: installedCommunityCount, + MigratedPluginCount: migratedPluginCount, + CommunityUpdatesPaused: !includeCommunity && installedCommunityCount > 0, + }, nil +} + +func (s *RepositoryStore) SetIncludeApprovedCommunity(ctx context.Context, include bool) (CatalogSettings, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return CatalogSettings{}, fmt.Errorf("begin approved community plugin setting update: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, ` + INSERT INTO server_settings (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `, IncludeApprovedCommunityPluginsSetting, strconv.FormatBool(include)); err != nil { + return CatalogSettings{}, fmt.Errorf("update approved community plugin setting: %w", err) + } + + if _, err := reconcileManagedRepositories(ctx, tx, include); err != nil { + return CatalogSettings{}, err + } + if err := tx.Commit(ctx); err != nil { + return CatalogSettings{}, fmt.Errorf("commit approved community plugin setting update: %w", err) + } + + return s.GetCatalogSettings(ctx) +} + +type catalogSettingsQuerier interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +type catalogSettingsExecutor interface { + catalogSettingsQuerier + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +func readIncludeApprovedCommunity(ctx context.Context, querier catalogSettingsQuerier) (bool, error) { + return readIncludeApprovedCommunityQuery(ctx, querier, `SELECT value FROM server_settings WHERE key = $1`) +} + +func readIncludeApprovedCommunityForUpdate(ctx context.Context, querier catalogSettingsQuerier) (bool, error) { + return readIncludeApprovedCommunityQuery(ctx, querier, `SELECT value FROM server_settings WHERE key = $1 FOR UPDATE`) +} + +func readIncludeApprovedCommunityQuery(ctx context.Context, querier catalogSettingsQuerier, query string) (bool, error) { + var value string + err := querier.QueryRow(ctx, query, IncludeApprovedCommunityPluginsSetting).Scan(&value) + if err != nil { + if err == pgx.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("read approved community plugin setting: %w", err) + } + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return false, nil + } + return parsed, nil +} + +func readIntegerSetting(ctx context.Context, querier catalogSettingsQuerier, key string) (int, error) { + var value string + err := querier.QueryRow(ctx, `SELECT value FROM server_settings WHERE key = $1`, key).Scan(&value) + if err != nil { + if err == pgx.ErrNoRows { + return 0, nil + } + return 0, fmt.Errorf("read plugin setting %q: %w", key, err) + } + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed < 0 { + return 0, nil + } + return parsed, nil +} + +func reconcileManagedRepositories(ctx context.Context, executor catalogSettingsExecutor, includeCommunity bool) (int, error) { + created := 0 + for _, definition := range managedRepositoryDefinitions { + var exists bool + if err := executor.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM plugin_repositories WHERE url = $1)`, definition.URL).Scan(&exists); err != nil { + return 0, fmt.Errorf("check managed plugin repository %q: %w", definition.Key, err) + } + + enabled := definition.Key == OfficialRepositoryManagedKey || includeCommunity + if _, err := executor.Exec(ctx, ` + INSERT INTO plugin_repositories (url, display_name, enabled, managed_key, source_kind) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (url) DO UPDATE SET + display_name = EXCLUDED.display_name, + enabled = EXCLUDED.enabled, + managed_key = EXCLUDED.managed_key, + source_kind = EXCLUDED.source_kind, + updated_at = NOW() + `, definition.URL, definition.DisplayName, enabled, definition.Key, definition.SourceKind); err != nil { + return 0, fmt.Errorf("reconcile managed plugin repository %q: %w", definition.Key, err) + } + if !exists { + created++ + } + } + return created, nil +} diff --git a/internal/plugins/repository.go b/internal/plugins/repository.go index 18852e20..99428209 100644 --- a/internal/plugins/repository.go +++ b/internal/plugins/repository.go @@ -12,12 +12,15 @@ import ( ) var ErrRepositoryNotFound = errors.New("plugin repository not found") +var ErrManagedRepositoryReadOnly = errors.New("managed plugin repository is read-only") type Repository struct { ID int URL string DisplayName string Enabled bool + ManagedKey *string + SourceKind string LastFetchedAt *time.Time CreatedAt time.Time UpdatedAt time.Time @@ -44,7 +47,7 @@ func NewRepositoryStore(pool *pgxpool.Pool) *RepositoryStore { return &RepositoryStore{pool: pool} } -const repositoryColumns = `id, url, display_name, enabled, last_fetched_at, created_at, updated_at` +const repositoryColumns = `id, url, display_name, enabled, managed_key, source_kind, last_fetched_at, created_at, updated_at` func scanRepository(row pgx.Row) (*Repository, error) { var repository Repository @@ -54,6 +57,8 @@ func scanRepository(row pgx.Row) (*Repository, error) { &repository.URL, &repository.DisplayName, &repository.Enabled, + &repository.ManagedKey, + &repository.SourceKind, &lastFetchedAt, &repository.CreatedAt, &repository.UpdatedAt, @@ -111,6 +116,16 @@ func (s *RepositoryStore) List(ctx context.Context) ([]*Repository, error) { } func (s *RepositoryStore) Update(ctx context.Context, id int, input UpdateRepositoryInput) error { + if input.URL != nil || input.DisplayName != nil || input.Enabled != nil { + repository, err := s.GetByID(ctx, id) + if err != nil { + return err + } + if repository.ManagedKey != nil { + return ErrManagedRepositoryReadOnly + } + } + var setClauses []string var args []any argIndex := 1 @@ -160,6 +175,14 @@ func (s *RepositoryStore) Update(ctx context.Context, id int, input UpdateReposi } func (s *RepositoryStore) Delete(ctx context.Context, id int) error { + repository, err := s.GetByID(ctx, id) + if err != nil { + return err + } + if repository.ManagedKey != nil { + return ErrManagedRepositoryReadOnly + } + tag, err := s.pool.Exec(ctx, `DELETE FROM plugin_repositories WHERE id = $1`, id) if err != nil { return fmt.Errorf("deleting plugin repository: %w", err) diff --git a/internal/plugins/service.go b/internal/plugins/service.go index 78f75127..2c3fe1ea 100644 --- a/internal/plugins/service.go +++ b/internal/plugins/service.go @@ -10,7 +10,9 @@ import ( "os" "path/filepath" "runtime/debug" + "slices" "strconv" + "strings" "sync" "sync/atomic" @@ -156,7 +158,61 @@ func NewService( } func (s *Service) FetchCatalog(ctx context.Context) ([]CatalogEntry, error) { - return s.catalog.Fetch(ctx) + entries, err := s.catalog.Fetch(ctx) + if err != nil { + return nil, err + } + return catalogEntriesForDiscovery(entries), nil +} + +func catalogEntriesForDiscovery(entries []CatalogEntry) []CatalogEntry { + selected := make(map[string]CatalogEntry, len(entries)) + for _, entry := range entries { + if entry.Manifest == nil { + continue + } + pluginID := entry.Manifest.GetPluginId() + if isApprovedCommunityPlugin(pluginID) && entry.SourceKind != RepositorySourceApprovedCommunity { + continue + } + + existing, ok := selected[pluginID] + if !ok || catalogEntryPreferredForDiscovery(entry, existing) { + selected[pluginID] = entry + } + } + + result := make([]CatalogEntry, 0, len(selected)) + for _, entry := range selected { + result = append(result, entry) + } + slices.SortFunc(result, func(left, right CatalogEntry) int { + return strings.Compare(left.Manifest.GetPluginId(), right.Manifest.GetPluginId()) + }) + return result +} + +func catalogEntryPreferredForDiscovery(candidate, current CatalogEntry) bool { + candidatePrecedence := repositorySourcePrecedence(candidate.SourceKind) + currentPrecedence := repositorySourcePrecedence(current.SourceKind) + if candidatePrecedence != currentPrecedence { + return candidatePrecedence < currentPrecedence + } + if candidate.RepositoryID != current.RepositoryID { + return candidate.RepositoryID < current.RepositoryID + } + return compareVersions(candidate.Manifest.GetVersion(), current.Manifest.GetVersion()) > 0 +} + +func repositorySourcePrecedence(sourceKind string) int { + switch sourceKind { + case RepositorySourceSilo: + return 0 + case RepositorySourceApprovedCommunity: + return 1 + default: + return 2 + } } func (s *Service) InstallLocal(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) { diff --git a/migrations/sql/20260709191109_plugin_catalog_community_migration.sql b/migrations/sql/20260709191109_plugin_catalog_community_migration.sql new file mode 100644 index 00000000..6c7da942 --- /dev/null +++ b/migrations/sql/20260709191109_plugin_catalog_community_migration.sql @@ -0,0 +1,146 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE public.plugin_repositories + ADD COLUMN managed_key TEXT, + ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'external'; + +ALTER TABLE public.plugin_repositories + ADD CONSTRAINT plugin_repositories_source_kind_check + CHECK (source_kind IN ('silo', 'approved_community', 'external')); + +CREATE UNIQUE INDEX plugin_repositories_managed_key_unique + ON public.plugin_repositories (managed_key) + WHERE managed_key IS NOT NULL; + +DO $$ +DECLARE + legacy_instance BOOLEAN := + EXISTS (SELECT 1 FROM public.users) + OR EXISTS (SELECT 1 FROM public.plugin_repositories) + OR EXISTS (SELECT 1 FROM public.plugin_installations); + include_community BOOLEAN; + official_repository_id BIGINT; + community_repository_id BIGINT; + migrated_plugin_count INTEGER := 0; +BEGIN + -- Any persisted account or plugin state is durable evidence that Silo has + -- run before. Existing servers are opted into the new channel, while a + -- truly fresh database remains default-off. + INSERT INTO public.server_settings (key, value) + VALUES ( + 'plugins.include_approved_community_plugins', + CASE WHEN legacy_instance THEN 'true' ELSE 'false' END + ) + ON CONFLICT (key) DO NOTHING; + + SELECT LOWER(TRIM(value)) = 'true' + INTO include_community + FROM public.server_settings + WHERE key = 'plugins.include_approved_community_plugins'; + + include_community := COALESCE(include_community, false); + + INSERT INTO public.plugin_repositories ( + url, + enabled, + display_name, + managed_key, + source_kind + ) VALUES ( + 'https://raw.githubusercontent.com/Silo-Server/silo-plugins/main/manifest.json', + true, + 'Silo maintained', + 'official', + 'silo' + ) + ON CONFLICT (url) DO UPDATE SET + enabled = true, + display_name = EXCLUDED.display_name, + managed_key = EXCLUDED.managed_key, + source_kind = EXCLUDED.source_kind, + updated_at = NOW() + RETURNING id INTO official_repository_id; + + INSERT INTO public.plugin_repositories ( + url, + enabled, + display_name, + managed_key, + source_kind + ) VALUES ( + 'https://raw.githubusercontent.com/Silo-Community/silo-plugins/main/manifest.json', + include_community, + 'Approved community', + 'approved-community', + 'approved_community' + ) + ON CONFLICT (url) DO UPDATE SET + enabled = EXCLUDED.enabled, + display_name = EXCLUDED.display_name, + managed_key = EXCLUDED.managed_key, + source_kind = EXCLUDED.source_kind, + updated_at = NOW() + RETURNING id INTO community_repository_id; + + -- Preserve the installation row and every dependent configuration/binding + -- row. Only installations that came from Silo's managed catalog move; + -- uploads and custom repositories with the same plugin IDs are untouched. + UPDATE public.plugin_installations + SET repository_id = community_repository_id, + available_version = NULL, + updated_at = NOW() + WHERE repository_id = official_repository_id + AND plugin_id IN ('silo.requests.arr', 'silo.requests.seerr'); + + GET DIAGNOSTICS migrated_plugin_count = ROW_COUNT; + + INSERT INTO public.server_settings (key, value) + VALUES ('plugins.approved_community_migrated_plugin_count', migrated_plugin_count::TEXT) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; +END $$; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DO $$ +DECLARE + official_repository_id BIGINT; + community_repository_id BIGINT; +BEGIN + SELECT id INTO official_repository_id + FROM public.plugin_repositories + WHERE managed_key = 'official'; + + SELECT id INTO community_repository_id + FROM public.plugin_repositories + WHERE managed_key = 'approved-community'; + + IF official_repository_id IS NOT NULL AND community_repository_id IS NOT NULL THEN + UPDATE public.plugin_installations + SET repository_id = official_repository_id, + available_version = NULL, + updated_at = NOW() + WHERE repository_id = community_repository_id + AND plugin_id IN ('silo.requests.arr', 'silo.requests.seerr'); + END IF; +END $$; + +DELETE FROM public.server_settings +WHERE key IN ( + 'plugins.include_approved_community_plugins', + 'plugins.approved_community_migrated_plugin_count' +); + +UPDATE public.plugin_repositories +SET managed_key = NULL, + source_kind = 'external', + updated_at = NOW() +WHERE managed_key IN ('official', 'approved-community'); + +DROP INDEX IF EXISTS public.plugin_repositories_managed_key_unique; + +ALTER TABLE public.plugin_repositories + DROP CONSTRAINT IF EXISTS plugin_repositories_source_kind_check, + DROP COLUMN IF EXISTS source_kind, + DROP COLUMN IF EXISTS managed_key; +-- +goose StatementEnd diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 865d29d1..85c11dd6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3312,16 +3312,36 @@ export interface PluginRepository { url: string; display_name: string; enabled: boolean; + source_kind: "silo" | "approved_community" | "external"; + managed: boolean; last_fetched_at?: string | null; created_at?: string; updated_at?: string; } +export interface PluginPresentation { + display_name: string; + summary: string; + description_markdown: string; + setup_markdown: string; + homepage_url: string; + source_url: string; + support_url: string; + changelog_url: string; + publisher_name: string; + publisher_url: string; + license_spdx: string; +} + export interface PluginCatalogEntry { repository_id: number; plugin_id: string; version: string; archive_url: string; + source_kind: "silo" | "approved_community" | "external"; + repository_name: string; + repo_url?: string; + presentation?: PluginPresentation; capabilities: PluginCapability[]; global_config_schema: PluginConfigSchema[]; user_config_schema: PluginConfigSchema[]; @@ -3348,11 +3368,28 @@ export interface PluginInstallation { task_bindings: PluginTaskBinding[]; update_policy: string; available_version?: string | null; + source_kind: "silo" | "approved_community" | "external"; + repository_name?: string; + repo_url?: string; + presentation?: PluginPresentation; + updates_paused: boolean; legacy_metadata_import_types?: string[]; created_at?: string; updated_at?: string; } +export interface PluginCatalogSettings { + include_approved_community_plugins: boolean; + approved_community_plugin_count: number; + installed_community_plugin_count: number; + migrated_plugin_count: number; + community_updates_paused: boolean; +} + +export interface UpdatePluginCatalogSettingsRequest { + include_approved_community_plugins: boolean; +} + export interface CreatePluginRepositoryRequest { url: string; display_name: string; diff --git a/web/src/hooks/queries/admin/plugins.ts b/web/src/hooks/queries/admin/plugins.ts index 0f9443c2..8069dd4c 100644 --- a/web/src/hooks/queries/admin/plugins.ts +++ b/web/src/hooks/queries/admin/plugins.ts @@ -8,6 +8,7 @@ import type { CreatePluginRepositoryRequest, InstallPluginRequest, PluginCatalogEntry, + PluginCatalogSettings, PluginInstallation, PluginRepository, PluginTaskBindingUpdateResponse, @@ -15,6 +16,7 @@ import type { SavePluginConfigRequest, SavePluginTaskBindingRequest, UpdatePluginInstallationRequest, + UpdatePluginCatalogSettingsRequest, UpdatePluginRepositoryRequest, } from "@/api/types"; import { @@ -31,6 +33,7 @@ function invalidatePluginQueries(queryClient: ReturnType) queryClient.invalidateQueries({ queryKey: adminKeys.pluginRepositories() }); queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalog() }); queryClient.invalidateQueries({ queryKey: adminKeys.pluginInstallations() }); + queryClient.invalidateQueries({ queryKey: adminKeys.pluginCatalogSettings() }); } // useAdminPluginInstallations is a slim hook for callers (e.g. AdminSidebar) @@ -66,17 +69,50 @@ export function useAdminPlugins() { staleTime: ADMIN_STALE_TIME, }); + const catalogSettingsQuery = useQuery({ + queryKey: adminKeys.pluginCatalogSettings(), + queryFn: () => api("/admin/plugins/catalog-settings"), + staleTime: ADMIN_STALE_TIME, + }); + return { repositories: repositoriesQuery.data ?? [], catalog: catalogQuery.data ?? [], installations: installationsQuery.data ?? [], + catalogSettings: catalogSettingsQuery.data, isLoading: - repositoriesQuery.isLoading || catalogQuery.isLoading || installationsQuery.isLoading, + repositoriesQuery.isLoading || + catalogQuery.isLoading || + installationsQuery.isLoading || + catalogSettingsQuery.isLoading, isFetching: - repositoriesQuery.isFetching || catalogQuery.isFetching || installationsQuery.isFetching, + repositoriesQuery.isFetching || + catalogQuery.isFetching || + installationsQuery.isFetching || + catalogSettingsQuery.isFetching, }; } +export function useUpdatePluginCatalogSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: UpdatePluginCatalogSettingsRequest) => + api("/admin/plugins/catalog-settings", { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success("Plugin catalog settings updated"); + invalidatePluginQueries(queryClient); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to update plugin catalog settings", + ); + }, + }); +} + export function useCreatePluginRepository() { const queryClient = useQueryClient(); return useMutation({ diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 74850e10..ba4ea5d6 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -437,6 +437,7 @@ export const adminKeys = { markerFileHistory: (fileId: number) => ["admin", "markerHistory", "files", fileId] as const, pluginRepositories: () => ["admin", "plugins", "repositories"] as const, pluginCatalog: () => ["admin", "plugins", "catalog"] as const, + pluginCatalogSettings: () => ["admin", "plugins", "catalogSettings"] as const, pluginInstallations: () => ["admin", "plugins", "installations"] as const, unmatchedItems: (page?: number, search?: string) => page != null diff --git a/web/src/pages/AdminPlugins.test.tsx b/web/src/pages/AdminPlugins.test.tsx index e3f9b0b7..8da67472 100644 --- a/web/src/pages/AdminPlugins.test.tsx +++ b/web/src/pages/AdminPlugins.test.tsx @@ -2,11 +2,87 @@ import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginCatalogEntry, PluginInstallation } from "@/api/types"; + import AdminPlugins from "./AdminPlugins"; const useAdminPluginsMock = vi.fn(); const checkPluginUpdatesMutateMock = vi.fn(); +const updatePluginCatalogSettingsMutateMock = vi.fn(); const capturedButtonProps: Array> = []; +const capturedSwitchProps: Array> = []; + +function makeCatalogEntry( + index: number, + overrides: { displayName?: string; summary?: string } = {}, +): PluginCatalogEntry { + const suffix = String(index).padStart(2, "0"); + const repoURL = `https://github.com/Silo-Server/plugin-${suffix}`; + return { + repository_id: 1, + plugin_id: `silo.plugin-${suffix}`, + version: "1.0.0", + archive_url: `${repoURL}/releases/download/v1.0.0/plugin-linux-amd64`, + source_kind: "silo", + repository_name: "Silo plugins", + repo_url: repoURL, + presentation: { + display_name: overrides.displayName ?? `Plugin ${suffix}`, + summary: overrides.summary ?? `Summary for plugin ${suffix}.`, + description_markdown: `Description for plugin ${suffix}.`, + setup_markdown: "Install and configure it.", + homepage_url: repoURL, + source_url: repoURL, + support_url: `${repoURL}/issues`, + changelog_url: `${repoURL}/releases`, + publisher_name: "Silo", + publisher_url: "https://github.com/Silo-Server", + license_spdx: "AGPL-3.0-or-later", + }, + capabilities: [], + global_config_schema: [], + user_config_schema: [], + routes: [], + assets: [], + }; +} + +function makeInstallation(index: number, displayName: string): PluginInstallation { + const suffix = String(index).padStart(2, "0"); + return { + id: index, + repository_id: 1, + plugin_id: `silo.installed-${suffix}`, + version: "1.0.0", + install_path: `/plugins/installed-${suffix}`, + enabled: true, + source_kind: "silo", + repository_name: "Silo plugins", + updates_paused: false, + presentation: { + display_name: displayName, + summary: `Installed summary ${suffix}.`, + description_markdown: `Installed description ${suffix}.`, + setup_markdown: "Configure it.", + homepage_url: "", + source_url: `https://github.com/Silo-Server/installed-${suffix}`, + support_url: "", + changelog_url: "", + publisher_name: "Silo", + publisher_url: "https://github.com/Silo-Server", + license_spdx: "AGPL-3.0-or-later", + }, + capabilities: [], + global_config_schema: [], + user_config_schema: [], + routes: [], + assets: [], + global_configs: [], + auth_bindings: [], + task_bindings: [], + update_policy: "auto", + }; +} vi.mock("@/components/ui/button", () => ({ Button: (props: Record) => { @@ -22,6 +98,13 @@ vi.mock("@/components/ui/tabs", () => ({ TabsContent: (props: Record) => props.children, })); +vi.mock("@/components/ui/switch", () => ({ + Switch: (props: Record) => { + capturedSwitchProps.push(props); + return null; + }, +})); + vi.mock("@tanstack/react-query", async () => { const actual = await vi.importActual("@tanstack/react-query"); @@ -35,6 +118,10 @@ vi.mock("@/hooks/queries/admin/plugins", () => ({ CHECK_PLUGIN_UPDATES_TASK_KEY: "check_plugin_updates", useAdminPlugins: () => useAdminPluginsMock(), useCheckPluginUpdates: () => ({ mutate: checkPluginUpdatesMutateMock, isPending: false }), + useUpdatePluginCatalogSettings: () => ({ + mutate: updatePluginCatalogSettingsMutateMock, + isPending: false, + }), useCreatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }), useUpdatePluginRepository: () => ({ mutate: vi.fn(), isPending: false }), useDeletePluginRepository: () => ({ mutate: vi.fn(), isPending: false }), @@ -42,6 +129,7 @@ vi.mock("@/hooks/queries/admin/plugins", () => ({ useUploadPlugin: () => ({ mutate: vi.fn(), isPending: false }), usePluginUpload: () => ({ upload: vi.fn(), progress: null, isPending: false }), useUpdatePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }), + useApplyPluginUpdate: () => ({ mutate: vi.fn(), isPending: false }), useDeletePluginInstallation: () => ({ mutate: vi.fn(), isPending: false }), useSavePluginConfig: () => ({ mutate: vi.fn(), isPending: false }), useSavePluginAuthBinding: () => ({ mutate: vi.fn(), isPending: false }), @@ -55,11 +143,14 @@ vi.mock("@/hooks/queries/admin/tasks", () => ({ describe("AdminPlugins", () => { beforeEach(() => { capturedButtonProps.length = 0; + capturedSwitchProps.length = 0; checkPluginUpdatesMutateMock.mockReset(); + updatePluginCatalogSettingsMutateMock.mockReset(); useAdminPluginsMock.mockReturnValue({ repositories: [], catalog: [], installations: [], + catalogSettings: undefined, isLoading: false, }); }); @@ -98,4 +189,277 @@ describe("AdminPlugins", () => { expect(markup).toContain("Choose plugin file..."); expect(markup).not.toContain('accept=".zip"'); }); + + it("shows the approved community setting and explains migrated installations", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalog: [], + installations: [], + catalogSettings: { + include_approved_community_plugins: true, + approved_community_plugin_count: 2, + installed_community_plugin_count: 2, + migrated_plugin_count: 2, + community_updates_paused: false, + }, + isLoading: false, + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Include approved community plugins"); + expect(markup).toContain("2 existing installations were moved here"); + expect(capturedSwitchProps[0]?.checked).toBe(true); + }); + + it("enables the approved community catalog directly when no community plugins are installed", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalog: [], + installations: [], + catalogSettings: { + include_approved_community_plugins: false, + approved_community_plugin_count: 2, + installed_community_plugin_count: 0, + migrated_plugin_count: 0, + community_updates_paused: false, + }, + isLoading: false, + }); + + renderToStaticMarkup( + + + , + ); + + const onCheckedChange = capturedSwitchProps[0]?.onCheckedChange as (checked: boolean) => void; + onCheckedChange(true); + + expect(updatePluginCatalogSettingsMutateMock).toHaveBeenCalledWith({ + include_approved_community_plugins: true, + }); + }); + + it("does not disable the community catalog without confirmation when plugins are installed", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalog: [], + installations: [], + catalogSettings: { + include_approved_community_plugins: true, + approved_community_plugin_count: 2, + installed_community_plugin_count: 2, + migrated_plugin_count: 2, + community_updates_paused: false, + }, + isLoading: false, + }); + + renderToStaticMarkup( + + + , + ); + + const onCheckedChange = capturedSwitchProps[0]?.onCheckedChange as (checked: boolean) => void; + onCheckedChange(false); + + expect(updatePluginCatalogSettingsMutateMock).not.toHaveBeenCalled(); + }); + + it("shows catalog presentation metadata and external resource links", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + installations: [], + catalogSettings: undefined, + isLoading: false, + catalog: [ + { + repository_id: 1, + plugin_id: "silo.example", + version: "1.0.0", + archive_url: "https://example.com/plugin", + source_kind: "silo", + repository_name: "Silo plugins", + repo_url: "https://github.com/Silo-Server/example-plugin", + presentation: { + display_name: "Example Plugin", + summary: "Explains the example for a homelab administrator.", + description_markdown: "Longer description.", + setup_markdown: "Configure the example.", + homepage_url: "https://example.com", + source_url: "https://github.com/Silo-Server/example-plugin", + support_url: "https://github.com/Silo-Server/example-plugin/issues", + changelog_url: "https://github.com/Silo-Server/example-plugin/releases", + publisher_name: "Silo", + publisher_url: "https://github.com/Silo-Server", + license_spdx: "AGPL-3.0-or-later", + }, + capabilities: [], + global_config_schema: [], + user_config_schema: [], + routes: [], + assets: [], + }, + ], + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Example Plugin"); + expect(markup).toContain("Explains the example for a homelab administrator."); + expect(markup).toContain('href="https://github.com/Silo-Server/example-plugin"'); + expect(markup).toContain('href="https://github.com/Silo-Server/example-plugin/releases"'); + }); + + it("uses catalog presentation metadata for an older installed manifest", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalogSettings: undefined, + isLoading: false, + installations: [ + { + id: 7, + repository_id: 1, + plugin_id: "silo.example", + version: "0.9.0", + install_path: "/plugins/example", + enabled: true, + source_kind: "silo", + repository_name: "Silo plugins", + updates_paused: false, + capabilities: [], + global_config_schema: [], + user_config_schema: [], + routes: [], + assets: [], + global_configs: [], + auth_bindings: [], + task_bindings: [], + update_policy: "auto", + }, + ], + catalog: [ + { + repository_id: 1, + plugin_id: "silo.example", + version: "1.0.0", + archive_url: "https://example.com/plugin", + source_kind: "silo", + repository_name: "Silo plugins", + repo_url: "https://github.com/Silo-Server/example-plugin", + presentation: { + display_name: "Example Plugin", + summary: "Catalog fallback description.", + description_markdown: "Longer description.", + setup_markdown: "Configure the example.", + homepage_url: "https://example.com", + source_url: "https://github.com/Silo-Server/example-plugin", + support_url: "https://github.com/Silo-Server/example-plugin/issues", + changelog_url: "https://github.com/Silo-Server/example-plugin/releases", + publisher_name: "Silo", + publisher_url: "https://github.com/Silo-Server", + license_spdx: "AGPL-3.0-or-later", + }, + capabilities: [], + global_config_schema: [], + user_config_schema: [], + routes: [], + assets: [], + }, + ], + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Catalog fallback description."); + expect(markup).toContain('href="https://github.com/Silo-Server/example-plugin"'); + }); + + it("searches catalog presentation metadata from the URL", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + installations: [], + catalogSettings: undefined, + isLoading: false, + catalog: [ + makeCatalogEntry(1, { + displayName: "Alpha Scanner", + summary: "Indexes local media files.", + }), + makeCatalogEntry(2, { + displayName: "Needle Requests", + summary: "Routes requests to the right service.", + }), + ], + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Needle Requests"); + expect(markup).toContain("1 of 2 plugins"); + expect(markup).not.toContain("Alpha Scanner"); + }); + + it("searches installed plugin metadata independently from the catalog", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + catalog: [], + catalogSettings: undefined, + isLoading: false, + installations: [ + makeInstallation(1, "Alpha Metadata"), + makeInstallation(2, "Needle Automation"), + ], + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Needle Automation"); + expect(markup).toContain("1 of 2 plugins"); + expect(markup).not.toContain("Alpha Metadata"); + }); + + it("paginates the catalog from URL state", () => { + useAdminPluginsMock.mockReturnValue({ + repositories: [], + installations: [], + catalogSettings: undefined, + isLoading: false, + catalog: Array.from({ length: 13 }, (_, index) => makeCatalogEntry(index + 1)), + }); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Plugin 13"); + expect(markup).not.toContain("Plugin 01"); + expect(markup).toContain("Showing"); + expect(markup).toContain(">13– = { "metadata_provider.v1": "Metadata", @@ -75,19 +97,194 @@ function capabilityLabel(type: string): string { return labels[type] ?? type.split(".")[0] ?? type; } +function sourceLabel(sourceKind: string): string { + switch (sourceKind) { + case "silo": + return "Silo maintained"; + case "approved_community": + return "Approved community"; + default: + return "External source"; + } +} + +function pluginDisplayName(pluginID: string, presentation?: PluginPresentation): string { + const displayName = presentation?.display_name.trim(); + if (displayName) return displayName; + + return pluginID + .replace(/^silo[._-]?/, "") + .split(/[._-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function pluginSummary( + presentation: PluginPresentation | undefined, + capabilities: PluginInstallation["capabilities"], +): string { + const summary = presentation?.summary.trim(); + if (summary) return summary; + + return ( + capabilities.find((capability) => capability.description?.trim())?.description?.trim() ?? + "No description provided." + ); +} + +function safeExternalURL(rawURL?: string): string | undefined { + if (!rawURL) return undefined; + try { + const url = new URL(rawURL); + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function PluginResourceLinks({ + presentation, + repoURL, +}: { + presentation?: PluginPresentation; + repoURL?: string; +}) { + const links = [ + { label: "Source", url: safeExternalURL(presentation?.source_url || repoURL) }, + { label: "Changelog", url: safeExternalURL(presentation?.changelog_url) }, + { label: "Support", url: safeExternalURL(presentation?.support_url) }, + ].filter((link): link is { label: string; url: string } => Boolean(link.url)); + + if (links.length === 0) return null; + + return ( +
+ {links.map((link) => ( + + {link.label} + + + ))} +
+ ); +} + +function parsePluginPage(rawPage: string | null): number { + const page = Number.parseInt(rawPage ?? "1", 10); + return Number.isFinite(page) && page > 0 ? page - 1 : 0; +} + +function pluginMatchesSearch({ + query, + pluginID, + presentation, + capabilities, + sourceKind, + repositoryName, +}: { + query: string; + pluginID: string; + presentation?: PluginPresentation; + capabilities: PluginInstallation["capabilities"]; + sourceKind: string; + repositoryName?: string; +}): boolean { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return true; + + const searchableText = [ + pluginID, + pluginDisplayName(pluginID, presentation), + presentation?.summary, + presentation?.description_markdown, + presentation?.publisher_name, + repositoryName, + sourceLabel(sourceKind), + ...capabilities.flatMap((capability) => [ + capability.display_name, + capability.description, + capability.type, + ]), + ] + .filter(Boolean) + .join("\n") + .toLocaleLowerCase(); + + return searchableText.includes(normalizedQuery); +} + +function PluginListToolbar({ + query, + total, + matchingTotal, + placeholder, + onQueryChange, +}: { + query: string; + total: number; + matchingTotal: number; + placeholder: string; + onQueryChange: (query: string) => void; +}) { + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + className="pr-9 pl-9" + /> + {query ? ( + + ) : null} +
+ + {query.trim() ? `${matchingTotal} of ${total} plugins` : `${total} plugins`} + +
+ ); +} + /* ─── Installed plugin card ─────────────────────────────────────── */ function InstalledPluginCard({ installation, + catalogEntry, onConfigure, }: { installation: PluginInstallation; + catalogEntry?: PluginCatalogEntry; onConfigure: (installation: PluginInstallation) => void; }) { const updateInstallation = useUpdatePluginInstallation(); const deleteInstallation = useDeletePluginInstallation(); const applyUpdate = useApplyPluginUpdate(); const capabilities = installation.capabilities ?? []; + const presentation = installation.presentation ?? catalogEntry?.presentation; + const repoURL = installation.repo_url || catalogEntry?.repo_url; const routes = installation.routes ?? []; const adminRoutes = routes.filter( (route) => route.navigable && route.navigation_kind === "admin", @@ -107,15 +304,25 @@ function InstalledPluginCard({
-

{installation.plugin_id}

+

+ {pluginDisplayName(installation.plugin_id, presentation)} +

{installation.version} + + {sourceLabel(installation.source_kind)} + {installation.available_version && ( {installation.version} → {installation.available_version} available )} + {installation.updates_paused ? ( + + Updates paused + + ) : null} {installation.available_version && (
+

{installation.plugin_id}

+

+ {pluginSummary(presentation, capabilities)} +

{capabilities.length > 0 && (
{capabilities.map((cap) => ( @@ -158,6 +369,7 @@ function InstalledPluginCard({ ))}
)} +
@@ -452,6 +664,7 @@ function ConfigureDialog({ function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInstalled: boolean }) { const installPlugin = useInstallPlugin(); + const capabilities = entry.capabilities ?? []; return (
@@ -461,14 +674,23 @@ function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInst
-

{entry.plugin_id}

+

+ {pluginDisplayName(entry.plugin_id, entry.presentation)} +

{entry.version} + + {sourceLabel(entry.source_kind)} +
- {entry.capabilities?.length > 0 && ( +

{entry.plugin_id}

+

+ {pluginSummary(entry.presentation, capabilities)} +

+ {capabilities.length > 0 && (
- {entry.capabilities.map((cap) => ( + {capabilities.map((cap) => (
-
+
+ {isInstalled ? ( Installed @@ -506,6 +729,81 @@ function CatalogCard({ entry, isInstalled }: { entry: PluginCatalogEntry; isInst ); } +function CommunityCatalogControl({ settings }: { settings: PluginCatalogSettings }) { + const updateSettings = useUpdatePluginCatalogSettings(); + const [confirmDisable, setConfirmDisable] = useState(false); + + function setIncluded(include: boolean) { + if (!include && settings.installed_community_plugin_count > 0) { + setConfirmDisable(true); + return; + } + updateSettings.mutate({ include_approved_community_plugins: include }); + } + + function disableCommunityCatalog() { + updateSettings.mutate({ include_approved_community_plugins: false }); + setConfirmDisable(false); + } + + return ( + <> +
+
+
+ + +
+

+ Reviewed by Silo maintainers to work as described and be safe for their documented use. + These plugins remain maintained and supported by community contributors. +

+ {settings.migrated_plugin_count > 0 ? ( +

+ {settings.migrated_plugin_count} existing{" "} + {settings.migrated_plugin_count === 1 ? "installation was" : "installations were"}{" "} + moved here without changing configuration. +

+ ) : null} +
+ +
+ + + + + Hide approved community plugins? + + {settings.installed_community_plugin_count}{" "} + {settings.installed_community_plugin_count === 1 + ? "installed plugin will" + : "installed plugins will"}{" "} + keep running, but update discovery will pause until this catalog is included again. + + + + Cancel + + Hide and pause updates + + + + + + ); +} + /* ─── Repository management ─────────────────────────────────────── */ function RepositorySection() { @@ -584,23 +882,29 @@ function RepositorySection() {

{repo.url}

- - + {repo.managed ? ( + Managed by Silo + ) : ( + <> + + + + )}
))} @@ -662,17 +966,95 @@ function UploadSection() { /* ─── Main page ─────────────────────────────────────────────────── */ export default function AdminPlugins() { - const { installations, catalog, isLoading } = useAdminPlugins(); + const { installations, catalog, catalogSettings, isLoading } = useAdminPlugins(); + const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const checkPluginUpdates = useCheckPluginUpdates(); const { data: pluginUpdateTask } = useTask(CHECK_PLUGIN_UPDATES_TASK_KEY); const [configuring, setConfiguring] = useState(null); const previousTaskState = useRef(null); - const installedIds = new Set(installations.map((i) => i.plugin_id)); + const installedIds = useMemo( + () => new Set(installations.map((installation) => installation.plugin_id)), + [installations], + ); + const catalogByPluginID = useMemo( + () => new Map(catalog.map((entry) => [entry.plugin_id, entry])), + [catalog], + ); + const activeTab = searchParams.get("tab") === "catalog" ? "catalog" : "installed"; + const installedQuery = searchParams.get("installed_q") ?? ""; + const catalogQuery = searchParams.get("catalog_q") ?? ""; + const filteredInstallations = useMemo( + () => + installations.filter((installation) => { + const catalogEntry = catalogByPluginID.get(installation.plugin_id); + return pluginMatchesSearch({ + query: installedQuery, + pluginID: installation.plugin_id, + presentation: installation.presentation ?? catalogEntry?.presentation, + capabilities: installation.capabilities ?? [], + sourceKind: installation.source_kind, + repositoryName: installation.repository_name, + }); + }), + [catalogByPluginID, installations, installedQuery], + ); + const filteredCatalog = useMemo( + () => + catalog.filter((entry) => + pluginMatchesSearch({ + query: catalogQuery, + pluginID: entry.plugin_id, + presentation: entry.presentation, + capabilities: entry.capabilities ?? [], + sourceKind: entry.source_kind, + repositoryName: entry.repository_name, + }), + ), + [catalog, catalogQuery], + ); + const installedPageCount = Math.max( + 1, + Math.ceil(filteredInstallations.length / INSTALLED_PAGE_SIZE), + ); + const catalogPageCount = Math.max(1, Math.ceil(filteredCatalog.length / CATALOG_PAGE_SIZE)); + const installedPage = Math.min( + parsePluginPage(searchParams.get("installed_page")), + installedPageCount - 1, + ); + const catalogPage = Math.min( + parsePluginPage(searchParams.get("catalog_page")), + catalogPageCount - 1, + ); + const visibleInstallations = useMemo( + () => + filteredInstallations.slice( + installedPage * INSTALLED_PAGE_SIZE, + (installedPage + 1) * INSTALLED_PAGE_SIZE, + ), + [filteredInstallations, installedPage], + ); + const visibleCatalog = useMemo( + () => + filteredCatalog.slice(catalogPage * CATALOG_PAGE_SIZE, (catalogPage + 1) * CATALOG_PAGE_SIZE), + [catalogPage, filteredCatalog], + ); const isCheckingUpdates = pluginUpdateTask?.state === "running" || pluginUpdateTask?.state === "cancelling"; + function updatePluginView( + updates: Record, + options: { replace?: boolean } = { replace: true }, + ) { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(updates)) { + if (value) next.set(key, value); + else next.delete(key); + } + setSearchParams(next, { replace: options.replace ?? true }); + } + useEffect(() => { const currentState = pluginUpdateTask?.state ?? null; const previousState = previousTaskState.current; @@ -723,7 +1105,12 @@ export default function AdminPlugins() {
- + + updatePluginView({ tab: value === "catalog" ? "catalog" : undefined }, { replace: false }) + } + > Installed @@ -733,8 +1120,8 @@ export default function AdminPlugins() { )} - - Available + + Catalog {catalog.length > 0 && ( {catalog.length} @@ -745,46 +1132,84 @@ export default function AdminPlugins() { {/* ── Installed ── */} + {installations.length > 0 ? ( + + updatePluginView({ installed_q: query || undefined, installed_page: undefined }) + } + /> + ) : null} {installations.length === 0 ? (

No plugins installed

- Browse the Available tab to find and install plugins. + Browse the Catalog tab to find and install plugins.

- ) : ( -
- {installations.map((installation) => ( - - ))} + ) : filteredInstallations.length === 0 ? ( +
+

No installed plugins match your search

+
- )} - - - {/* ── Available ── */} - - {/* Catalog grid */} - {catalog.length > 0 ? ( -
-

Catalog

-
- {catalog.map((entry) => ( - +
+ {visibleInstallations.map((installation) => ( + ))}
-
- ) : ( + {filteredInstallations.length > INSTALLED_PAGE_SIZE ? ( + + updatePluginView({ installed_page: page === 0 ? undefined : String(page + 1) }) + } + className="border-t pt-3" + /> + ) : null} + + )} + + + {/* ── Catalog ── */} + + {catalogSettings ? : null} + {catalog.length > 0 ? ( + + updatePluginView({ catalog_q: query || undefined, catalog_page: undefined }) + } + /> + ) : null} + {/* Catalog grid */} + {catalog.length === 0 ? (
@@ -794,6 +1219,41 @@ export default function AdminPlugins() {

+ ) : filteredCatalog.length === 0 ? ( +
+

No catalog plugins match your search

+ +
+ ) : ( +
+
+ {visibleCatalog.map((entry) => ( + + ))} +
+ {filteredCatalog.length > CATALOG_PAGE_SIZE ? ( + + updatePluginView({ catalog_page: page === 0 ? undefined : String(page + 1) }) + } + className="border-t pt-3" + /> + ) : null} +
)}