Dispatcharr/apps/plugins/api_views.py
2026-06-14 09:43:46 -04:00

1455 lines
62 KiB
Python

import hashlib
import ipaddress
import logging
import json
import re
import socket
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, serializers
from drf_spectacular.utils import extend_schema, inline_serializer
from django.core.cache import cache
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,
permission_classes_by_method,
)
from dispatcharr.utils import network_access_allowed
from .loader import PluginManager
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)
def _parse_bool(value):
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("true", "1", "yes", "y", "on"):
return True
if normalized in ("false", "0", "no", "n", "off"):
return False
return None
def _sanitize_plugin_key(value: str) -> str:
base = os.path.basename(value or "")
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
parsed = urlparse(url)
if parsed.scheme:
return url
return request.build_absolute_uri(url)
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 [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
class PluginsListAPIView(PluginAuthMixin, APIView):
def get(self, request):
pm = PluginManager.get()
# Prefer cached registry; reload explicitly via the reload endpoint
pm.discover_plugins(sync_db=False, use_cache=True)
plugins = pm.list_plugins()
for plugin in plugins:
plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url"))
return Response({"plugins": plugins})
class PluginReloadAPIView(PluginAuthMixin, APIView):
def post(self, request):
pm = PluginManager.get()
pm.stop_all_plugins(reason="reload")
pm.discover_plugins(force_reload=True)
return Response({"success": True, "count": len(pm._registry)})
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)
# Single walk: find candidate plugin dirs AND logo.png simultaneously
candidates = []
logo_candidates_raw = []
for dirpath, dirnames, filenames in os.walk(tmp_root):
depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
has_pluginpy = "plugin.py" in filenames
has_init = "__init__.py" in filenames
if has_pluginpy or has_init:
candidates.append((0 if has_pluginpy else 1, depth, dirpath))
for filename in filenames:
if filename.lower() == "logo.png":
logo_candidates_raw.append((depth, os.path.join(dirpath, filename)))
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 (prefer one inside the chosen plugin dir, then shallowest)
logo_bytes = None
try:
chosen_abs = os.path.abspath(chosen)
logo_candidates = []
for depth, full_path in logo_candidates_raw:
full_abs = os.path.abspath(full_path)
try:
in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs
except Exception:
in_chosen = False
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)
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 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:
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 _resolve_manifest_base_urls(manifest: dict) -> tuple[str, str]:
"""Return (download_base_url, metadata_base_url) from a manifest dict.
Both fields fall back to root_url when not set. All base URL fields are
optional; callers must guard against empty strings before building URLs.
"""
root_url = manifest.get("root_url", "").rstrip("/")
download_base_url = manifest.get("download_base_url", "").rstrip("/") or root_url
metadata_base_url = manifest.get("metadata_base_url", "").rstrip("/") or root_url
return download_base_url, metadata_base_url
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
manifest = manifest_data.get("manifest", manifest_data)
_, metadata_base_url = _resolve_manifest_base_urls(manifest)
keys = []
for p in manifest.get("plugins", []):
url = p.get("manifest_url", "")
if not url:
continue
if metadata_base_url and not url.startswith(("http://", "https://")):
url = f"{metadata_base_url}/{url}"
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
if keys:
cache.delete_many(keys)
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,
defaults={
"name": plugin_key,
"version": "",
"description": "",
"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
# Reload discovery to register the plugin (trusted plugins will load)
pm.discover_plugins(force_reload=True)
plugin_entry = None
try:
plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None)
except Exception:
plugin_entry = None
if not plugin_entry:
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(pm.plugins_dir, plugin_key, "plugin.json"))
plugin_entry = {
"key": plugin_key,
"name": cfg.name if cfg else plugin_key,
"version": cfg.version if cfg else "",
"description": cfg.description if cfg else "",
"enabled": cfg.enabled if cfg else False,
"ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False,
"fields": [],
"actions": [],
"trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)),
"loaded": False,
"missing": False,
"legacy": legacy,
"logo_url": logo_url,
}
plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
return Response({"success": True, "plugin": plugin_entry, "was_managed": was_managed})
class PluginSettingsAPIView(PluginAuthMixin, APIView):
def post(self, request, key):
pm = PluginManager.get()
data = request.data or {}
settings = data.get("settings", {})
try:
updated = pm.update_settings(key, settings)
return Response({"success": True, "settings": updated})
except Exception as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
class PluginRunAPIView(PluginAuthMixin, APIView):
def post(self, request, key):
pm = PluginManager.get()
action = request.data.get("action")
params = request.data.get("params", {})
if not action:
return Response({"success": False, "error": "Missing 'action'"}, status=status.HTTP_400_BAD_REQUEST)
# Respect plugin enabled flag
try:
cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled:
return Response({"success": False, "error": "Plugin is disabled"}, status=status.HTTP_403_FORBIDDEN)
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
try:
result = pm.run_action(key, action, params)
return Response({"success": True, "result": result})
except PermissionError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
logger.exception("Plugin action failed")
return Response({"success": False, "error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class PluginEnabledAPIView(PluginAuthMixin, APIView):
def post(self, request, key):
enabled_raw = request.data.get("enabled")
if enabled_raw is None:
return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
enabled = _parse_bool(enabled_raw)
if enabled is None:
return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
try:
cfg = PluginConfig.objects.get(key=key)
pm = PluginManager.get()
if not enabled and cfg.enabled:
try:
pm.stop_plugin(key, reason="disable")
except Exception:
logger.exception("Failed to stop plugin '%s' on disable", key)
cfg.enabled = enabled
# Mark that this plugin has been enabled at least once
if cfg.enabled and not cfg.ever_enabled:
cfg.ever_enabled = True
cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"])
pm.discover_plugins(force_reload=True)
plugin_entry = None
try:
plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None)
except Exception:
plugin_entry = None
response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}
if plugin_entry:
plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url"))
response["plugin"] = plugin_entry
return Response(response)
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
class PluginLogoAPIView(APIView):
def get_permissions(self):
return []
def get(self, request, key):
if not network_access_allowed(request, "UI"):
return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN)
pm = PluginManager.get()
pm.discover_plugins(use_cache=True)
plugins_dir = pm.plugins_dir
logo_path = os.path.join(plugins_dir, key, "logo.png")
lp = pm.get_plugin(key)
if lp and getattr(lp, "path", None):
logo_path = os.path.join(lp.path, "logo.png")
abs_plugins = os.path.abspath(plugins_dir) + os.sep
abs_target = os.path.abspath(logo_path)
if not abs_target.startswith(abs_plugins):
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
if not os.path.isfile(logo_path):
return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND)
return FileResponse(open(logo_path, "rb"), content_type="image/png")
class PluginDeleteAPIView(PluginAuthMixin, APIView):
def delete(self, request, key):
pm = PluginManager.get()
try:
pm.stop_plugin(key, reason="delete")
except Exception:
logger.exception("Failed to stop plugin '%s' before delete", key)
plugins_dir = pm.plugins_dir
target_dir = os.path.join(plugins_dir, key)
# Safety: ensure path inside plugins_dir
abs_plugins = os.path.abspath(plugins_dir) + os.sep
abs_target = os.path.abspath(target_dir)
if not abs_target.startswith(abs_plugins):
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
# Remove files
if os.path.isdir(target_dir):
try:
shutil.rmtree(target_dir)
except Exception as e:
return Response({"success": False, "error": f"Failed to delete plugin files: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Remove DB record
try:
PluginConfig.objects.filter(key=key).delete()
except Exception:
pass
# Reload registry
pm.discover_plugins(force_reload=True)
return Response({"success": True})
# ---------------------------------------------------------------------------
# Plugin Repo (Hub / Store) views
# ---------------------------------------------------------------------------
MANIFEST_FETCH_TIMEOUT = 15
PLUGIN_DETAIL_CACHE_TTL = 300 # seconds
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 _gpg_run(cmd, input_data=None, timeout=30):
"""
Run a GPG command using os.posix_spawn.
os.posix_spawn skips pthread_atfork handlers, avoiding the indefinite hang
that fork()-based approaches suffer under gevent+uWSGI. select.select()
and time.sleep() are gevent-patched so reads and the waitpid poll yield to
the hub cooperatively.
Returns (returncode, stdout_bytes, stderr_bytes).
"""
import select as _select
import signal as _signal
import time as _time
stdin_r, stdin_w = os.pipe()
stdout_r, stdout_w = os.pipe()
stderr_r, stderr_w = os.pipe()
try:
executable = shutil.which(cmd[0]) or cmd[0]
pid = os.posix_spawn(
executable, cmd, os.environ,
file_actions=[
(os.POSIX_SPAWN_DUP2, stdin_r, 0),
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
(os.POSIX_SPAWN_DUP2, stderr_w, 2),
(os.POSIX_SPAWN_CLOSE, stdin_r),
(os.POSIX_SPAWN_CLOSE, stdin_w),
(os.POSIX_SPAWN_CLOSE, stdout_w),
(os.POSIX_SPAWN_CLOSE, stderr_w),
(os.POSIX_SPAWN_CLOSE, stdout_r),
(os.POSIX_SPAWN_CLOSE, stderr_r),
],
)
except Exception:
for fd in (stdin_r, stdin_w, stdout_r, stdout_w, stderr_r, stderr_w):
try:
os.close(fd)
except OSError:
pass
raise
for fd in (stdin_r, stdout_w, stderr_w):
os.close(fd)
try:
if input_data:
os.write(stdin_w, input_data)
finally:
os.close(stdin_w)
out, err = [], []
done = set()
deadline = _time.monotonic() + timeout
try:
while len(done) < 2:
remaining = deadline - _time.monotonic()
if remaining <= 0:
try:
os.kill(pid, _signal.SIGKILL)
except ProcessLookupError:
pass
break
fds = [fd for fd in (stdout_r, stderr_r) if fd not in done]
readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5))
for fd in readable:
data = os.read(fd, 8192)
if data:
(out if fd == stdout_r else err).append(data)
else:
done.add(fd)
finally:
for fd in (stdout_r, stderr_r):
try:
os.close(fd)
except OSError:
pass
deadline = _time.monotonic() + 5.0
while _time.monotonic() < deadline:
try:
wpid, st = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
return -1, b"".join(out), b"".join(err)
if wpid == pid:
if os.WIFEXITED(st):
return os.WEXITSTATUS(st), b"".join(out), b"".join(err)
if os.WIFSIGNALED(st):
return -os.WTERMSIG(st), b"".join(out), b"".join(err)
return -1, b"".join(out), b"".join(err)
_time.sleep(0.01)
return -1, b"".join(out), b"".join(err)
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, gpg binary 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
tmp_home = tempfile.mkdtemp(prefix="gpg_verify_")
try:
key_bytes = key_text.encode("utf-8") if isinstance(key_text, str) else key_text
rc, _, import_stderr = _gpg_run(
["gpg", "--batch", "--no-tty", "--status-fd", "2",
"--homedir", tmp_home, "--import"],
input_data=key_bytes,
)
if logger.isEnabledFor(logging.DEBUG):
for line in import_stderr.decode("utf-8", errors="replace").splitlines():
logger.debug("gpg import: %s", line)
if rc != 0:
logger.warning("GPG key import failed (rc=%d)", rc)
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)
rc, _, verify_stderr = _gpg_run(
["gpg", "--batch", "--no-tty", "--status-fd", "2",
"--homedir", tmp_home, "--verify", sig_path, "-"],
input_data=manifest_bytes,
)
if logger.isEnabledFor(logging.DEBUG):
for line in verify_stderr.decode("utf-8", errors="replace").splitlines():
logger.debug("gpg verify: %s", line)
return rc == 0 and b"VALIDSIG" in verify_stderr
except FileNotFoundError:
logger.debug("gpg binary not found; skipping signature verification")
return None
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)
with http_requests.get(url, timeout=MANIFEST_FETCH_TIMEOUT, stream=True) as resp:
resp.raise_for_status()
body = b"".join(resp.iter_content(8192))
data = json.loads(body)
# 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": "Failed to fetch manifest. Check the URL and try again."},
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": "Failed to fetch manifest. Check the URL and try again."},
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)
_invalidate_plugin_detail_cache(repo.id, 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)
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)
download_base_url, metadata_base_url = _resolve_manifest_base_urls(manifest)
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; metadata and download assets use separate bases
if metadata_base_url:
for url_field in ("manifest_url", "icon_url"):
val = plugin_data.get(url_field, "")
if val and not val.startswith(("http://", "https://")):
plugin_data[url_field] = f"{metadata_base_url}/{val}"
if download_base_url:
val = plugin_data.get("latest_url", "")
if val and not val.startswith(("http://", "https://")):
plugin_data["latest_url"] = f"{download_base_url}/{val}"
# Fallback icon_url: if metadata_base_url is explicitly set and manifest_url
# is known, assume logo.png lives in the same directory as the per-plugin
# manifest. Guard against root_url-only manifests to preserve the GitHub fallback.
if not plugin_data.get("icon_url") and manifest.get("metadata_base_url") and plugin_data.get("manifest_url"):
manifest_dir = plugin_data["manifest_url"].rsplit("/", 1)[0]
plugin_data["icon_url"] = f"{manifest_dir}/logo.png"
# 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)
cache_key = f"plugin_detail:{repo_id}:{hashlib.md5(manifest_url.encode()).hexdigest()}"
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
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)
download_base_url, _ = _resolve_manifest_base_urls(inner)
if download_base_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"{download_base_url}/{url_val}"
if download_base_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"{download_base_url}/{url_val}"
result = {
"manifest": manifest_obj,
"signature_verified": verified,
}
cache.set(cache_key, result, PLUGIN_DETAIL_CACHE_TTL)
return Response(result)
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": "Failed to download plugin. Check the URL and try again."},
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)