From 1cda7b9439a8923e42b1cb6b1db33b0065e444cc Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Fri, 10 Apr 2026 13:53:45 -0400 Subject: [PATCH] feat: plugin hub --- Plugin_repo.md | 586 ++++++++ apps/plugins/api_urls.py | 17 + apps/plugins/api_views.py | 1233 ++++++++++++++--- apps/plugins/apps.py | 50 + apps/plugins/keys/dispatcharr-plugins.pub | 11 + apps/plugins/loader.py | 52 +- apps/plugins/migrations/0002_pluginrepo.py | 84 ++ apps/plugins/models.py | 44 + apps/plugins/serializers.py | 38 + apps/plugins/tasks.py | 33 + frontend/src/App.jsx | 5 + frontend/src/api.js | 150 +- frontend/src/components/PluginDetailPanel.jsx | 430 ++++++ .../src/components/__tests__/Sidebar.test.jsx | 2 + .../components/cards/AvailablePluginCard.jsx | 779 +++++++++++ frontend/src/components/cards/PluginCard.jsx | 556 +++++--- .../cards/__tests__/PluginCard.test.jsx | 92 +- .../__tests__/StreamConnectionCard.test.jsx | 3 + frontend/src/components/pluginUtils.js | 25 + frontend/src/config/navigation.js | 7 +- frontend/src/pages/PluginBrowse.jsx | 729 ++++++++++ frontend/src/pages/Plugins.jsx | 298 ++-- frontend/src/pages/__tests__/Plugins.test.jsx | 55 +- frontend/src/store/plugins.jsx | 76 + frontend/src/utils/pages/PluginsUtils.js | 4 +- .../pages/__tests__/PluginsUtils.test.js | 6 +- 26 files changed, 4891 insertions(+), 474 deletions(-) create mode 100644 Plugin_repo.md create mode 100644 apps/plugins/keys/dispatcharr-plugins.pub create mode 100644 apps/plugins/migrations/0002_pluginrepo.py create mode 100644 apps/plugins/tasks.py create mode 100644 frontend/src/components/PluginDetailPanel.jsx create mode 100644 frontend/src/components/cards/AvailablePluginCard.jsx create mode 100644 frontend/src/components/pluginUtils.js create mode 100644 frontend/src/pages/PluginBrowse.jsx diff --git a/Plugin_repo.md b/Plugin_repo.md new file mode 100644 index 00000000..d846526d --- /dev/null +++ b/Plugin_repo.md @@ -0,0 +1,586 @@ +# Dispatcharr Plugin Repository Specification + +How to create and host a plugin repository that Dispatcharr can consume. + +For writing plugins themselves, see [Plugins.md](Plugins.md). + +--- + +## Overview + +Dispatcharr discovers plugins from remote repositories using a two-level manifest system: + +1. **Repo manifest** - a JSON file listing all plugins in the repo with basic metadata. +2. **Per-plugin manifest** (optional) - a JSON file per plugin with full version history, checksums, and compatibility info. + +Users add a repo by its manifest URL. Dispatcharr fetches and caches the repo manifest periodically (default: every 6 hours, configurable). The UI displays all plugins from enabled repos in a browsable store. + +--- + +## Repo Manifest + +The repo manifest is the entry point. Dispatcharr fetches this URL and caches the response. + +### Minimal Example (no signing) + +```json +{ + "registry_name": "My Plugin Repo", + "plugins": [ + { + "slug": "my_plugin", + "name": "My Plugin", + "description": "Does something useful", + "author": "Your Name", + "latest_version": "1.0.0", + "latest_url": "https://example.com/releases/my_plugin-1.0.0.zip" + } + ] +} +``` + +This is the simplest valid repo manifest - one plugin with enough info to show in the store and install. + +### Full Example (with signing) + +```json +{ + "manifest": { + "registry_name": "My Plugin Repo", + "registry_url": "https://github.com/myorg/my-plugins", + "root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases", + "plugins": [ + { + "slug": "weather_display", + "name": "Weather Display", + "description": "Shows weather info on the dashboard", + "author": "Acme Labs", + "license": "MIT", + "latest_version": "1.2.5", + "last_updated": "2025-01-20T15:30:00Z", + "manifest_url": "plugins/weather_display/manifest.json", + "latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip", + "latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "icon_url": "plugins/weather_display/logo.png", + "min_dispatcharr_version": "2.5.0", + "max_dispatcharr_version": null + } + ] + }, + "signature": "-----BEGIN PGP SIGNATURE-----\n..." +} +``` + +### Accepted Formats + +Dispatcharr accepts two top-level shapes: + +**Wrapped (supports signing):** +```json +{ + "manifest": { "plugins": [...], ... }, + "signature": "..." +} +``` + +**Flat (no signing):** +```json +{ + "plugins": [...], + "registry_name": "...", + "root_url": "..." +} +``` + +The wrapped format is required for signing. If you don't need signing, the flat format works and is simpler. + +### Name Restrictions + +`registry_name` is required. Dispatcharr rejects repos that are missing it. + +Third-party repos must not use names that could be confused with an official Dispatcharr repo. The following words are blocked in `registry_name` (case-insensitive): + +- "official" +- "dispatcharr plugins" +- "dispatcharr repo" +- "dispatcharr official" + +If the name contains any of these, the repo will be rejected on add and skipped during refresh. + +--- + +## Repo Manifest Fields + +### Top-Level Metadata + +| Field | Required | Description | +|-------|----------|-------------| +| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). | +| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. | +| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. | +| `plugins` | **Yes** | Array of plugin entry objects. | + +### Plugin Entry Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). | +| `name` | **Yes** | Human-readable display name. | +| `description` | No | Short description shown on the plugin card. | +| `author` | No | Author or organization name. | +| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | +| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | +| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | +| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | +| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | +| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | +| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | +| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | +| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | +| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | + +Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything. + +### URL Resolution + +If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) does not start with `http://` or `https://`, it is treated as relative and resolved as: + +``` +{root_url}/{field_value} +``` + +This lets you keep plugin entries compact: +```json +{ + "root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases", + "plugins": [ + { + "slug": "my_plugin", + "latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip", + "icon_url": "plugins/my_plugin/logo.png", + "manifest_url": "plugins/my_plugin/manifest.json" + } + ] +} +``` + +**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL: +``` +{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png +``` + +--- + +## Per-Plugin Manifest (Optional) + +The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead. + +Include a per-plugin manifest if you want to: +- Offer multiple downloadable versions +- Show per-version compatibility ranges +- Display build timestamps and commit links for each version +- Provide detailed author/license info beyond what's in the repo manifest + +### Accepted Formats + +Same as the root manifest - both flat and wrapped formats are accepted: + +**Flat (no signing):** +```json +{ + "slug": "...", + "versions": [...] +} +``` + +**Wrapped (supports signing):** +```json +{ + "manifest": { + "slug": "...", + "versions": [...] + }, + "signature": "-----BEGIN PGP SIGNATURE-----\n..." +} +``` + +Use the wrapped format if you want to GPG-sign the per-plugin manifest. + +### Example + +```json +{ + "slug": "weather_display", + "name": "Weather Display", + "description": "Shows weather information on the Dispatcharr dashboard", + "author": "Acme Labs", + "license": "MIT", + "latest_version": "1.2.5", + "versions": [ + { + "version": "1.2.5", + "url": "releases/weather_display-1.2.5.zip", + "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build_timestamp": "2025-01-20T15:30:00Z", + "commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648", + "commit_sha_short": "4e8f1b1", + "min_dispatcharr_version": "2.5.0", + "max_dispatcharr_version": null + }, + { + "version": "1.2.5-rc.1", + "url": "releases/weather_display-1.2.5-rc.1.zip", + "checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "prerelease": true, + "build_timestamp": "2025-01-18T09:00:00Z", + "min_dispatcharr_version": "2.5.0" + }, + { + "version": "1.2.4", + "url": "releases/weather_display-1.2.4.zip", + "checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6", + "build_timestamp": "2025-01-15T10:00:00Z", + "min_dispatcharr_version": "2.4.0" + } + ], + "latest": { + "version": "1.2.5", + "url": "releases/weather_display-1.2.5.zip", + "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build_timestamp": "2025-01-20T15:30:00Z", + "min_dispatcharr_version": "2.5.0" + } +} +``` + +### Per-Plugin Manifest Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `slug` | No | Plugin identifier (should match the repo entry). | +| `name` | No | Display name. | +| `description` | No | Full description shown in the detail modal. | +| `author` | No | Author/org name shown in the detail modal. | +| `license` | No | SPDX license identifier. | +| `latest_version` | No | Latest version string. | +| `versions` | No | Array of version objects (newest first recommended). | +| `latest` | No | Object mirroring the latest version entry for quick access. | + +### Version Object Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). | +| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. | +| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. | +| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. | +| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | +| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | +| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | +| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | +| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | + +Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`. + +--- + +## Without a Per-Plugin Manifest + +If you omit `manifest_url` from a plugin entry, the store still works. When a user clicks "More Info", the UI builds a detail view from the repo-level fields: + +- `description`, `author`, `license` from the plugin entry +- A single version entry built from `latest_version`, `latest_url`, `latest_sha256`, `min_dispatcharr_version`, `max_dispatcharr_version`, and `last_updated` + +This is the simplest path for third-party repos that only publish one version at a time. You lose version history and per-version release dates, but install, update detection, and everything else works the same. + +--- + +## Signing + +Signing your repo manifest lets Dispatcharr verify it hasn't been tampered with. Signing is **optional** - unsigned repos work fine but show an "unverified" badge in the UI. + +### How It Works + +1. You generate a GPG keypair. +2. You sign the manifest JSON and include the detached signature in the response. +3. When adding the repo in Dispatcharr, the user pastes your public key. +4. Dispatcharr verifies the signature on every manifest fetch. + +### Key Format + +Standard PGP/GPG armored keys: + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBG... +... +-----END PGP PUBLIC KEY BLOCK----- +``` + +### Signing Convention + +The signature is computed over the **canonical JSON** representation of the `manifest` object (not the entire response), plus a trailing newline: + +```bash +# Canonical format: compact JSON (no spaces) + trailing newline +jq -c '.manifest' manifest.json | gpg --armor --detach-sign +``` + +In code terms: +```python +import json +canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n" +``` + +> **Important:** The signing input must be `json.dumps(obj, separators=(",", ":")) + "\n"` - compact JSON with no whitespace, followed by exactly one newline. Any difference (pretty-printing, trailing spaces, key ordering changes) will cause verification to fail. + +### Manifest Structure for Signing + +Use the wrapped format so the signature sits alongside the manifest: + +```json +{ + "manifest": { + "registry_name": "...", + "plugins": [...] + }, + "signature": "-----BEGIN PGP SIGNATURE-----\n...\n-----END PGP SIGNATURE-----" +} +``` + +### Verification Results + +| Result | Meaning | UI Badge | +|--------|---------|----------| +| `true` | Valid signature | Green checkmark | +| `false` | Invalid signature or verification error | Red X | +| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral | + +### Signing Workflow Example + +```bash +# Generate a keypair (one-time) +gpg --gen-key + +# Export your public key (give this to repo users) +gpg --armor --export "your@email.com" > my-repo.pub + +# Build your manifest +cat > manifest.json << 'EOF' +{ + "manifest": { + "registry_name": "My Repo", + "root_url": "https://example.com/releases", + "plugins": [ + { + "slug": "my_plugin", + "name": "My Plugin", + "latest_version": "1.0.0", + "latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip" + } + ] + } +} +EOF + +# Sign the manifest object (canonical JSON + newline) +jq -c '.manifest' manifest.json | gpg --armor --detach-sign > manifest.sig + +# Combine into final output +jq --arg sig "$(cat manifest.sig)" '.signature = $sig' manifest.json > signed_manifest.json +``` + +### Third-Party Key Management + +When a user adds your repo URL, they can paste your public key. Dispatcharr stores the key per-repo and uses it for verification. Users can update the key at any time from the repo management UI. + +If you don't provide a key and the repo is not the official Dispatcharr repo, signature verification is skipped (result: `null`). + +--- + +## Release Zip Format + +Each plugin release is a `.zip` archive. + +### Requirements + +- Must contain a `plugin.py` with a `Plugin` class, **or** a Python package with `__init__.py` exporting a `Plugin` class. +- Files can be at the top level of the zip or inside a single subdirectory. +- Optionally include `plugin.json` for metadata discovery without code execution. +- Optionally include `logo.png` for the plugin icon. + +### Size Limits + +- Maximum 2000 files per archive. +- Maximum total size: 200 MB (configurable via `MAX_PLUGIN_IMPORT_BYTES` setting). + +### Recommended Structure + +``` +my_plugin-1.0.0.zip + plugin.py + plugin.json + logo.png + (any other files your plugin needs) +``` + +Or with a subdirectory: +``` +my_plugin-1.0.0.zip + my_plugin/ + plugin.py + plugin.json + logo.png + utils.py +``` + +--- + +## Install Flow + +When a user installs a plugin from the store: + +1. **Version compatibility check** - if `min_dispatcharr_version` or `max_dispatcharr_version` is set, the running Dispatcharr version is compared. Install is blocked if out of range. +2. **Download** - the zip is streamed from `download_url` (max 200 MB). +3. **SHA256 integrity check** - if `sha256` was provided, the download is hashed and compared. Mismatch blocks the install. +4. **Extraction** - the zip is extracted to a temp directory, validated, then moved to `/data/plugins/{plugin_key}/`. If the plugin already exists, the old version is backed up and restored on failure (atomic rollback). +5. **Registration** - a `PluginConfig` record is created or updated, linking the plugin to its source repo and slug. +6. **Discovery reload** - the plugin loader re-scans all plugin directories. + +The plugin is installed **disabled** by default. The user can enable it from the post-install dialog or the My Plugins page. + +--- + +## Update Detection + +Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection. + +A plugin shows "Update Available" when: +- It is managed (installed from a repo) +- Its `installed_version` differs from `latest_version` +- It was installed from the same repo + +--- + +## Hosting Options + +A plugin repo manifest is just a JSON file served over HTTPS. Some options: + +### GitHub Pages / Raw Content +Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs: +``` +https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json +``` + +Use `root_url` pointing to your releases branch/path so version URLs stay relative. + +### Static File Server +Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed. + +### GitHub Releases +You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch. + +--- + +## Refresh Behavior + +- Manifests are refreshed automatically at a configurable interval (default: 6 hours, setting: `refresh_interval_hours`, 0 = disabled). +- Users can force a refresh from the repo management UI. +- A new repo is refreshed immediately when added. +- On refresh, if a plugin's `slug` disappears from the manifest, its `PluginConfig` is unlinked from the repo (becomes "unmanaged") but the installed files are not deleted. + +--- + +## Checklist: Publishing a Plugin Repo + +### Minimum Viable Repo + +- [ ] Host a JSON file at a stable, public URL +- [ ] Set `registry_name` (required, must not sound official) +- [ ] Include at least one plugin entry with `slug`, `name`, and `latest_version` +- [ ] Host a downloadable `.zip` for each plugin and set `latest_url` +- [ ] Share the manifest URL with users + +### Recommended + +- [ ] Set `root_url` so plugin URLs can be relative +- [ ] Include `description`, `author`, and `icon_url` per plugin +- [ ] Include `latest_sha256` for integrity verification +- [ ] Include `license` (SPDX identifier) +- [ ] Include `last_updated` timestamps +- [ ] Add a per-plugin `manifest_url` with version history +- [ ] Include `sha256` in every version object +- [ ] Include `min_dispatcharr_version` where applicable +- [ ] Include `plugin.json` in each release zip + +### Optional + +- [ ] Sign your manifest with GPG and publish your public key +- [ ] Set `registry_url` to enable automatic icon fallback +- [ ] Set `max_dispatcharr_version` if a plugin is incompatible with newer releases + +--- + +## Quick Reference: Repo Manifest Schema + +```json +{ + "manifest": { + "registry_name": "string (required)", + "registry_url": "string (optional)", + "root_url": "string (optional)", + "plugins": [ + { + "slug": "string (required)", + "name": "string (required)", + "description": "string", + "author": "string", + "license": "string (SPDX)", + "latest_version": "string (semver)", + "last_updated": "string (ISO 8601)", + "manifest_url": "string (URL or relative path)", + "latest_url": "string (URL or relative path)", + "latest_sha256": "string (64-char hex)", + "icon_url": "string (URL or relative path)", + "min_dispatcharr_version": "string (semver)", + "max_dispatcharr_version": "string (semver) or null" + } + ] + }, + "signature": "string (armored PGP signature, optional)" +} +``` + +## Quick Reference: Per-Plugin Manifest Schema + +```json +{ + "slug": "string", + "name": "string", + "description": "string", + "author": "string", + "license": "string (SPDX)", + "latest_version": "string (semver)", + "versions": [ + { + "version": "string (required)", + "url": "string (required, URL or relative path)", + "checksum_sha256": "string (64-char hex)", + "build_timestamp": "string (ISO 8601)", + "commit_sha": "string", + "commit_sha_short": "string", + "min_dispatcharr_version": "string (semver)", + "max_dispatcharr_version": "string (semver) or null" + } + ], + "latest": { + "version": "string", + "url": "string", + "checksum_sha256": "string", + "build_timestamp": "string", + "min_dispatcharr_version": "string", + "max_dispatcharr_version": "string or null" + } +} +``` diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py index 5ba85be2..99355232 100644 --- a/apps/plugins/api_urls.py +++ b/apps/plugins/api_urls.py @@ -8,6 +8,14 @@ from .api_views import ( PluginImportAPIView, PluginDeleteAPIView, PluginLogoAPIView, + PluginRepoListCreateAPIView, + PluginRepoPreviewAPIView, + PluginRepoDetailAPIView, + PluginRepoRefreshAPIView, + AvailablePluginsAPIView, + PluginDetailManifestAPIView, + PluginInstallFromRepoAPIView, + PluginRepoSettingsAPIView, ) app_name = "plugins" @@ -21,4 +29,13 @@ urlpatterns = [ path("plugins//run/", PluginRunAPIView.as_view(), name="run"), path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), path("plugins//logo/", PluginLogoAPIView.as_view(), name="logo"), + # Plugin repos (hub / store) - static paths first, then parametric + path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"), + path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"), + path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"), + path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"), + path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"), + path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"), + path("repos//", PluginRepoDetailAPIView.as_view(), name="repo-detail"), + path("repos//refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"), ] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 624dcc4d..425ac77e 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -1,15 +1,23 @@ +import hashlib +import ipaddress import logging +import io +import json import re +import socket from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers +from drf_spectacular.utils import extend_schema, inline_serializer from django.conf import settings from django.core.files.uploadedfile import UploadedFile from django.http import FileResponse +from django.utils import timezone import os import zipfile import shutil import tempfile +import requests as http_requests from urllib.parse import urlparse from apps.accounts.permissions import ( Authenticated, @@ -18,11 +26,35 @@ from apps.accounts.permissions import ( from dispatcharr.utils import network_access_allowed from .loader import PluginManager -from .models import PluginConfig +from .models import PluginConfig, PluginRepo +from .serializers import PluginRepoSerializer logger = logging.getLogger(__name__) +def _compare_versions(a, b): + """Compare two semver-like version strings. + Returns negative if a < b, 0 if equal, positive if a > b. + + If either version is a prerelease (any dot-segment contains non-digit + characters), numeric ordering is meaningless. Falls back to exact string + equality: 0 if identical, 1 otherwise. + """ + if not a or not b: + return 0 + na = a.lstrip("v") + nb = b.lstrip("v") + if any(not p.isdigit() for p in na.split(".")) or any(not p.isdigit() for p in nb.split(".")): + return 0 if na == nb else 1 + pa = [int(x) for x in na.split(".")] + pb = [int(x) for x in nb.split(".")] + for i in range(max(len(pa), len(pb))): + diff = (pa[i] if i < len(pa) else 0) - (pb[i] if i < len(pb) else 0) + if diff != 0: + return diff + return 0 + + MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000) MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024) MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024) @@ -44,12 +76,43 @@ def _parse_bool(value): def _sanitize_plugin_key(value: str) -> str: base = os.path.basename(value or "") - base = base.replace(" ", "_").lower() - base = re.sub(r"[^a-z0-9_-]", "_", base) - base = base.strip("._-") + base = base.replace(" ", "_").replace("-", "_").lower() + base = re.sub(r"[^a-z0-9_]", "_", base) + base = base.strip("._ ") return base or "plugin" +def _validate_fetch_url(url): + """Raise ValueError if the URL must not be fetched (SSRF prevention). + + Only http and https schemes are allowed. Hostnames that resolve to + loopback, private, link-local, or otherwise non-routable addresses + are rejected. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"URL scheme '{parsed.scheme}' is not allowed; only http and https are permitted." + ) + hostname = parsed.hostname + if not hostname: + raise ValueError("URL has no hostname.") + try: + infos = socket.getaddrinfo(hostname, None) + except socket.gaierror as exc: + raise ValueError(f"Could not resolve hostname '{hostname}': {exc}") from exc + for _family, _type, _proto, _canon, sockaddr in infos: + addr_str = sockaddr[0] + try: + ip = ipaddress.ip_address(addr_str) + except ValueError: + continue + if ip.is_loopback or ip.is_link_local or ip.is_private or ip.is_reserved or ip.is_unspecified: + raise ValueError( + f"URL resolves to a non-routable address ({addr_str}) and cannot be fetched." + ) + + def _absolutize_logo_url(request, url: str | None) -> str | None: if not url or not request: return url @@ -59,7 +122,10 @@ def _absolutize_logo_url(request, url: str | None) -> str | None: return request.build_absolute_uri(url) -class PluginsListAPIView(APIView): +class PluginAuthMixin: + """Mixin that routes permission resolution through permission_classes_by_method, + falling back to Authenticated() for any method not explicitly listed.""" + def get_permissions(self): try: return [ @@ -68,6 +134,8 @@ class PluginsListAPIView(APIView): except KeyError: return [Authenticated()] + +class PluginsListAPIView(PluginAuthMixin, APIView): def get(self, request): pm = PluginManager.get() # Prefer cached registry; reload explicitly via the reload endpoint @@ -78,15 +146,7 @@ class PluginsListAPIView(APIView): return Response({"plugins": plugins}) -class PluginReloadAPIView(APIView): - def get_permissions(self): - try: - return [ - perm() for perm in permission_classes_by_method[self.request.method] - ] - except KeyError: - return [Authenticated()] - +class PluginReloadAPIView(PluginAuthMixin, APIView): def post(self, request): pm = PluginManager.get() pm.stop_all_plugins(reason="reload") @@ -94,143 +154,237 @@ class PluginReloadAPIView(APIView): return Response({"success": True, "count": len(pm._registry)}) -class PluginImportAPIView(APIView): - def get_permissions(self): +def _install_plugin_from_zip(zip_file, plugins_dir, *, file_name="plugin.zip", allow_overwrite_key=None, allow_overwrite=False): + """Extract and install a plugin from a zip file-like object. + + Args: + zip_file: File-like object containing the zip. + plugins_dir: Path to the plugins directory. + file_name: Name hint for deriving plugin key when the zip has flat contents. + allow_overwrite_key: If set, allow overwriting this specific plugin directory. + allow_overwrite: If True, allow overwriting any existing plugin with the same key. + + Returns: + dict with "success" bool, and either "plugin_key" on success or "error" on failure. + """ + try: + zf = zipfile.ZipFile(zip_file) + except zipfile.BadZipFile: + return {"success": False, "error": "Invalid zip file"} + + tmp_root = tempfile.mkdtemp(prefix="plugin_import_") + try: + file_members = [m for m in zf.infolist() if not m.is_dir()] + if not file_members: + return {"success": False, "error": "Archive is empty"} + if len(file_members) > MAX_PLUGIN_IMPORT_FILES: + return {"success": False, "error": "Archive has too many files"} + + total_size = 0 + for member in file_members: + total_size += member.file_size + if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES: + return {"success": False, "error": "Archive contains a file that is too large"} + if total_size > MAX_PLUGIN_IMPORT_BYTES: + return {"success": False, "error": "Archive is too large"} + + for member in file_members: + name = member.filename + if not name or name.endswith("/"): + continue + norm = os.path.normpath(name) + if norm.startswith("..") or os.path.isabs(norm): + return {"success": False, "error": "Unsafe path in archive"} + dest_path = os.path.join(tmp_root, norm) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with zf.open(member, "r") as src, open(dest_path, "wb") as dst: + shutil.copyfileobj(src, dst) + + # Find candidate directory containing plugin.py or __init__.py + candidates = [] + for dirpath, dirnames, filenames in os.walk(tmp_root): + has_pluginpy = "plugin.py" in filenames + has_init = "__init__.py" in filenames + if has_pluginpy or has_init: + depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) + candidates.append((0 if has_pluginpy else 1, depth, dirpath)) + if not candidates: + return {"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"} + + candidates.sort() + chosen = candidates[0][2] + + # Determine plugin key + base_name = os.path.splitext(file_name)[0] + plugin_key = os.path.basename(chosen.rstrip(os.sep)) + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + plugin_key = base_name + plugin_key = _sanitize_plugin_key(plugin_key) + if len(plugin_key) > 128: + plugin_key = plugin_key[:128] + + # Extract logo + logo_bytes = None try: - return [ - perm() for perm in permission_classes_by_method[self.request.method] - ] - except KeyError: - return [Authenticated()] - - def post(self, request): - file: UploadedFile = request.FILES.get("file") - if not file: - return Response({"success": False, "error": "Missing 'file' upload"}, status=status.HTTP_400_BAD_REQUEST) - - pm = PluginManager.get() - plugins_dir = pm.plugins_dir - - try: - zf = zipfile.ZipFile(file) - except zipfile.BadZipFile: - return Response({"success": False, "error": "Invalid zip file"}, status=status.HTTP_400_BAD_REQUEST) - - # Extract to a temporary directory first to avoid server reload thrash - tmp_root = tempfile.mkdtemp(prefix="plugin_import_") - try: - file_members = [m for m in zf.infolist() if not m.is_dir()] - if not file_members: - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST) - if len(file_members) > MAX_PLUGIN_IMPORT_FILES: - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": "Archive has too many files"}, status=status.HTTP_400_BAD_REQUEST) - - total_size = 0 - for member in file_members: - total_size += member.file_size - if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES: - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": "Archive contains a file that is too large"}, status=status.HTTP_400_BAD_REQUEST) - if total_size > MAX_PLUGIN_IMPORT_BYTES: - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": "Archive is too large"}, status=status.HTTP_400_BAD_REQUEST) - - for member in file_members: - name = member.filename - if not name or name.endswith("/"): - continue - # Normalize and prevent path traversal - norm = os.path.normpath(name) - if norm.startswith("..") or os.path.isabs(norm): - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": "Unsafe path in archive"}, status=status.HTTP_400_BAD_REQUEST) - dest_path = os.path.join(tmp_root, norm) - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - with zf.open(member, 'r') as src, open(dest_path, 'wb') as dst: - shutil.copyfileobj(src, dst) - - # Find candidate directory containing plugin.py or __init__.py - candidates = [] - for dirpath, dirnames, filenames in os.walk(tmp_root): - has_pluginpy = "plugin.py" in filenames - has_init = "__init__.py" in filenames - if has_pluginpy or has_init: + chosen_abs = os.path.abspath(chosen) + logo_candidates = [] + for dirpath, _, filenames in os.walk(tmp_root): + for filename in filenames: + if filename.lower() != "logo.png": + continue + full_path = os.path.join(dirpath, filename) + full_abs = os.path.abspath(full_path) + try: + in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs + except Exception: + in_chosen = False depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) - candidates.append((0 if has_pluginpy else 1, depth, dirpath)) - if not candidates: - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"}, status=status.HTTP_400_BAD_REQUEST) - - candidates.sort() - chosen = candidates[0][2] - # Determine plugin key: prefer chosen folder name; if chosen is tmp_root, use zip base name - base_name = os.path.splitext(getattr(file, "name", "plugin"))[0] - plugin_key = os.path.basename(chosen.rstrip(os.sep)) - if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): - plugin_key = base_name - plugin_key = _sanitize_plugin_key(plugin_key) - if len(plugin_key) > 128: - plugin_key = plugin_key[:128] + logo_candidates.append((0 if in_chosen else 1, depth, full_path)) + if logo_candidates: + logo_candidates.sort() + with open(logo_candidates[0][2], "rb") as fh: + logo_bytes = fh.read() + except Exception: logo_bytes = None - try: - logo_candidates = [] - chosen_abs = os.path.abspath(chosen) - for dirpath, _, filenames in os.walk(tmp_root): - for filename in filenames: - if filename.lower() != "logo.png": - continue - full_path = os.path.join(dirpath, filename) - full_abs = os.path.abspath(full_path) - try: - in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs - except Exception: - in_chosen = False - depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) - logo_candidates.append((0 if in_chosen else 1, depth, full_path)) - if logo_candidates: - logo_candidates.sort() - with open(logo_candidates[0][2], "rb") as fh: - logo_bytes = fh.read() - except Exception: - logo_bytes = None - final_dir = os.path.join(plugins_dir, plugin_key) - if os.path.exists(final_dir): - # If final dir exists but contains a valid plugin, refuse; otherwise clear it - if os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")): - shutil.rmtree(tmp_root, ignore_errors=True) - return Response({"success": False, "error": f"Plugin '{plugin_key}' already exists"}, status=status.HTTP_400_BAD_REQUEST) + final_dir = os.path.join(plugins_dir, plugin_key) + should_overwrite = (allow_overwrite_key and plugin_key == allow_overwrite_key) or allow_overwrite + if os.path.exists(final_dir): + if should_overwrite: + # Atomic swap: rename old to backup, move new in, delete backup + backup_dir = final_dir + ".__backup__" + try: + if os.path.exists(backup_dir): + shutil.rmtree(backup_dir) + os.rename(final_dir, backup_dir) + except Exception as e: + return {"success": False, "error": f"Failed to back up existing plugin: {e}"} + try: + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + os.makedirs(final_dir, exist_ok=True) + for item in os.listdir(tmp_root): + shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item)) + else: + shutil.move(chosen, final_dir) + if logo_bytes: + try: + with open(os.path.join(final_dir, "logo.png"), "wb") as fh: + fh.write(logo_bytes) + except Exception: + pass + # Success - remove backup + shutil.rmtree(backup_dir, ignore_errors=True) + return {"success": True, "plugin_key": plugin_key} + except Exception as e: + # Rollback: restore backup + logger.exception("Failed to install updated plugin; rolling back") + shutil.rmtree(final_dir, ignore_errors=True) + try: + os.rename(backup_dir, final_dir) + except Exception: + logger.exception("Failed to rollback plugin backup") + return {"success": False, "error": f"Failed to install updated plugin: {e}"} + elif os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")): + return {"success": False, "error": f"Plugin '{plugin_key}' already exists"} + else: try: shutil.rmtree(final_dir) except Exception: pass - # Move chosen directory into final location - if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): - # Move all contents into final_dir - os.makedirs(final_dir, exist_ok=True) - for item in os.listdir(tmp_root): - shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item)) - else: - shutil.move(chosen, final_dir) - if logo_bytes: - try: - with open(os.path.join(final_dir, "logo.png"), "wb") as fh: - fh.write(logo_bytes) - except Exception: - pass - # Cleanup temp - shutil.rmtree(tmp_root, ignore_errors=True) - target_dir = final_dir - finally: + # Move plugin files into final location + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + os.makedirs(final_dir, exist_ok=True) + for item in os.listdir(tmp_root): + shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item)) + else: + shutil.move(chosen, final_dir) + + if logo_bytes: try: - shutil.rmtree(tmp_root, ignore_errors=True) + with open(os.path.join(final_dir, "logo.png"), "wb") as fh: + fh.write(logo_bytes) except Exception: pass + return {"success": True, "plugin_key": plugin_key} + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + +def _save_fetched_manifest_to_repo(repo, data, verified): + """Validate and persist a freshly-fetched manifest onto a PluginRepo. + + Validates that 'registry_name' is present and not official-sounding (for + non-official repos). On success, updates repo fields and saves to DB. + + Returns an error string if validation fails, or None on success. + Does *not* call _unmanage_dropped_slugs — caller does that when needed. + """ + manifest_inner = data.get("manifest", data) + registry_name = (manifest_inner.get("registry_name") or "").strip() + if not registry_name: + return "Manifest is missing a 'registry_name'. The repo maintainer must set this field." + if not repo.is_official and _is_official_sounding(registry_name): + return f"The registry name '{registry_name}' is not allowed because it may be confused with an official repo." + repo.cached_manifest = data + repo.last_fetched = timezone.now() + repo.last_fetch_status = "200" + repo.name = registry_name + repo.signature_verified = verified + repo.save(update_fields=["name", "cached_manifest", "signature_verified", "last_fetched", "last_fetch_status", "updated_at"]) + return None + + +def _unmanage_dropped_slugs(repo, new_manifest_data): + """After a manifest refresh, clear source_repo on any installed plugins + whose slug is no longer listed in the repo's manifest. Also syncs the + 'deprecated' flag for all plugins that remain managed by this repo.""" + manifest = new_manifest_data.get("manifest", new_manifest_data) + plugin_entries = {p["slug"]: p for p in manifest.get("plugins", []) if p.get("slug")} + current_slugs = set(plugin_entries.keys()) + + dropped = PluginConfig.objects.filter(source_repo=repo).exclude(slug__in=current_slugs) + count = dropped.update(source_repo=None, slug="", deprecated=False) + if count: + logger.info( + "Unmanaged %d plugin(s) removed from repo '%s' manifest", + count, repo.name, + ) + + # Sync deprecated flag for retained managed plugins + for cfg in PluginConfig.objects.filter(source_repo=repo, slug__in=current_slugs): + new_deprecated = bool(plugin_entries.get(cfg.slug, {}).get("deprecated", False)) + if cfg.deprecated != new_deprecated: + cfg.deprecated = new_deprecated + cfg.save(update_fields=["deprecated", "updated_at"]) + + +class PluginImportAPIView(PluginAuthMixin, APIView): + def post(self, request): + file: UploadedFile = request.FILES.get("file") + if not file: + return Response({"success": False, "error": "Missing 'file' upload"}, status=status.HTTP_400_BAD_REQUEST) + + # Manual imports default to non-overwrite; require explicit flag to replace existing plugins + overwrite_flag = bool(request.data.get("overwrite")) + + pm = PluginManager.get() + result = _install_plugin_from_zip( + file, pm.plugins_dir, + file_name=getattr(file, "name", "plugin.zip"), + allow_overwrite=overwrite_flag, + ) + if not result["success"]: + return Response( + {"success": False, "error": result["error"]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + plugin_key = result["plugin_key"] + # Ensure DB config exists (untrusted plugins are registered without loading) + was_managed = False try: cfg, _ = PluginConfig.objects.get_or_create( key=plugin_key, @@ -241,6 +395,13 @@ class PluginImportAPIView(APIView): "settings": {}, }, ) + # Manual install always breaks the managed relationship + if cfg and cfg.source_repo_id: + was_managed = True + cfg.source_repo = None + cfg.slug = "" + cfg.save(update_fields=["source_repo", "slug", "updated_at"]) + logger.info("Plugin '%s' manually replaced - cleared managed repo link", plugin_key) except Exception: cfg = None @@ -253,9 +414,9 @@ class PluginImportAPIView(APIView): plugin_entry = None if not plugin_entry: - logo_path = os.path.join(plugins_dir, plugin_key, "logo.png") + logo_path = os.path.join(pm.plugins_dir, plugin_key, "logo.png") logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None - legacy = not os.path.isfile(os.path.join(plugins_dir, plugin_key, "plugin.json")) + legacy = not os.path.isfile(os.path.join(pm.plugins_dir, plugin_key, "plugin.json")) plugin_entry = { "key": plugin_key, "name": cfg.name if cfg else plugin_key, @@ -273,18 +434,10 @@ class PluginImportAPIView(APIView): } plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) - return Response({"success": True, "plugin": plugin_entry}) + return Response({"success": True, "plugin": plugin_entry, "was_managed": was_managed}) -class PluginSettingsAPIView(APIView): - def get_permissions(self): - try: - return [ - perm() for perm in permission_classes_by_method[self.request.method] - ] - except KeyError: - return [Authenticated()] - +class PluginSettingsAPIView(PluginAuthMixin, APIView): def post(self, request, key): pm = PluginManager.get() data = request.data or {} @@ -296,15 +449,7 @@ class PluginSettingsAPIView(APIView): return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST) -class PluginRunAPIView(APIView): - def get_permissions(self): - try: - return [ - perm() for perm in permission_classes_by_method[self.request.method] - ] - except KeyError: - return [Authenticated()] - +class PluginRunAPIView(PluginAuthMixin, APIView): def post(self, request, key): pm = PluginManager.get() action = request.data.get("action") @@ -330,15 +475,7 @@ class PluginRunAPIView(APIView): return Response({"success": False, "error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -class PluginEnabledAPIView(APIView): - def get_permissions(self): - try: - return [ - perm() for perm in permission_classes_by_method[self.request.method] - ] - except KeyError: - return [Authenticated()] - +class PluginEnabledAPIView(PluginAuthMixin, APIView): def post(self, request, key): enabled_raw = request.data.get("enabled") if enabled_raw is None: @@ -397,15 +534,7 @@ class PluginLogoAPIView(APIView): return FileResponse(open(logo_path, "rb"), content_type="image/png") -class PluginDeleteAPIView(APIView): - def get_permissions(self): - try: - return [ - perm() for perm in permission_classes_by_method[self.request.method] - ] - except KeyError: - return [Authenticated()] - +class PluginDeleteAPIView(PluginAuthMixin, APIView): def delete(self, request, key): pm = PluginManager.get() try: @@ -436,3 +565,753 @@ class PluginDeleteAPIView(APIView): # Reload registry pm.discover_plugins(force_reload=True) return Response({"success": True}) + + +# --------------------------------------------------------------------------- +# Plugin Repo (Hub / Store) views +# --------------------------------------------------------------------------- + +MANIFEST_FETCH_TIMEOUT = 15 + +OFFICIAL_KEY_PATH = os.path.join( + os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub" +) + + +def _normalize_pgp_key(text): + """Ensure PGP public key text has armor header/footer.""" + if not text or not text.strip(): + return text + text = text.strip() + if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text: + text = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" + text + if "-----END PGP PUBLIC KEY BLOCK-----" not in text: + text = text + "\n-----END PGP PUBLIC KEY BLOCK-----" + return text + + +def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None): + """Verify a detached GPG signature over the canonical manifest JSON. + + *signature_armored* is the PGP armored signature string from the manifest. + *public_key_text* is an armored PGP public-key string (for third-party + repos). When *None* the bundled official key is used instead. + + Returns True if valid, False if invalid/error, None if verification + could not be attempted (no signature, no key, gnupg missing, etc.). + """ + if not signature_armored: + return None + + # Determine which key material to use + key_text = None + if public_key_text: + key_text = _normalize_pgp_key(public_key_text) + elif os.path.isfile(OFFICIAL_KEY_PATH): + with open(OFFICIAL_KEY_PATH, "r") as fh: + key_text = fh.read() + + if not key_text: + logger.debug("No GPG public key available; skipping verification") + return None + + try: + import gnupg + except ImportError: + logger.debug("python-gnupg not installed; skipping signature verification") + return None + + tmp_home = tempfile.mkdtemp(prefix="gpg_verify_") + try: + gpg = gnupg.GPG(gnupghome=tmp_home) + import_result = gpg.import_keys(key_text) + if not import_result.fingerprints: + logger.warning("Failed to import GPG public key") + return None + + # Must match what the signing script produces: jq -c '.manifest' + # which uses compact separators, preserves key order, and appends \n. + manifest_bytes = ( + json.dumps(manifest_obj, separators=(",", ":")) + "\n" + ).encode("utf-8") + + # Write the PGP armored signature directly to file + sig_path = os.path.join(tmp_home, "manifest.sig") + with open(sig_path, "w") as sf: + sf.write(signature_armored) + + verified = gpg.verify_data(sig_path, manifest_bytes) + return bool(verified) + except Exception: + logger.exception("GPG signature verification error") + return False + finally: + shutil.rmtree(tmp_home, ignore_errors=True) + + +_OFFICIAL_NAME_PATTERNS = [ + "official", + "official repo", + "dispatcharr plugins", + "dispatcharr repo", + "dispatcharr official", +] + + +def _is_official_sounding(name): + """Return True if the name could be mistaken for an official repo.""" + lower = (name or "").lower().strip() + return any(pat in lower for pat in _OFFICIAL_NAME_PATTERNS) + + +def _fetch_manifest(url, public_key_text=None): + """Fetch a remote manifest JSON, validate structure, return (data, verified).""" + _validate_fetch_url(url) + resp = http_requests.get(url, timeout=MANIFEST_FETCH_TIMEOUT) + resp.raise_for_status() + data = resp.json() + # Accept both top-level {manifest: {plugins: [...]}} and {plugins: [...]} + if "manifest" in data and "plugins" in data["manifest"]: + signature = data.get("signature") + verified = _verify_manifest_signature( + data["manifest"], signature, public_key_text + ) + return data, verified + if "plugins" in data: + return {"manifest": data}, None + raise ValueError("Manifest JSON missing 'manifest.plugins' list") + + +class PluginRepoListCreateAPIView(PluginAuthMixin, APIView): + @extend_schema( + description="List all plugin repositories.", + responses={200: PluginRepoSerializer(many=True)}, + ) + def get(self, request): + repos = PluginRepo.objects.all() + return Response(PluginRepoSerializer(repos, many=True).data) + + @extend_schema( + description="Add a new plugin repository by manifest URL. Fetches and validates the manifest immediately.", + request=PluginRepoSerializer, + responses={201: PluginRepoSerializer, 400: inline_serializer(name="RepoAddError", fields={"error": serializers.CharField()})}, + ) + def post(self, request): + serializer = PluginRepoSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + repo = serializer.save(name="") + # Fetch manifest and validate + save + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + except Exception as e: + logger.warning("Initial manifest fetch failed for %s: %s", repo.url, e) + repo.delete() + return Response( + {"error": f"Failed to fetch manifest: {e}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + repo.delete() + return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) + return Response( + PluginRepoSerializer(repo).data, status=status.HTTP_201_CREATED + ) + + +class PluginRepoPreviewAPIView(PluginAuthMixin, APIView): + """Fetch and validate a manifest URL without saving anything.""" + + @extend_schema( + description="Preview a manifest URL: fetch and validate without saving. Returns validity, repo name, signature status, and plugin count.", + request=inline_serializer(name="RepoPreviewRequest", fields={ + "url": serializers.URLField(), + "public_key": serializers.CharField(required=False, allow_blank=True), + }), + responses={200: inline_serializer(name="RepoPreviewResponse", fields={ + "valid": serializers.BooleanField(), + "registry_name": serializers.CharField(), + "registry_url": serializers.CharField(), + "signature_verified": serializers.BooleanField(allow_null=True), + "plugin_count": serializers.IntegerField(), + "errors": serializers.ListField(child=serializers.CharField()), + })}, + ) + def post(self, request): + url = (request.data.get("url") or "").strip() + public_key = (request.data.get("public_key") or "").strip() + if not url: + return Response( + {"error": "url is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + key_text = public_key or None + data, verified = _fetch_manifest(url, public_key_text=key_text) + manifest_inner = data.get("manifest", data) + registry_name = (manifest_inner.get("registry_name") or "").strip() + registry_url = (manifest_inner.get("registry_url") or "").strip() + plugin_count = len(manifest_inner.get("plugins", [])) + errors = [] + if not registry_name: + errors.append("Manifest is missing a 'registry_name'.") + elif _is_official_sounding(registry_name): + errors.append(f"The registry name '{registry_name}' is not allowed because it may be confused with an official repo.") + if PluginRepo.objects.filter(url=url).exists(): + errors.append("This manifest URL has already been added.") + return Response({ + "valid": len(errors) == 0, + "registry_name": registry_name, + "registry_url": registry_url, + "signature_verified": verified, + "plugin_count": plugin_count, + "errors": errors, + }) + except http_requests.exceptions.Timeout: + return Response( + {"valid": False, "errors": ["The request timed out. Check the URL and try again."]}, + status=status.HTTP_200_OK, + ) + except http_requests.exceptions.ConnectionError: + return Response( + {"valid": False, "errors": ["Could not connect to the server. Check the URL and your network connection."]}, + status=status.HTTP_200_OK, + ) + except http_requests.exceptions.HTTPError as e: + code = e.response.status_code if e.response is not None else None + if code == 404: + msg = "Manifest not found (404). Check that the URL points to a valid manifest file." + elif code == 403: + msg = "Access denied (403). The server refused the request." + elif code is not None: + msg = f"The server returned an error ({code}). Check the URL and try again." + else: + msg = "The server returned an unexpected error. Check the URL and try again." + return Response( + {"valid": False, "errors": [msg]}, + status=status.HTTP_200_OK, + ) + except (json.JSONDecodeError, ValueError) as e: + msg = str(e) + # Pass through messages from _validate_fetch_url and _fetch_manifest + # as-is; only substitute the generic JSON message for actual parse errors. + if "missing" in msg.lower() and "plugins" in msg.lower(): + friendly = msg + elif any(kw in msg.lower() for kw in ("non-routable", "scheme", "hostname", "resolve")): + friendly = msg + else: + friendly = "The URL did not return valid JSON. Make sure it points directly to a manifest .json file." + return Response( + {"valid": False, "errors": [friendly]}, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + {"valid": False, "errors": ["An unexpected error occurred. Check the URL and try again."]}, + status=status.HTTP_200_OK, + ) + + +class PluginRepoDetailAPIView(PluginAuthMixin, APIView): + @extend_schema( + description="Update a plugin repository (e.g. public key).", + request=PluginRepoSerializer, + responses={200: PluginRepoSerializer, 404: inline_serializer(name="RepoNotFound", fields={"error": serializers.CharField()})}, + ) + def put(self, request, pk): + try: + repo = PluginRepo.objects.get(pk=pk) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + # Only public_key and enabled are mutable after creation. + # url, is_official, name, etc. must not be changed via the API. + ALLOWED_FIELDS = {"public_key", "enabled"} + update_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS} + serializer = PluginRepoSerializer(repo, data=update_data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(PluginRepoSerializer(repo).data) + + @extend_schema( + description="Remove a plugin repository.", + responses={200: inline_serializer(name="RepoDeleteSuccess", fields={"success": serializers.BooleanField()}), 404: inline_serializer(name="RepoDeleteNotFound", fields={"error": serializers.CharField()})}, + ) + def delete(self, request, pk): + try: + repo = PluginRepo.objects.get(pk=pk) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + if repo.is_official: + return Response( + {"error": "Cannot delete the official repository"}, + status=status.HTTP_400_BAD_REQUEST, + ) + repo.delete() + return Response({"success": True}) + + +class PluginRepoRefreshAPIView(PluginAuthMixin, APIView): + @extend_schema( + description="Re-fetch and update the cached manifest for a plugin repository.", + request=None, + responses={200: PluginRepoSerializer, 404: inline_serializer(name="RepoRefreshNotFound", fields={"error": serializers.CharField()}), 502: inline_serializer(name="RepoRefreshError", fields={"error": serializers.CharField()})}, + ) + def post(self, request, pk): + try: + repo = PluginRepo.objects.get(pk=pk) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + except Exception as e: + logger.exception("Manifest fetch failed for %s", repo.url) + return Response( + {"error": f"Failed to fetch manifest: {e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) + _unmanage_dropped_slugs(repo, data) + return Response(PluginRepoSerializer(repo).data) + + +class AvailablePluginsAPIView(PluginAuthMixin, APIView): + """Aggregate plugins from all enabled repo manifests.""" + + @extend_schema( + description="Return the aggregated list of available plugins from all enabled repositories, annotated with installation status.", + responses={200: inline_serializer(name="AvailablePluginsResponse", fields={ + "plugins": serializers.ListField(child=serializers.DictField()), + })}, + ) + def get(self, request): + repos = PluginRepo.objects.filter(enabled=True) + + # Auto-fetch manifest for repos that have never been refreshed + for repo in repos: + if not repo.cached_manifest: + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + logger.warning("Skipping repo %s: %s", repo.url, err) + except Exception: + pass + + # Re-read in case we just updated + repos = PluginRepo.objects.filter(enabled=True) + configs = list(PluginConfig.objects.select_related("source_repo").all()) + # Build lookup: slug -> (version, repo_id, repo_name) for managed plugins, + # plus key -> version for all plugins (legacy matching) + installed_by_slug = {} + installed_by_key = {} + # Secondary dict keyed by dash-to-underscore-normalized key, for backward compat + # with existing DB entries that were saved before normalization was enforced. + installed_by_key_norm = {} + for cfg in configs: + installed_by_key[cfg.key] = cfg.version + installed_by_key_norm[cfg.key.replace("-", "_")] = cfg.key + if cfg.slug: + installed_by_slug[cfg.slug] = { + "version": cfg.version, + "source_repo_id": cfg.source_repo_id, + "source_repo_name": cfg.source_repo.name if cfg.source_repo else None, + "is_prerelease": cfg.installed_version_is_prerelease, + } + # Also index by normalized slug so a dash-variant in the manifest still matches + norm_slug = cfg.slug.replace("-", "_") + if norm_slug not in installed_by_slug: + installed_by_slug[norm_slug] = installed_by_slug[cfg.slug] + + plugins = [] + for repo in repos: + manifest_data = repo.cached_manifest or {} + manifest = manifest_data.get("manifest", manifest_data) + root_url = manifest.get("root_url", "").rstrip("/") + registry_url = manifest.get("registry_url", "").rstrip("/") + repo_plugins = manifest.get("plugins", []) + for p in repo_plugins: + slug = p.get("slug", "") + plugin_data = {**p} + # Resolve relative URLs against root_url; absolute URLs pass through + if root_url: + for url_field in ("manifest_url", "latest_url", "icon_url"): + val = plugin_data.get(url_field, "") + if val and not val.startswith(("http://", "https://")): + plugin_data[url_field] = f"{root_url}/{val}" + # Fallback icon_url from main branch when not provided + if not plugin_data.get("icon_url") and registry_url: + # registry_url is e.g. https://github.com/Dispatcharr/Plugins + # Convert to raw.githubusercontent.com URL for the main branch + raw_base = registry_url.replace( + "https://github.com/", "https://raw.githubusercontent.com/" + ) + plugin_data["icon_url"] = f"{raw_base}/refs/heads/main/plugins/{slug}/logo.png" + # Determine install status + managed = installed_by_slug.get(slug) or installed_by_slug.get(slug.replace("-", "_")) + sanitized_slug = _sanitize_plugin_key(slug) + key_match = sanitized_slug in installed_by_key or sanitized_slug in installed_by_key_norm + if managed: + is_installed = True + installed_version = managed["version"] + latest = plugin_data.get("latest_version") + if managed["source_repo_id"] == repo.id: + if installed_version and latest and installed_version != latest and not managed.get("is_prerelease"): + install_status = "update_available" + else: + install_status = "installed" + else: + install_status = "different_repo" + elif key_match: + is_installed = True + installed_version = installed_by_key.get(sanitized_slug) or installed_by_key.get( + installed_by_key_norm.get(sanitized_slug, sanitized_slug) + ) + install_status = "unmanaged" + else: + is_installed = False + installed_version = None + install_status = "not_installed" + entry = { + **plugin_data, + "repo_id": repo.id, + "repo_name": repo.name, + "is_official_repo": repo.is_official, + "signature_verified": repo.signature_verified, + "installed": is_installed, + "installed_version": installed_version, + "installed_version_is_prerelease": managed.get("is_prerelease", False) if managed else False, + "install_status": install_status, + "key": _sanitize_plugin_key(slug), + } + if install_status == "different_repo": + entry["installed_source_repo_name"] = managed["source_repo_name"] + plugins.append(entry) + return Response({"plugins": plugins}) + + +class PluginDetailManifestAPIView(PluginAuthMixin, APIView): + """Fetch and verify a per-plugin manifest given repo_id and manifest_url.""" + + @extend_schema( + description="Fetch and GPG-verify a per-plugin manifest from a repo, resolving relative URLs against the repo root.", + request=inline_serializer(name="PluginDetailManifestRequest", fields={ + "repo_id": serializers.IntegerField(), + "manifest_url": serializers.URLField(), + }), + responses={200: inline_serializer(name="PluginDetailManifestResponse", fields={ + "manifest": serializers.DictField(), + "signature_verified": serializers.BooleanField(allow_null=True), + }), 502: inline_serializer(name="PluginDetailManifestError", fields={"error": serializers.CharField()})}, + ) + def post(self, request): + repo_id = request.data.get("repo_id") + manifest_url = request.data.get("manifest_url") + if not repo_id or not manifest_url: + return Response( + {"error": "repo_id and manifest_url are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + repo = PluginRepo.objects.get(pk=repo_id) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + try: + _validate_fetch_url(manifest_url) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT) + resp.raise_for_status() + data = resp.json() + + signature = data.get("signature") + manifest_obj = data.get("manifest", data) + verified = _verify_manifest_signature( + manifest_obj, signature, + repo.public_key if not repo.is_official else None + ) + + # Resolve relative URLs in versions + repo_manifest = repo.cached_manifest or {} + inner = repo_manifest.get("manifest", repo_manifest) + root_url = inner.get("root_url", "").rstrip("/") + + if root_url and isinstance(manifest_obj.get("versions"), list): + for v in manifest_obj["versions"]: + url_val = v.get("url", "") + if url_val and not url_val.startswith(("http://", "https://")): + v["url"] = f"{root_url}/{url_val}" + if root_url and isinstance(manifest_obj.get("latest"), dict): + for url_field in ("url", "latest_url"): + url_val = manifest_obj["latest"].get(url_field, "") + if url_val and not url_val.startswith(("http://", "https://")): + manifest_obj["latest"][url_field] = f"{root_url}/{url_val}" + + return Response({ + "manifest": manifest_obj, + "signature_verified": verified, + }) + except Exception as e: + logger.exception("Failed to fetch plugin manifest from %s", manifest_url) + return Response( + {"error": f"Failed to fetch plugin manifest: {e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + +class PluginInstallFromRepoAPIView(PluginAuthMixin, APIView): + """Install a plugin from a managed repo by downloading its release zip.""" + + @extend_schema( + description="Download and install a plugin release zip from a managed repository. Verifies SHA256 if provided.", + request=inline_serializer(name="PluginInstallFromRepoRequest", fields={ + "repo_id": serializers.IntegerField(), + "slug": serializers.CharField(), + "version": serializers.CharField(), + "download_url": serializers.URLField(), + "sha256": serializers.CharField(required=False, allow_blank=True), + "min_dispatcharr_version": serializers.CharField(required=False, allow_blank=True), + "max_dispatcharr_version": serializers.CharField(required=False, allow_blank=True), + }), + responses={ + 200: inline_serializer(name="PluginInstallFromRepoResponse", fields={"success": serializers.BooleanField(), "plugin": serializers.DictField()}), + 201: inline_serializer(name="PluginInstallFromRepoCreated", fields={"success": serializers.BooleanField(), "plugin": serializers.DictField()}), + 400: inline_serializer(name="PluginInstallFromRepoError", fields={"error": serializers.CharField()}), + }, + ) + def post(self, request): + repo_id = request.data.get("repo_id") + slug = request.data.get("slug") + version = request.data.get("version") + download_url = request.data.get("download_url") + + if not all([repo_id, slug, version, download_url]): + return Response( + {"error": "repo_id, slug, version, and download_url are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + repo = PluginRepo.objects.get(pk=repo_id) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + + # Resolve the plugin key and look up any existing install + plugin_key = _sanitize_plugin_key(slug) + if len(plugin_key) > 128: + plugin_key = plugin_key[:128] + + existing_cfg = PluginConfig.objects.filter(key=plugin_key).first() + # Backward compat: if no match, also try with dashes (legacy entries saved before + # normalization was enforced) so overwrite is still allowed on update. + if not existing_cfg: + dash_key = plugin_key.replace("_", "-") + if dash_key != plugin_key: + existing_cfg = PluginConfig.objects.filter(key=dash_key).first() + + # Version compatibility check against the running Dispatcharr version + min_version = request.data.get("min_dispatcharr_version") + max_version = request.data.get("max_dispatcharr_version") + if min_version or max_version: + from version import __version__ as app_version + try: + if min_version and _compare_versions(app_version, min_version) < 0: + return Response( + {"error": f"This plugin version requires Dispatcharr {min_version} or newer (you have {app_version})"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if max_version and _compare_versions(app_version, max_version) > 0: + return Response( + {"error": f"This plugin version requires Dispatcharr {max_version} or older (you have {app_version})"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (ValueError, TypeError): + logger.warning("Failed to parse version constraints: min=%s, max=%s", min_version, max_version) + + # Download the zip + try: + _validate_fetch_url(download_url) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = http_requests.get(download_url, timeout=60, stream=True) + resp.raise_for_status() + except Exception as e: + logger.exception("Failed to download plugin from %s", download_url) + return Response( + {"error": f"Failed to download plugin: {e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + # SHA256 integrity check (streamed) + expected_sha256 = request.data.get("sha256", "").lower().strip() + hasher = hashlib.sha256() if expected_sha256 else None + + # Stream the response to a temporary file to avoid buffering in memory + with tempfile.NamedTemporaryFile(suffix=".zip") as tmp_file: + total = 0 + for chunk in resp.iter_content(chunk_size=8192): + if not chunk: + continue + total += len(chunk) + if total > MAX_PLUGIN_IMPORT_BYTES: + return Response( + {"error": "Download is too large"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if hasher is not None: + hasher.update(chunk) + tmp_file.write(chunk) + + if expected_sha256: + actual_sha256 = hasher.hexdigest() + if actual_sha256 != expected_sha256: + logger.warning( + "SHA256 mismatch for plugin '%s' from %s: expected %s, got %s", + slug, download_url, expected_sha256, actual_sha256, + ) + return Response( + { + "error": "SHA256 integrity check failed - download discarded. The file may be corrupted or tampered with." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Delegate to shared install logic (allow overwrite for managed updates) + tmp_file.flush() + tmp_file.seek(0) + pm = PluginManager.get() + result = _install_plugin_from_zip( + tmp_file, + pm.plugins_dir, + file_name=f"{slug}.zip", + allow_overwrite_key=plugin_key if existing_cfg else None, + ) + if not result["success"]: + return Response( + {"success": False, "error": result["error"]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + actual_key = result["plugin_key"] + + # Create/update PluginConfig with managed fields + # Use defaults for creation only; explicitly update fields on existing records + # to preserve settings, enabled, and ever_enabled + is_prerelease = bool(request.data.get("prerelease", False)) + + # Determine deprecated status from the repo's cached manifest + is_deprecated = False + manifest_data = repo.cached_manifest or {} + manifest_inner = manifest_data.get("manifest", manifest_data) + for rp in manifest_inner.get("plugins", []): + if rp.get("slug") == slug: + is_deprecated = bool(rp.get("deprecated", False)) + break + + cfg, created = PluginConfig.objects.get_or_create( + key=actual_key, + defaults={ + "name": slug, + "version": version, + "slug": slug, + "source_repo": repo, + "installed_version_is_prerelease": is_prerelease, + "deprecated": is_deprecated, + }, + ) + if not created: + cfg.version = version + cfg.slug = slug + cfg.source_repo = repo + cfg.installed_version_is_prerelease = is_prerelease + cfg.deprecated = is_deprecated + cfg.save(update_fields=["version", "slug", "source_repo", "installed_version_is_prerelease", "deprecated", "updated_at"]) + + # Reload discovery + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next( + (p for p in pm.list_plugins() if p.get("key") == actual_key), + None, + ) + except Exception: + plugin_entry = None + + return Response( + { + "success": True, + "plugin": plugin_entry or {"key": actual_key, "slug": slug, "version": version}, + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +class PluginRepoSettingsAPIView(PluginAuthMixin, APIView): + """Get/update plugin repo refresh settings (interval in hours, 0=disabled).""" + + @extend_schema( + description="Get the plugin repository refresh interval setting.", + responses={200: inline_serializer(name="PluginRepoSettingsResponse", fields={"refresh_interval_hours": serializers.IntegerField()})}, + ) + def get(self, request): + from core.models import CoreSettings + try: + obj = CoreSettings.objects.get(key="plugin_repo_settings") + return Response(obj.value) + except CoreSettings.DoesNotExist: + return Response({"refresh_interval_hours": 6}) + + @extend_schema( + description="Update the plugin repository refresh interval (hours). Set to 0 to disable automatic refresh.", + request=inline_serializer(name="PluginRepoSettingsRequest", fields={"refresh_interval_hours": serializers.IntegerField()}), + responses={200: inline_serializer(name="PluginRepoSettingsUpdated", fields={"refresh_interval_hours": serializers.IntegerField()})}, + ) + def put(self, request): + from core.models import CoreSettings + from core.scheduling import create_or_update_periodic_task, delete_periodic_task + from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME + + interval = request.data.get("refresh_interval_hours", 6) + try: + interval = int(interval) + if interval < 0: + interval = 0 + except (TypeError, ValueError): + interval = 6 + + obj, _ = CoreSettings.objects.update_or_create( + key="plugin_repo_settings", + defaults={ + "name": "Plugin Repo Settings", + "value": {"refresh_interval_hours": interval}, + }, + ) + + if interval == 0: + delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME) + else: + create_or_update_periodic_task( + task_name=PLUGIN_REPO_REFRESH_TASK_NAME, + celery_task_path="apps.plugins.tasks.refresh_plugin_repos", + interval_hours=interval, + enabled=True, + ) + + return Response(obj.value) diff --git a/apps/plugins/apps.py b/apps/plugins/apps.py index 3ab44cb1..c2c0bce6 100644 --- a/apps/plugins/apps.py +++ b/apps/plugins/apps.py @@ -52,3 +52,53 @@ class PluginsConfig(AppConfig): import logging logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready") + + # Register periodic task for refreshing plugin repo manifests + self._setup_repo_refresh_schedule() + + # Refresh repo manifests once at startup so the UI always has current data + self._enqueue_startup_refresh() + + def _enqueue_startup_refresh(self): + from dispatcharr.app_initialization import should_skip_initialization + if should_skip_initialization(): + return + try: + from .tasks import refresh_plugin_repos + refresh_plugin_repos.apply_async(countdown=10) + except Exception: + import logging + logging.getLogger(__name__).debug( + "Could not enqueue startup plugin repo refresh (Celery may not be ready yet)" + ) + + def _setup_repo_refresh_schedule(self): + from dispatcharr.app_initialization import should_skip_initialization + if should_skip_initialization(): + return + try: + from core.scheduling import create_or_update_periodic_task, delete_periodic_task + from core.models import CoreSettings + from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME + + interval = 6 + try: + obj = CoreSettings.objects.get(key="plugin_repo_settings") + interval = obj.value.get("refresh_interval_hours", 6) + except CoreSettings.DoesNotExist: + pass + + if interval == 0: + delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME) + else: + create_or_update_periodic_task( + task_name=PLUGIN_REPO_REFRESH_TASK_NAME, + celery_task_path="apps.plugins.tasks.refresh_plugin_repos", + interval_hours=interval, + enabled=True, + ) + except Exception: + import logging + logging.getLogger(__name__).debug( + "Could not set up plugin repo refresh schedule (migrations may not have run yet)" + ) diff --git a/apps/plugins/keys/dispatcharr-plugins.pub b/apps/plugins/keys/dispatcharr-plugins.pub new file mode 100644 index 00000000..1f6ba60a --- /dev/null +++ b/apps/plugins/keys/dispatcharr-plugins.pub @@ -0,0 +1,11 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h +zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv +Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap +MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy +LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ +AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb +yDvGuoZi3I2/GNM/f2qVzFtgPQk= +=Zf/y +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 4b53e08b..6084b28e 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -367,21 +367,35 @@ class PluginManager: obj.save() def list_plugins(self) -> List[Dict[str, Any]]: - from .models import PluginConfig + from .models import PluginConfig, PluginRepo plugins: List[Dict[str, Any]] = [] with self._lock: registry_snapshot = dict(self._registry) try: - configs = {c.key: c for c in PluginConfig.objects.all()} + configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").all()} except Exception as e: # Database might not be migrated yet; fall back to registry only logger.warning("PluginConfig table unavailable; listing registry only: %s", e) configs = {} + # Build repo latest-version lookup from cached manifests + repo_latest = {} # slug -> latest_version + try: + for repo in PluginRepo.objects.filter(enabled=True): + manifest_data = repo.cached_manifest or {} + manifest = manifest_data.get("manifest", manifest_data) + for rp in manifest.get("plugins", []): + s = rp.get("slug", "") + if s: + repo_latest[s] = rp.get("latest_version", "") + except Exception: + pass + # First, include all discovered plugins for key, lp in registry_snapshot.items(): conf = configs.get(key) + conf_slug = conf.slug if conf else "" trusted = bool(conf and (conf.ever_enabled or conf.enabled)) logo_url = self._get_logo_url(key, path=lp.path) plugins.append( @@ -393,7 +407,7 @@ class PluginManager: "author": getattr(lp, "author", "") or "", "help_url": getattr(lp, "help_url", "") or "", "enabled": conf.enabled if conf else False, - "ever_enabled": getattr(conf, "ever_enabled", False) if conf else False, + "ever_enabled": conf.ever_enabled if conf else False, "fields": lp.fields or [], "settings": (conf.settings if conf else {}), "actions": lp.actions or [], @@ -402,6 +416,22 @@ class PluginManager: "loaded": bool(lp.loaded), "legacy": bool(getattr(lp, "legacy", False)), "logo_url": logo_url, + "source_repo": conf.source_repo_id if conf else None, + "source_repo_name": conf.source_repo.name if conf and conf.source_repo else None, + "is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official), + "slug": conf_slug, + "is_managed": bool(conf and conf.source_repo_id), + "installed_version_is_prerelease": bool( + conf and conf.installed_version_is_prerelease + ), + "update_available": bool( + conf_slug and conf and conf.source_repo_id + and not (conf and conf.installed_version_is_prerelease) + and repo_latest.get(conf_slug) + and lp.version != repo_latest.get(conf_slug) + ), + "latest_version": repo_latest.get(conf_slug, ""), + "deprecated": conf.deprecated if conf else False, } ) @@ -428,6 +458,22 @@ class PluginManager: "loaded": False, "legacy": False, "logo_url": self._get_logo_url(key), + "source_repo": conf.source_repo_id, + "source_repo_name": conf.source_repo.name if conf.source_repo else None, + "is_official_repo": bool(conf.source_repo and conf.source_repo.is_official), + "slug": conf.slug, + "is_managed": bool(conf.source_repo_id), + "installed_version_is_prerelease": bool( + conf.installed_version_is_prerelease + ), + "update_available": bool( + conf.slug and conf.source_repo_id + and not conf.installed_version_is_prerelease + and repo_latest.get(conf.slug) + and conf.version != repo_latest.get(conf.slug) + ), + "latest_version": repo_latest.get(conf.slug or "", ""), + "deprecated": conf.deprecated, } ) diff --git a/apps/plugins/migrations/0002_pluginrepo.py b/apps/plugins/migrations/0002_pluginrepo.py new file mode 100644 index 00000000..0b7a5196 --- /dev/null +++ b/apps/plugins/migrations/0002_pluginrepo.py @@ -0,0 +1,84 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def seed_official_repo(apps, schema_editor): + PluginRepo = apps.get_model("plugins", "PluginRepo") + PluginRepo.objects.get_or_create( + url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json", + defaults={ + "name": "Dispatcharr Official", + "is_official": True, + "enabled": True, + }, + ) + + +def unseed_official_repo(apps, schema_editor): + PluginRepo = apps.get_model("plugins", "PluginRepo") + PluginRepo.objects.filter(is_official=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("plugins", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PluginRepo", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("url", models.URLField(unique=True)), + ("is_official", models.BooleanField(default=False)), + ("enabled", models.BooleanField(default=True)), + ("cached_manifest", models.JSONField(blank=True, default=dict)), + ("last_fetched", models.DateTimeField(blank=True, null=True)), + ("public_key", models.TextField(blank=True, default="")), + ("signature_verified", models.BooleanField(blank=True, default=None, null=True)), + ("last_fetch_status", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["-is_official", "name"], + }, + ), + migrations.RunPython(seed_official_repo, unseed_official_repo), + migrations.AddField( + model_name="pluginconfig", + name="source_repo", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="installed_plugins", + to="plugins.pluginrepo", + ), + ), + migrations.AddField( + model_name="pluginconfig", + name="slug", + field=models.CharField(blank=True, default="", max_length=128), + ), + migrations.AddField( + model_name="pluginconfig", + name="installed_version_is_prerelease", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="pluginconfig", + name="deprecated", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/plugins/models.py b/apps/plugins/models.py index 8ae0b5be..f1960fd9 100644 --- a/apps/plugins/models.py +++ b/apps/plugins/models.py @@ -12,8 +12,52 @@ class PluginConfig(models.Model): # Tracks whether this plugin has ever been enabled at least once ever_enabled = models.BooleanField(default=False) settings = models.JSONField(default=dict, blank=True) + + # Managed plugin fields (populated when installed from a repo) + source_repo = models.ForeignKey( + "PluginRepo", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="installed_plugins", + ) + slug = models.CharField(max_length=128, blank=True, default="") + installed_version_is_prerelease = models.BooleanField(default=False) + deprecated = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + @property + def is_managed(self): + return bool(self.source_repo_id) + def __str__(self) -> str: return f"{self.name} ({self.key})" + + +OFFICIAL_REPO_URL = ( + "https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json" +) + + +class PluginRepo(models.Model): + """A remote plugin repository manifest URL.""" + + name = models.CharField(max_length=255) + url = models.URLField(unique=True) + is_official = models.BooleanField(default=False) + enabled = models.BooleanField(default=True) + cached_manifest = models.JSONField(default=dict, blank=True) + public_key = models.TextField(blank=True, default="") + signature_verified = models.BooleanField(null=True, blank=True, default=None) + last_fetched = models.DateTimeField(null=True, blank=True) + last_fetch_status = models.CharField(max_length=255, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-is_official", "name"] + + def __str__(self) -> str: + return self.name diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index 9f568054..ed2b57d7 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from .models import PluginRepo class PluginActionSerializer(serializers.Serializer): @@ -46,3 +47,40 @@ class PluginSerializer(serializers.Serializer): fields = PluginFieldSerializer(many=True) settings = serializers.JSONField() actions = PluginActionSerializer(many=True) + source_repo = serializers.IntegerField(required=False, allow_null=True) + slug = serializers.CharField(required=False, allow_blank=True) + is_managed = serializers.BooleanField(required=False) + deprecated = serializers.BooleanField(required=False) + + +class PluginRepoSerializer(serializers.ModelSerializer): + registry_url = serializers.SerializerMethodField() + plugin_count = serializers.SerializerMethodField() + + class Meta: + model = PluginRepo + fields = [ + "id", + "name", + "url", + "is_official", + "enabled", + "public_key", + "signature_verified", + "registry_url", + "plugin_count", + "last_fetched", + "last_fetch_status", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "name", "is_official", "signature_verified", "registry_url", "plugin_count", "last_fetched", "last_fetch_status", "created_at", "updated_at"] + + def get_registry_url(self, obj): + manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {}) + return manifest.get("registry_url", "") or "" + + def get_plugin_count(self, obj): + manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {}) + plugins = manifest.get("plugins", []) + return len(plugins) if isinstance(plugins, list) else 0 diff --git a/apps/plugins/tasks.py b/apps/plugins/tasks.py new file mode 100644 index 00000000..6bd1544c --- /dev/null +++ b/apps/plugins/tasks.py @@ -0,0 +1,33 @@ +import logging +from celery import shared_task + +logger = logging.getLogger(__name__) + +PLUGIN_REPO_REFRESH_TASK_NAME = "plugin-repo-refresh-task" + + +@shared_task +def refresh_plugin_repos(): + """Refresh cached manifests for all enabled plugin repos.""" + from .models import PluginRepo + from .api_views import _fetch_manifest, _save_fetched_manifest_to_repo, _unmanage_dropped_slugs + from django.utils import timezone + + repos = PluginRepo.objects.filter(enabled=True) + for repo in repos: + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + logger.warning("Skipping repo '%s': %s", repo.name, err) + continue + _unmanage_dropped_slugs(repo, data) + logger.info("Refreshed plugin repo '%s'", repo.name) + except Exception as e: + resp = getattr(e, 'response', None) + status_str = str(resp.status_code) if resp is not None and hasattr(resp, 'status_code') else type(e).__name__ + repo.last_fetch_status = status_str[:255] + repo.last_fetched = timezone.now() + repo.save(update_fields=["last_fetch_status", "last_fetched", "updated_at"]) + logger.warning("Failed to refresh plugin repo '%s': %s", repo.name, e) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7950e8ae..659d8070 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; import PluginsPage from './pages/Plugins'; +import PluginBrowsePage from './pages/PluginBrowse'; import ConnectPage from './pages/Connect'; import ConnectLogsPage from './pages/ConnectLogs'; import Users from './pages/Users'; @@ -153,6 +154,10 @@ const App = () => { } /> } /> } /> + } + /> } /> } /> ( + + + +); + +export const DiscordIcon = ({ size = 16 }) => ( + + + +); + +/** + * Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals. + * + * Props: + * - detail manifest detail object { manifest: { ... }, signature_verified } + * - detailLoading boolean + * - selectedVersion string | null + * - onVersionChange (version) => void + * - installedVersion string | null currently installed version + * - appVersion string current app version for compat checks + * - installing boolean + * - uninstalling boolean + * - onInstall (params) => void called with { version, url, sha256, min/max } + * - onUninstall () => void called when uninstall button clicked + * - installStatus string | null 'unmanaged' | 'different_repo' | 'installed' | 'update_available' | 'not_installed' + * - installedSourceRepoName string for different_repo tooltip + * - installedVersionIsPrerelease boolean + * - repoId number + * - slug string + */ +const PluginDetailPanel = ({ + detail, + detailLoading, + selectedVersion, + onVersionChange, + installedVersion, + installedVersionIsPrerelease = false, + appVersion, + installing = false, + uninstalling = false, + onInstall, + onUninstall, + installStatus, + installedSourceRepoName, + repoId, + slug, +}) => { + if (detailLoading) { + return ( + + + Loading plugin details… + + ); + } + + if (!detail?.manifest) { + return Failed to load plugin details.; + } + + const manifest = detail.manifest; + const selectedVersionData = manifest.versions?.find( + (v) => v.version === selectedVersion + ); + + const isSelSame = installedVersion && selectedVersion && + compareVersions(selectedVersion, installedVersion) === 0; + const isSelDowngrade = installedVersion && selectedVersion && + compareVersions(selectedVersion, installedVersion) < 0; + const isInstalled = !!installedVersion; + + const selMeetsMin = !selectedVersionData?.min_dispatcharr_version || + compareVersions(appVersion, selectedVersionData.min_dispatcharr_version) >= 0; + const selMeetsMax = !selectedVersionData?.max_dispatcharr_version || + compareVersions(appVersion, selectedVersionData.max_dispatcharr_version) <= 0; + const selCompatible = selMeetsMin && selMeetsMax; + + const isOverwrite = installStatus === 'unmanaged' || installStatus === 'different_repo'; + + const handleInstallClick = () => { + if (isSelSame && onUninstall) { + onUninstall(); + return; + } + if (!selectedVersionData?.url || !onInstall) return; + const params = { + repo_id: repoId, + slug, + version: selectedVersion, + download_url: selectedVersionData.url, + sha256: selectedVersionData.checksum_sha256, + min_dispatcharr_version: selectedVersionData.min_dispatcharr_version, + max_dispatcharr_version: selectedVersionData.max_dispatcharr_version, + prerelease: selectedVersionData.prerelease === true, + }; + onInstall(params); + }; + + const getButtonProps = () => { + if (isOverwrite) { + return { + label: installing ? 'Installing…' : 'Overwrite', + color: 'orange', + icon: installing ? : , + variant: 'filled', + tooltip: installStatus === 'unmanaged' + ? 'Installed manually – installing will take over management' + : `Managed by ${installedSourceRepoName || 'another repo'} – installing will transfer management to this repo`, + }; + } + if (isSelSame) { + return { + label: uninstalling ? 'Uninstalling…' : 'Uninstall', + color: 'red', + icon: uninstalling ? : , + variant: 'light', + }; + } + if (!selCompatible) { + return { + label: 'Incompatible', + color: 'gray', + icon: , + variant: 'filled', + }; + } + if (isSelDowngrade) { + return { + label: installing ? 'Downgrading…' : 'Downgrade', + color: 'orange', + icon: installing ? : , + variant: 'filled', + }; + } + if (isInstalled && !installedVersionIsPrerelease) { + return { + label: installing ? 'Updating…' : 'Update', + color: 'yellow', + icon: installing ? : , + variant: 'filled', + }; + } + return { + label: installing ? 'Installing…' : 'Install', + color: undefined, + icon: installing ? : , + variant: 'filled', + }; + }; + + const btnProps = getButtonProps(); + const btnDisabled = (isSelSame ? uninstalling : (!selCompatible || installing || !selectedVersionData?.url)); + + return ( + + {manifest.description && ( + {manifest.description} + )} + + + {manifest.author && ( + + AUTHOR + {manifest.author} + + )} + {manifest.license && ( + + LICENSE + {manifest.license} + + )} + {detail.signature_verified != null && ( + detail.signature_verified ? ( + } + > + Verified Signature + + ) : ( + + } + > + Unverified + + + ) + )} + {manifest.repo_url && ( + + + + + + )} + {manifest.discord_thread && (() => { + const isDiscordChannel = /^https:\/\/discord\.com\/channels\//.test(manifest.discord_thread); + return ( + + + + + + ); + })()} + + + {manifest.deprecated && ( + } + color="red" + variant="light" + title="Deprecated Plugin" + > + This plugin has been marked as deprecated by its maintainer. It may no longer receive + updates or fixes, and could stop working with future versions of Dispatcharr. + Consider looking for an alternative. + + )} + + {manifest.versions?.length > 0 && (() => { + const installedMissing = installedVersion && + !manifest.versions.some((v) => compareVersions(v.version, installedVersion) === 0); + const buildLabel = (v) => + `v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`; + + let versions = [...manifest.versions]; + if (installedVersionIsPrerelease) { + const prereleases = versions.filter((v) => v.prerelease); + const stable = versions.filter((v) => !v.prerelease); + versions = [...prereleases, ...stable]; + } + + const versionItems = versions.map((v) => ({ + value: v.version, + label: buildLabel(v), + disabled: false, + })); + if (installedMissing) { + const ghostItem = { + value: installedVersion, + label: `v${installedVersion} (installed)`, + disabled: true, + }; + // Insert in sorted position (newest first, matching manifest order convention) + const idx = versionItems.findIndex( + (item) => compareVersions(installedVersion, item.value) > 0 + ); + if (idx === -1) { + versionItems.push(ghostItem); + } else { + versionItems.splice(idx, 0, ghostItem); + } + } + return ( + <> + + + {repoOptions.length > 2 && ( + + + )} + + {!loading && filteredPlugins.length === 0 && availablePlugins.length > 0 && ( + + + No plugins match your filters. Try adjusting your search or filter criteria. + + + )} + + {!loading && availablePlugins.length === 0 && ( + + + No plugins available. Try refreshing repos or adding a new plugin + repository. + + + )} + + {!loading && filteredPlugins.length > 0 && ( + <> + + {paginatedPlugins.map((p) => ( + 1} + onBeforeInstall={(slug) => { if (slug) recentlyInstalledSlugs.current.add(slug); }} + onInstalled={(slug) => { if (slug) recentlyInstalledSlugs.current.add(slug); fetchAvailablePlugins(); }} + onUninstalled={(slug) => { if (slug) recentlyUninstalledSlugs.current.add(slug); }} + /> + ))} + + {totalPages > 1 && ( + + + + )} + + )} + + {/* Manage Repos Modal */} + setRepoModalOpen(false)} + title={ + +
+ Plugin Repositories + + Add third-party plugin repositories or manage existing ones. + Manifests are fetched automatically at the configured interval. + +
+
+ Refresh Interval + + Hours, 0 to disable +
+
+ } + centered + size="lg" + styles={{ title: { width: '100%' }, header: { alignItems: 'flex-start' } }} + > + + {reposLoading && repos.length === 0 && } + + {repos.map((repo) => ( + + + + + + {repo.name} + + {repo.is_official && ( + + Official Repo + + )} + {repo.signature_verified === true && ( + }> + Verified Signature + + )} + {repo.signature_verified === false && ( + }> + Invalid Signature + + )} + + + {repo.registry_url ? ( + + + {repo.registry_url} + + + ) : null} + + {repo.url} + + {repo.last_fetched && ( + + Last fetched:{' '} + {new Date(repo.last_fetched).toLocaleString()} + {repo.last_fetch_status && repo.last_fetch_status !== '200' + ? ` · ${repo.last_fetch_status}` + : repo.plugin_count != null + ? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available` + : ''} + + )} + + {!repo.is_official && ( + + handleEditKey(repo)} + > + + + setDeleteConfirmId(repo.id)} + > + + + + )} + + {editingKeyRepoId === repo.id && ( + +