mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +09:00
Plan 55 task 6, backend half. Installing a plugin's requirements runs pip with the backend's privileges, and a setup.py hook is arbitrary code, so it is opt-in and off by default -- correct, and the part that was missing is that nothing ever said so. The skip was a logger.warning: the requirements.txt was written next to the extension and the panel showed nothing, so an extension could sit there running with imports it does not have and the operator had no way to find out short of reading the install log. pending_requirements() answers it: whether a file is pending, its contents, its path, the parsed package lines, and the exact env var that changes the behaviour. GET /plugins/<id>/requirements exposes it, admin-only. Strictly read-only. Nothing here installs anything -- the plan is explicit that this surface introduces no auto-install behaviour change -- and a test pins that by failing if _install_requirements is reached. The env check the installer inlined is now plugin_pip_enabled(), shared by both sides. If the surface and the installer ever disagreed about what "enabled" means, the page would describe a behaviour the installer does not have. Two honest edges: the content is capped at 64KB before being echoed into a browser, and pip_enabled is reported alongside pending, because a file written while the opt-in was off survives turning it on -- the surface reports both facts rather than guessing which applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2335 lines
96 KiB
Python
2335 lines
96 KiB
Python
"""
|
||
Plugin Service - Download, install, and manage ServerKit plugins from URLs.
|
||
|
||
Plugins are zip files containing a plugin.json manifest, optional backend/
|
||
and frontend/ directories. They get extracted into the ServerKit plugins
|
||
directories and auto-registered at startup.
|
||
"""
|
||
import io
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import zipfile
|
||
|
||
import requests
|
||
|
||
from app import db
|
||
from app.models.plugin import InstalledPlugin
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Resolve paths relative to the backend directory.
|
||
# __file__ = backend/app/services/plugin_service.py
|
||
# _APP_DIR = backend/app/
|
||
# _BACKEND_ROOT = backend/
|
||
# _PROJECT_ROOT = ServerKit/
|
||
#
|
||
# Both targets are env-var overridable. The defaults work for native dev
|
||
# (backend run from a checkout). They break in Docker because the backend
|
||
# image only contains /app — the host's frontend directory isn't mounted —
|
||
# so dockerized panels MUST set SERVERKIT_FRONTEND_PLUGINS_DIR (and bind
|
||
# mount the corresponding host folder) when installing plugins that ship
|
||
# a frontend.
|
||
_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
_BACKEND_ROOT = os.path.dirname(_APP_DIR)
|
||
_PROJECT_ROOT = os.path.dirname(_BACKEND_ROOT)
|
||
|
||
BACKEND_PLUGINS_DIR = os.environ.get(
|
||
'SERVERKIT_BACKEND_PLUGINS_DIR',
|
||
os.path.join(_APP_DIR, 'plugins'),
|
||
)
|
||
FRONTEND_PLUGINS_DIR = os.environ.get(
|
||
'SERVERKIT_FRONTEND_PLUGINS_DIR',
|
||
os.path.join(_PROJECT_ROOT, 'frontend', 'src', 'plugins'),
|
||
)
|
||
|
||
# Folder of bundled-with-the-repo extensions that ship as plugins
|
||
# but are not installed by default. The discovery endpoint enumerates
|
||
# this folder so the Marketplace can show one-click installs without
|
||
# requiring users to know absolute paths.
|
||
BUILTIN_EXTENSIONS_DIR = os.environ.get(
|
||
'SERVERKIT_BUILTIN_EXTENSIONS_DIR',
|
||
os.path.join(_PROJECT_ROOT, 'builtin-extensions'),
|
||
)
|
||
|
||
# Flagship extensions (decision D4): bundled, installed-by-default on EVERY panel
|
||
# (fresh and upgrade), and uninstallable. Unlike the niche Tier-1 conversions
|
||
# (handled one-shot in extension_migration), a flagship is seeded in-place — no
|
||
# file copy — its backend loads straight from builtin-extensions/ and its frontend
|
||
# is pre-bundled (D5). A user uninstall records a marker so it stays gone.
|
||
#
|
||
# serverkit-wordpress GRADUATED from this list in plan 52 Phase 5: it left the
|
||
# tree into its own repo and now distributes through the registry (the
|
||
# k8s/tramo model — bundled index entry + GitHub release zip), offered during
|
||
# onboarding via RECOMMENDED_EXTENSIONS_BY_USE_CASE. Its uninstall-marker
|
||
# semantics are preserved (the settings key machinery below is untouched), so
|
||
# a user who uninstalled WP before the extraction is never re-seeded.
|
||
FLAGSHIP_SLUGS = ['serverkit-cloudflare-ops']
|
||
_FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled'
|
||
|
||
# Wizard-optional flagships (plan 47): offered — not force-installed — during
|
||
# onboarding. Currently EMPTY: WordPress (the only member) left the tree in
|
||
# plan 52 Phase 5 and is now a registry extension the wizard offers like any
|
||
# other recommendation. The machinery stays — a future in-tree flagship that
|
||
# should be wizard-optional goes here.
|
||
WIZARD_OPTIONAL_FLAGSHIP_SLUGS = []
|
||
|
||
|
||
def _ensure_builtin_backend_importable(slug):
|
||
"""Register ``builtin-extensions/<slug>/backend`` as the dashed package
|
||
``app.plugins.<slug>`` in ``sys.modules`` (in-place, no copy), so its
|
||
blueprints and services import — and relative imports inside resolve —
|
||
without the plugin being copy-installed under ``app/plugins/``.
|
||
|
||
This is what makes a dashed, default-installed flagship loadable uniformly in
|
||
production, dev checkouts, and the test suite. Idempotent; returns ``True`` if
|
||
the package is (now) importable.
|
||
"""
|
||
import importlib
|
||
import importlib.util
|
||
pkg = f'app.plugins.{slug}'
|
||
if pkg in sys.modules:
|
||
return True
|
||
try:
|
||
importlib.import_module(pkg) # already copy-installed under app/plugins/
|
||
return True
|
||
except ImportError:
|
||
pass
|
||
backend_dir = os.path.join(BUILTIN_EXTENSIONS_DIR, slug, 'backend')
|
||
init_py = os.path.join(backend_dir, '__init__.py')
|
||
if not os.path.isfile(init_py):
|
||
return False
|
||
try:
|
||
importlib.import_module('app.plugins') # ensure the parent package exists
|
||
spec = importlib.util.spec_from_file_location(
|
||
pkg, init_py, submodule_search_locations=[backend_dir]
|
||
)
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[pkg] = module
|
||
spec.loader.exec_module(module)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f'In-place load of {pkg} failed: {e}')
|
||
sys.modules.pop(pkg, None)
|
||
return False
|
||
|
||
|
||
def _ensure_backend_dir():
|
||
"""Create the backend plugin dir; raise a useful error if we can't."""
|
||
try:
|
||
os.makedirs(BACKEND_PLUGINS_DIR, exist_ok=True)
|
||
except OSError as e:
|
||
raise ValueError(
|
||
f"Cannot create backend plugin directory at {BACKEND_PLUGINS_DIR}: {e}. "
|
||
f"Set SERVERKIT_BACKEND_PLUGINS_DIR to a writable path."
|
||
)
|
||
init_path = os.path.join(BACKEND_PLUGINS_DIR, '__init__.py')
|
||
if not os.path.exists(init_path):
|
||
with open(init_path, 'w') as f:
|
||
f.write('')
|
||
|
||
|
||
def _ensure_frontend_dir():
|
||
"""Create the frontend plugin dir on demand. Called only when a plugin
|
||
actually ships frontend content — plugins without frontends never need
|
||
this directory and shouldn't fail just because it doesn't exist (a
|
||
common case in Docker)."""
|
||
try:
|
||
os.makedirs(FRONTEND_PLUGINS_DIR, exist_ok=True)
|
||
except OSError as e:
|
||
raise ValueError(
|
||
f"This plugin ships a frontend, but the panel can't write to "
|
||
f"the frontend plugin directory ({FRONTEND_PLUGINS_DIR}): {e}.\n\n"
|
||
f"If the panel runs in Docker, this is expected — the container "
|
||
f"sees only /app, not the host's frontend folder. Two fixes:\n"
|
||
f" • Bind-mount the host's frontend/src/plugins into the "
|
||
f"container and set SERVERKIT_FRONTEND_PLUGINS_DIR to that path.\n"
|
||
f" • Or run the backend natively for plugin development.\n\n"
|
||
f"You can also install the backend half only by stripping the "
|
||
f"frontend/ folder from the plugin zip, but the UI half won't load."
|
||
)
|
||
|
||
|
||
def _ensure_dirs():
|
||
"""Backwards-compatible eager init for backend dir only. Frontend dir is
|
||
lazy now — see _ensure_frontend_dir."""
|
||
_ensure_backend_dir()
|
||
|
||
|
||
def _github_token():
|
||
"""Optional GitHub token (SERVERKIT_GITHUB_TOKEN). Lifts the 60/hr anonymous
|
||
rate limit and enables private-repo installs. Never logged or echoed back."""
|
||
return (os.environ.get('SERVERKIT_GITHUB_TOKEN') or '').strip()
|
||
|
||
|
||
def _is_github_host(url):
|
||
"""True for github.com / api.github.com / codeload.github.com URLs — the only
|
||
hosts the token is ever attached to (never leak it to third-party mirrors,
|
||
and never over plain http — audit L6)."""
|
||
return bool(re.match(r'^https://(?:[a-z0-9-]+\.)?github\.com/', url or '', re.I))
|
||
|
||
|
||
def _github_api_headers(accept='application/vnd.github+json'):
|
||
headers = {'Accept': accept, 'User-Agent': 'ServerKit-Plugin-Installer/1.0'}
|
||
token = _github_token()
|
||
if token:
|
||
headers['Authorization'] = f'Bearer {token}'
|
||
return headers
|
||
|
||
|
||
class GitHubResolveError(ValueError):
|
||
"""Actionable failure resolving a GitHub URL (rate limit, 404, private)."""
|
||
|
||
|
||
def _normalize_source_url(url):
|
||
"""Accept `owner/repo` and `owner/repo@tag` shorthand, normalizing to a
|
||
github.com URL. Full URLs and direct .zip links pass through unchanged."""
|
||
if not url:
|
||
return url
|
||
u = url.strip()
|
||
if u.startswith('http://') or u.startswith('https://') or u.endswith('.zip'):
|
||
return u
|
||
m = re.match(r'^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:@(.+))?$', u)
|
||
if not m:
|
||
return u
|
||
owner, repo, tag = m.groups()
|
||
if tag:
|
||
return f'https://github.com/{owner}/{repo}/releases/tag/{tag}'
|
||
return f'https://github.com/{owner}/{repo}'
|
||
|
||
|
||
def _resolve_github_url(url, strict=False):
|
||
"""Convert a GitHub repo URL to the latest release zip download URL.
|
||
|
||
Handles:
|
||
- https://github.com/user/repo -> latest release zip
|
||
- https://github.com/user/repo/releases/tag/v1.0.0 -> that release zip
|
||
- Direct zip URLs pass through unchanged
|
||
|
||
When ``strict`` is False (installs) any API hiccup silently falls back to the
|
||
default-branch/tag zipball. When ``strict`` is True (preview) failures raise
|
||
:class:`GitHubResolveError` with an actionable message, and a
|
||
``.used_zipball_fallback`` flag records whether no release asset was found.
|
||
"""
|
||
if url.endswith('.zip'):
|
||
return url
|
||
|
||
# Match github.com/owner/repo patterns
|
||
gh_match = re.match(
|
||
r'https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/releases/tag/([^/]+))?/?$',
|
||
url,
|
||
)
|
||
if not gh_match:
|
||
return url
|
||
|
||
owner, repo, tag = gh_match.groups()
|
||
|
||
if tag:
|
||
# Specific release - get its assets
|
||
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}'
|
||
else:
|
||
# Latest release
|
||
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest'
|
||
|
||
try:
|
||
resp = requests.get(api_url, timeout=15, headers=_github_api_headers())
|
||
if strict:
|
||
_raise_for_github_status(resp, owner, repo)
|
||
resp.raise_for_status()
|
||
release = resp.json()
|
||
|
||
# Look for a .zip asset (prefer plugin zip over source)
|
||
for asset in release.get('assets', []):
|
||
if asset['name'].endswith('.zip'):
|
||
return asset['browser_download_url']
|
||
|
||
# No .zip asset — fall back to source zipball (default branch of the tag).
|
||
zipball = release.get('zipball_url') or (
|
||
f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' if tag
|
||
else f'https://api.github.com/repos/{owner}/{repo}/zipball')
|
||
_mark_zipball_fallback(zipball)
|
||
return zipball
|
||
except GitHubResolveError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning(f'Could not resolve GitHub release URL: {e}')
|
||
# Fallback: try the zipball endpoint directly (no release published).
|
||
zipball = (f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}' if tag
|
||
else f'https://api.github.com/repos/{owner}/{repo}/zipball')
|
||
_mark_zipball_fallback(zipball)
|
||
return zipball
|
||
|
||
|
||
# Records the most recent resolution's zipball fallback so the preview flow can
|
||
# warn "no release found (installing the default-branch zipball)". Only read
|
||
# immediately after a _resolve_github_url call.
|
||
_LAST_RESOLUTION = {'zipball_fallback': False}
|
||
|
||
|
||
def _mark_zipball_fallback(_url):
|
||
_LAST_RESOLUTION['zipball_fallback'] = True
|
||
|
||
|
||
def _raise_for_github_status(resp, owner, repo):
|
||
"""Turn a GitHub API error response into an actionable GitHubResolveError."""
|
||
if resp.status_code == 200:
|
||
_LAST_RESOLUTION['zipball_fallback'] = False
|
||
return
|
||
if resp.status_code == 403 and resp.headers.get('X-RateLimit-Remaining') == '0':
|
||
reset = resp.headers.get('X-RateLimit-Reset')
|
||
mins = ''
|
||
try:
|
||
import time as _t
|
||
if reset:
|
||
mins = f' (try again in ~{max(1, round((int(reset) - _t.time()) / 60))} min)'
|
||
except Exception:
|
||
pass
|
||
raise GitHubResolveError(
|
||
f'GitHub API rate limit reached{mins}. Set SERVERKIT_GITHUB_TOKEN '
|
||
f'to raise the limit and enable private-repo installs.')
|
||
if resp.status_code == 404:
|
||
raise GitHubResolveError(
|
||
f'Repository or release not found for {owner}/{repo}. Check the URL, '
|
||
f'or set SERVERKIT_GITHUB_TOKEN if the repository is private.')
|
||
raise GitHubResolveError(
|
||
f'GitHub API returned HTTP {resp.status_code} for {owner}/{repo}.')
|
||
|
||
|
||
def _download_zip(url):
|
||
"""Download a zip file from URL and return bytes."""
|
||
resolved = _resolve_github_url(url)
|
||
return _download_resolved(resolved)
|
||
|
||
|
||
# ── Download safety (audit L4): scheme/host checks, redirect re-validation,
|
||
# and byte caps, shared by the zip and detached-signature fetches.
|
||
_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 # extension zip cap
|
||
_MAX_SIGNATURE_BYTES = 64 * 1024 # detached .minisig cap
|
||
_MAX_EXTRACT_BYTES = 250 * 1024 * 1024 # decompression cap (audit L5)
|
||
_MAX_REDIRECTS = 5
|
||
|
||
|
||
def _ip_is_public(ip):
|
||
import ipaddress
|
||
addr = ipaddress.ip_address(ip)
|
||
return not (addr.is_private or addr.is_loopback or addr.is_link_local
|
||
or addr.is_multicast or addr.is_reserved or addr.is_unspecified)
|
||
|
||
|
||
def _assert_url_safe(url):
|
||
"""Reject anything that isn't an https URL to a public host (SSRF guard).
|
||
|
||
Rules: https everywhere; http only for loopback dev hosts
|
||
(http://localhost:8000/ext.zip while developing); loopback targets are
|
||
always allowed (a same-host registry is legitimate), but RFC1918 /
|
||
link-local / reserved targets are refused by default — a panel must never
|
||
follow an attacker-supplied URL into the internal network or the cloud
|
||
metadata service. Operators with a private-zone registry can opt out with
|
||
SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1.
|
||
"""
|
||
import ipaddress
|
||
import socket
|
||
from urllib.parse import urlparse
|
||
|
||
parsed = urlparse(url or '')
|
||
scheme = (parsed.scheme or '').lower()
|
||
if scheme not in ('http', 'https'):
|
||
raise ValueError(
|
||
f"Unsupported URL scheme {scheme!r} — extension downloads must be https://")
|
||
host = parsed.hostname
|
||
if not host:
|
||
raise ValueError('Download URL has no host')
|
||
try:
|
||
ips = {info[4][0] for info in socket.getaddrinfo(host, None)}
|
||
except Exception as e:
|
||
raise ValueError(f'Could not resolve download host {host!r}: {e}')
|
||
if all(ipaddress.ip_address(ip).is_loopback for ip in ips):
|
||
return # loopback dev host — http or https both fine
|
||
if scheme == 'http':
|
||
raise ValueError(
|
||
'Plain-http extension downloads are only allowed for loopback dev hosts')
|
||
if os.environ.get('SERVERKIT_ALLOW_PRIVATE_DOWNLOADS', '').lower() in ('1', 'true', 'yes'):
|
||
return
|
||
bad = sorted(ip for ip in ips if not _ip_is_public(ip))
|
||
if bad:
|
||
raise ValueError(
|
||
f'Download host {host!r} resolves to a non-public address ({bad[0]}) '
|
||
f'— refusing to fetch (SSRF guard). Set '
|
||
f'SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1 to allow private-zone registries.')
|
||
|
||
|
||
def _safe_get(url, timeout, headers, max_bytes):
|
||
"""GET ``url`` with per-hop SSRF re-validation (redirects are followed
|
||
manually so each hop is re-checked), a hard byte cap, and the GitHub
|
||
token re-computed per hop (never leaked cross-host). Returns bytes."""
|
||
from urllib.parse import urljoin
|
||
current = url
|
||
for _ in range(_MAX_REDIRECTS + 1):
|
||
_assert_url_safe(current)
|
||
hop_headers = dict(headers or {})
|
||
token = _github_token()
|
||
if token and _is_github_host(current):
|
||
hop_headers['Authorization'] = f'Bearer {token}'
|
||
resp = requests.get(current, timeout=timeout, stream=True,
|
||
headers=hop_headers, allow_redirects=False)
|
||
try:
|
||
# status_code check rather than is_redirect: equivalent semantics,
|
||
# and tolerant of minimal test doubles.
|
||
if resp.status_code in (301, 302, 303, 307, 308):
|
||
location = resp.headers.get('Location')
|
||
if not location:
|
||
raise ValueError('Redirect without a Location header')
|
||
current = urljoin(current, location)
|
||
continue
|
||
resp.raise_for_status()
|
||
buf = io.BytesIO()
|
||
for chunk in resp.iter_content(chunk_size=8192):
|
||
buf.write(chunk)
|
||
if buf.tell() > max_bytes:
|
||
raise ValueError(
|
||
f'Download exceeds the {max_bytes // (1024 * 1024)} MB cap '
|
||
f'— aborting (possible zip bomb or misconfigured source)')
|
||
buf.seek(0)
|
||
return buf.getvalue()
|
||
finally:
|
||
close = getattr(resp, 'close', None)
|
||
if callable(close):
|
||
close()
|
||
raise ValueError(f'Too many redirects fetching {url}')
|
||
|
||
|
||
def _download_resolved(resolved):
|
||
"""Download an already-resolved zip URL and return a BytesIO buffer."""
|
||
logger.info(f'Downloading plugin from: {resolved}')
|
||
headers = {
|
||
'Accept': 'application/octet-stream',
|
||
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
|
||
}
|
||
data = _safe_get(resolved, timeout=120, headers=headers,
|
||
max_bytes=_MAX_DOWNLOAD_BYTES)
|
||
return io.BytesIO(data)
|
||
|
||
|
||
def _find_manifest(zf):
|
||
"""Find plugin.json inside the zip, handling nested directories (GitHub zipball nesting)."""
|
||
for name in zf.namelist():
|
||
basename = os.path.basename(name)
|
||
if basename == 'plugin.json':
|
||
# Return the directory prefix so we can strip it
|
||
prefix = name[: -len('plugin.json')]
|
||
return name, prefix
|
||
return None, None
|
||
|
||
|
||
# 'module:attr' references (entry_point, socket_entry, models, lifecycle hooks,
|
||
# job handlers). Module part may be dotted; attr must be a bare identifier.
|
||
_MODULE_REF_RE = re.compile(r'^[A-Za-z_][\w.]*:[A-Za-z_]\w*$')
|
||
|
||
# Required keys per contribution kind (mirrors GET /plugins/manifest-spec and
|
||
# docs/EXTENSIONS.md — keep the three in sync).
|
||
_REQUIRED_CONTRIB_KEYS = {
|
||
'nav': ('id', 'label', 'route'),
|
||
'routes': ('path', 'component'),
|
||
'tabs': ('group', 'to', 'label'),
|
||
'command_palette': ('label', 'path'),
|
||
'widgets': ('slot', 'component'),
|
||
'dashboard_widgets': ('id', 'name', 'component'),
|
||
'layouts': ('id', 'component'),
|
||
}
|
||
|
||
_KNOWN_CONTRIB_KINDS = set(_REQUIRED_CONTRIB_KEYS) | {'page_titles', 'ai'}
|
||
|
||
|
||
def _validate_manifest(manifest):
|
||
"""Validate the manifest: required fields plus the SHAPE of every
|
||
declarative block the platform consumes (#52).
|
||
|
||
A malformed block used to be silently skipped at runtime (contribution
|
||
dropped, socket never registered, job never scheduled) — hard to debug.
|
||
Now the install fails with a message naming each problem. Unknown
|
||
contribution kinds only warn, so newer manifests stay installable on
|
||
older panels (forward compat).
|
||
"""
|
||
required = ['name', 'display_name', 'version']
|
||
missing = [f for f in required if f not in manifest]
|
||
if missing:
|
||
raise ValueError(f"Manifest missing required fields: {', '.join(missing)}")
|
||
|
||
# Sanitize the name for use as a directory
|
||
name = manifest['name']
|
||
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
|
||
raise ValueError(f"Plugin name must be alphanumeric/dashes/underscores: {name}")
|
||
|
||
problems = []
|
||
|
||
for field in ('entry_point', 'socket_entry', 'models'):
|
||
val = manifest.get(field)
|
||
if val and not (isinstance(val, str) and _MODULE_REF_RE.match(val)):
|
||
problems.append(f"{field} must be a 'module:attr' string (got {val!r})")
|
||
|
||
lifecycle = manifest.get('lifecycle')
|
||
if lifecycle is not None:
|
||
if not isinstance(lifecycle, dict):
|
||
problems.append('lifecycle must be an object of phase -> module:func')
|
||
else:
|
||
for phase, target in lifecycle.items():
|
||
if not (isinstance(target, str) and _MODULE_REF_RE.match(target)):
|
||
problems.append(
|
||
f"lifecycle.{phase} must be a 'module:func' string (got {target!r})")
|
||
|
||
jobs = manifest.get('jobs')
|
||
if jobs is not None:
|
||
if not isinstance(jobs, list):
|
||
problems.append('jobs must be a list of {kind, handler}')
|
||
else:
|
||
for i, j in enumerate(jobs):
|
||
if not (isinstance(j, dict) and j.get('kind')
|
||
and isinstance(j.get('handler'), str)
|
||
and _MODULE_REF_RE.match(j['handler'])):
|
||
problems.append(f"jobs[{i}] must be {{kind, handler: 'module:func'}}")
|
||
|
||
schedules = manifest.get('schedules')
|
||
if schedules is not None:
|
||
if not isinstance(schedules, list):
|
||
problems.append('schedules must be a list of {name, kind, cron?|interval_seconds?}')
|
||
else:
|
||
for i, s in enumerate(schedules):
|
||
if not (isinstance(s, dict) and s.get('name') and s.get('kind')):
|
||
problems.append(f'schedules[{i}] needs name and kind')
|
||
|
||
contrib = manifest.get('contributions')
|
||
if contrib is not None and not isinstance(contrib, dict):
|
||
problems.append('contributions must be an object')
|
||
elif isinstance(contrib, dict):
|
||
for key in contrib:
|
||
if key not in _KNOWN_CONTRIB_KINDS:
|
||
logger.warning(
|
||
f"Manifest for {name}: unknown contribution kind '{key}' (ignored)")
|
||
|
||
for kind, req in _REQUIRED_CONTRIB_KEYS.items():
|
||
entries = contrib.get(kind)
|
||
if entries is None:
|
||
continue
|
||
if not isinstance(entries, list):
|
||
problems.append(f'contributions.{kind} must be a list')
|
||
continue
|
||
for i, entry in enumerate(entries):
|
||
if not isinstance(entry, dict):
|
||
problems.append(f'contributions.{kind}[{i}] must be an object')
|
||
continue
|
||
missing_keys = [k for k in req if not entry.get(k)]
|
||
if missing_keys:
|
||
problems.append(
|
||
f"contributions.{kind}[{i}] missing {', '.join(missing_keys)}")
|
||
|
||
titles = contrib.get('page_titles')
|
||
if titles is not None and not isinstance(titles, dict):
|
||
problems.append('contributions.page_titles must be an object of path -> title')
|
||
|
||
if problems:
|
||
raise ValueError(
|
||
'Manifest validation failed: ' + '; '.join(problems)
|
||
+ '. See GET /api/v1/plugins/manifest-spec or docs/EXTENSIONS.md.')
|
||
|
||
return True
|
||
|
||
|
||
def _assert_manifest_panel_compatible(manifest):
|
||
"""Enforce a manifest's min/max_panel_version at install (#28), for every
|
||
install source (URL/upload/local/builtin), not just the registry. A manifest
|
||
without bounds installs anywhere."""
|
||
from app.utils.version import version_satisfies, get_panel_version
|
||
minv = manifest.get('min_panel_version')
|
||
maxv = manifest.get('max_panel_version')
|
||
if not (minv or maxv):
|
||
return
|
||
panel = get_panel_version()
|
||
if not version_satisfies(panel, minv, maxv):
|
||
raise ValueError(
|
||
f"{manifest.get('display_name') or manifest.get('name')} "
|
||
f"v{manifest.get('version')} needs panel {minv or '*'}–{maxv or '*'} "
|
||
f"(this panel is {panel})."
|
||
)
|
||
|
||
|
||
def _safe_extract_path(dest_root, rel_path):
|
||
"""Resolve `rel_path` under `dest_root` and assert it stays inside.
|
||
|
||
Defense against Zip Slip: a malicious archive can carry entries like
|
||
`../../etc/cron.d/foo` or absolute paths. Without this check, the
|
||
extract loop happily writes outside the plugin directory and into
|
||
anything the backend process can touch.
|
||
|
||
Returns the validated absolute output path, or raises ValueError.
|
||
"""
|
||
if not rel_path:
|
||
raise ValueError('empty path in archive')
|
||
normalized = rel_path.replace('\\', '/').lstrip('/')
|
||
if normalized.startswith('/') or ':' in normalized.split('/')[0]:
|
||
raise ValueError(f'absolute path in archive: {rel_path!r}')
|
||
if '..' in normalized.split('/'):
|
||
raise ValueError(f'path traversal in archive: {rel_path!r}')
|
||
out = os.path.normpath(os.path.join(dest_root, normalized))
|
||
root = os.path.normpath(dest_root) + os.sep
|
||
if not (out + os.sep).startswith(root):
|
||
raise ValueError(f'archive entry escapes destination: {rel_path!r}')
|
||
return out
|
||
|
||
|
||
def _update_plugin_metadata(plugin, manifest):
|
||
"""Sync mutable manifest-derived fields onto an existing plugin row.
|
||
|
||
Used on reinstall: the new manifest may have a fresh display_name,
|
||
author, description, etc. Without this, those fields go stale and
|
||
the UI keeps showing the original values.
|
||
"""
|
||
plugin.name = manifest['name']
|
||
plugin.display_name = manifest['display_name']
|
||
plugin.description = manifest.get('description', '')
|
||
plugin.author = manifest.get('author', '')
|
||
plugin.homepage = manifest.get('homepage', '')
|
||
plugin.repository = manifest.get('repository', '')
|
||
plugin.license = manifest.get('license', '')
|
||
plugin.category = manifest.get('category', 'utility')
|
||
|
||
|
||
def _record_signature_status(plugin, sig_result):
|
||
"""Stamp the install-time signature verdict on the plugin's config under
|
||
the panel-managed ``_signature`` reserved key (same trust-machinery
|
||
namespace as ``_frontend_hashes`` — config PUTs can't forge it). The
|
||
extension detail surfaces this so an installed extension's origin
|
||
verification stays visible after install."""
|
||
cfg = dict(plugin.config or {})
|
||
if sig_result and sig_result.get('status') != 'unsigned':
|
||
cfg['_signature'] = {
|
||
k: sig_result.get(k)
|
||
for k in ('status', 'key_id', 'publisher')
|
||
if sig_result.get(k)
|
||
}
|
||
else:
|
||
cfg['_signature'] = {'status': 'unsigned'}
|
||
plugin.config = cfg
|
||
db.session.commit()
|
||
|
||
|
||
def install_from_url(url, user_id=None, expected_sha256=None, force=False,
|
||
hot_load=True, source_type=None,
|
||
expected_signature=None, expected_key_id=None):
|
||
"""Download and install a plugin from a URL.
|
||
|
||
Args:
|
||
url: GitHub repo URL, release URL, or direct zip URL
|
||
user_id: ID of the user performing the install
|
||
expected_sha256: if given, the downloaded zip must match this digest
|
||
before extraction — a mismatch is a hard failure (no partial install)
|
||
expected_signature / expected_key_id: if given, the downloaded zip is
|
||
verified against this base64 ed25519 signature envelope (plan 55
|
||
D3) under the pinned publisher key named by expected_key_id. A bad
|
||
signature is a hard failure; an unpinned key installs but is
|
||
recorded as 'untrusted_key'.
|
||
force: allow reinstalling over an already-active plugin (used by updates)
|
||
hot_load: register the blueprint into the running app immediately.
|
||
The boot-time repair pass (#48) passes False — load_all_plugins
|
||
registers right after, and a double registration would error.
|
||
source_type: override the stamped source_type (defaults to 'url').
|
||
Registry installs pass 'registry' so the row records where the
|
||
plugin really came from.
|
||
|
||
Returns:
|
||
InstalledPlugin instance
|
||
"""
|
||
url = _normalize_source_url(url)
|
||
try:
|
||
buf = _download_zip(url)
|
||
except Exception as e:
|
||
raise ValueError(f'Failed to download plugin: {e}')
|
||
|
||
if expected_sha256:
|
||
import hashlib
|
||
digest = hashlib.sha256(buf.getvalue()).hexdigest()
|
||
if digest.lower() != str(expected_sha256).lower():
|
||
raise ValueError(
|
||
f'Checksum mismatch — refusing to install. '
|
||
f'Expected sha256 {expected_sha256}, got {digest}.'
|
||
)
|
||
buf.seek(0)
|
||
|
||
sig_result = None
|
||
if expected_signature:
|
||
from app.services import signing_service
|
||
sig_result = signing_service.verify_for_install(
|
||
buf.getvalue(), expected_signature, expected_key_id)
|
||
buf.seek(0)
|
||
|
||
plugin = _install_from_buffer(
|
||
buf, source_url=url, source_type=source_type or 'url',
|
||
user_id=user_id, force=force,
|
||
hot_load=hot_load, sig_result=sig_result,
|
||
)
|
||
return plugin
|
||
|
||
|
||
def _read_manifest_from_buffer(buf):
|
||
"""Read and JSON-parse plugin.json from a plugin zip buffer, handling the
|
||
GitHub zipball directory nesting. Raises ValueError with an actionable
|
||
message when the archive is not a valid plugin package."""
|
||
try:
|
||
zf = zipfile.ZipFile(buf)
|
||
except zipfile.BadZipFile:
|
||
raise ValueError('The downloaded file is not a valid zip archive.')
|
||
manifest_path, _prefix = _find_manifest(zf)
|
||
if not manifest_path:
|
||
raise ValueError(
|
||
'No plugin.json found at the archive root. A ServerKit extension '
|
||
'needs plugin.json at the top level (see the layout in '
|
||
'docs/EXTENSIONS.md).')
|
||
try:
|
||
with zf.open(manifest_path) as f:
|
||
return json.loads(f.read().decode('utf-8'))
|
||
except Exception as e:
|
||
raise ValueError(f'plugin.json could not be parsed: {e}')
|
||
|
||
|
||
def _fetch_detached_signature(resolved_url):
|
||
"""Best-effort fetch of the minisign-style detached signature published
|
||
beside a release zip (``<zip URL>.minisig`` — the convention
|
||
scripts/sign-extension.mjs writes). Returns the base64 envelope string,
|
||
or None when the release carries no signature (or the fetch fails —
|
||
preview must never hard-fail on a missing optional asset)."""
|
||
if not (resolved_url or '').startswith(('http://', 'https://')):
|
||
return None
|
||
try:
|
||
headers = {
|
||
'Accept': 'text/plain, application/octet-stream',
|
||
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
|
||
}
|
||
data = _safe_get(resolved_url + '.minisig', timeout=15, headers=headers,
|
||
max_bytes=_MAX_SIGNATURE_BYTES)
|
||
text = data.decode('utf-8', errors='replace').strip()
|
||
return text or None
|
||
except Exception as e:
|
||
logger.debug(f'No detached signature at {resolved_url}.minisig: {e}')
|
||
return None
|
||
|
||
|
||
def preview_from_url(url):
|
||
"""Resolve + download a GitHub/zip source and read its manifest WITHOUT
|
||
installing. Returns the metadata a user needs to consent to the install:
|
||
slug, display_name, version, description, permissions, panel-version gate,
|
||
the resolved download URL, its sha256, the signature verdict (signed by a
|
||
pinned publisher / unsigned / untrusted key / invalid), and any warnings.
|
||
|
||
Stateless by design — the subsequent install re-downloads (extension zips
|
||
are small) and pins the previewed sha256 so the installed bytes are
|
||
byte-identical to the previewed bytes.
|
||
"""
|
||
from app.utils.version import version_satisfies, get_panel_version
|
||
from app.services import signing_service
|
||
import hashlib
|
||
|
||
normalized = _normalize_source_url(url)
|
||
_LAST_RESOLUTION['zipball_fallback'] = False
|
||
resolved = _resolve_github_url(normalized, strict=True)
|
||
|
||
try:
|
||
buf = _download_resolved(resolved)
|
||
except GitHubResolveError:
|
||
raise
|
||
except Exception as e:
|
||
raise ValueError(f'Failed to download the extension: {e}')
|
||
|
||
digest = hashlib.sha256(buf.getvalue()).hexdigest()
|
||
buf.seek(0)
|
||
manifest = _read_manifest_from_buffer(buf)
|
||
|
||
# Signature verdict for the consent card (plan 55): the detached .minisig
|
||
# beside the release asset, verified against the panel's pinned publisher
|
||
# keys. Unsigned is a verdict, not an error — the card says so honestly.
|
||
sig_b64 = _fetch_detached_signature(resolved)
|
||
sig_result = signing_service.verify_detached(buf.getvalue(), sig_b64)
|
||
signature = {
|
||
'status': sig_result['status'],
|
||
'key_id': sig_result.get('key_id'),
|
||
'publisher': sig_result.get('publisher'),
|
||
# The raw envelope is echoed back so the install step can re-verify
|
||
# the exact previewed signature against the pinned sha256 bytes.
|
||
'signature': sig_b64 if sig_result['status'] != 'unsigned' else None,
|
||
}
|
||
if sig_result['status'] == 'invalid':
|
||
signature['error'] = sig_result.get('error')
|
||
|
||
# Validate shape early so preview surfaces manifest problems too.
|
||
_validate_manifest(manifest)
|
||
|
||
slug = manifest['name']
|
||
minv = manifest.get('min_panel_version')
|
||
maxv = manifest.get('max_panel_version')
|
||
|
||
warnings = []
|
||
if _LAST_RESOLUTION.get('zipball_fallback'):
|
||
warnings.append(
|
||
'No release asset found — installing the default-branch source '
|
||
'archive. Publish a release with a plugin .zip asset for '
|
||
'reproducible, checksum-pinned installs.')
|
||
if (minv or maxv):
|
||
panel = get_panel_version()
|
||
if not version_satisfies(panel, minv, maxv):
|
||
warnings.append(
|
||
f'This extension targets panel {minv or "*"}-{maxv or "*"}, '
|
||
f'but this panel is {panel}. Install will be blocked.')
|
||
if InstalledPlugin.query.filter_by(slug=slug).first():
|
||
warnings.append(
|
||
f'An extension with the slug "{slug}" is already installed. '
|
||
f'Installing will reinstall/overwrite it.')
|
||
|
||
return {
|
||
'slug': slug,
|
||
'display_name': manifest.get('display_name') or slug,
|
||
'version': manifest.get('version'),
|
||
'description': manifest.get('description', ''),
|
||
'author': manifest.get('author', ''),
|
||
'homepage': manifest.get('homepage', ''),
|
||
'permissions': manifest.get('permissions', []) if isinstance(
|
||
manifest.get('permissions'), list) else [],
|
||
'min_panel_version': minv,
|
||
'max_panel_version': maxv,
|
||
'resolved_url': resolved,
|
||
'sha256': digest,
|
||
'signature': signature,
|
||
'warnings': warnings,
|
||
}
|
||
|
||
|
||
def install_from_path(path, user_id=None, force=False, hot_load=True,
|
||
source_type=None):
|
||
"""Install a plugin from a local directory on the panel host.
|
||
|
||
Useful during plugin development: point at the working tree, install,
|
||
iterate. Internally we zip the folder in memory and reuse the same
|
||
install pipeline as URL/upload installs so behavior is identical.
|
||
|
||
Note: "local" here means local to the *panel backend*, not to the
|
||
user's browser. If the backend runs in Docker, browser-host paths
|
||
won't resolve — the user should either bind-mount their plugin
|
||
source into the container or use install_from_zip via the upload
|
||
endpoint instead.
|
||
"""
|
||
if not path:
|
||
raise ValueError('path is required')
|
||
|
||
raw = path
|
||
|
||
# Windows path on a non-Windows panel host is the most common
|
||
# foot-gun (typical case: backend in Docker, dev on Windows). Detect
|
||
# and reject with a helpful message before os.path.abspath turns it
|
||
# into garbage like /app/C:\Users\...
|
||
if os.name != 'nt' and len(path) >= 3 and path[1:3] == ':\\':
|
||
raise ValueError(
|
||
f"'{raw}' looks like a Windows path, but the panel backend is "
|
||
f"running on {os.name!r} (likely a Linux container). The folder "
|
||
f"install runs on the panel host's filesystem, not your browser's. "
|
||
f"Either bind-mount your plugin source into the backend container, "
|
||
f"or use 'Upload Zip' instead."
|
||
)
|
||
|
||
path = os.path.abspath(os.path.expanduser(path))
|
||
if not os.path.isdir(path):
|
||
raise ValueError(
|
||
f"Not a directory: {path}\n"
|
||
f"This path is resolved on the panel backend ({os.name!r}), not "
|
||
f"your browser. If the backend runs in Docker, the path must "
|
||
f"exist inside the container — bind-mount the source folder or "
|
||
f"use 'Upload Zip'."
|
||
)
|
||
if not os.path.exists(os.path.join(path, 'plugin.json')):
|
||
raise ValueError(f'No plugin.json in {path}')
|
||
|
||
# Zip the folder in memory, skipping dev junk that bloats the bundle.
|
||
skip_dirs = {
|
||
'.git', '.github', 'node_modules', '__pycache__',
|
||
'.venv', 'venv', 'dist', 'build', '.pytest_cache',
|
||
'.idea', '.vscode',
|
||
}
|
||
skip_files_endswith = ('.pyc', '.pyo')
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||
for root, dirs, files in os.walk(path):
|
||
# mutate dirs in place so os.walk skips them
|
||
dirs[:] = [d for d in dirs if d not in skip_dirs]
|
||
for name in files:
|
||
if name.endswith(skip_files_endswith):
|
||
continue
|
||
full = os.path.join(root, name)
|
||
rel = os.path.relpath(full, path).replace(os.sep, '/')
|
||
zf.write(full, rel)
|
||
buf.seek(0)
|
||
|
||
return _install_from_buffer(
|
||
buf, source_url=path, source_type=source_type or 'local',
|
||
user_id=user_id, force=force, hot_load=hot_load,
|
||
)
|
||
|
||
|
||
def install_from_zip(zip_bytes, user_id=None, source_name=None):
|
||
"""Install a plugin from raw zip bytes (e.g. a multipart upload)."""
|
||
if not zip_bytes:
|
||
raise ValueError('Empty upload')
|
||
buf = io.BytesIO(zip_bytes)
|
||
return _install_from_buffer(
|
||
buf,
|
||
source_url=source_name or 'uploaded.zip',
|
||
source_type='upload',
|
||
user_id=user_id,
|
||
)
|
||
|
||
|
||
def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False,
|
||
hot_load=True, sig_result=None):
|
||
"""Shared install pipeline: takes a seekable BytesIO containing a zip,
|
||
extracts it into the panel's plugin dirs, and registers / hot-loads
|
||
the resulting blueprint. All public install_* helpers funnel here so
|
||
behavior matches across URL / local / upload sources.
|
||
|
||
`force=True` allows reinstalling over an active plugin (the update path);
|
||
normal installs still refuse to clobber an active install. `hot_load=False`
|
||
skips the immediate blueprint registration (the boot repair pass, #48).
|
||
|
||
`sig_result` is the signature verdict produced by the caller (only
|
||
install_from_url can produce one — local/upload/builtin installs have no
|
||
detached signature). It is stamped on EVERY successful install so a
|
||
reinstall via a different source can never inherit a stale 'verified'
|
||
badge from an earlier signed install (audit M1).
|
||
"""
|
||
_ensure_dirs()
|
||
|
||
# Open zip
|
||
try:
|
||
zf = zipfile.ZipFile(buf)
|
||
except zipfile.BadZipFile:
|
||
raise ValueError('File is not a valid zip archive')
|
||
|
||
# Find and read manifest
|
||
manifest_path, prefix = _find_manifest(zf)
|
||
if not manifest_path:
|
||
raise ValueError('No plugin.json found in archive')
|
||
|
||
manifest = json.loads(zf.read(manifest_path))
|
||
_validate_manifest(manifest)
|
||
_assert_manifest_panel_compatible(manifest)
|
||
|
||
slug = manifest['name']
|
||
|
||
# Check if already installed
|
||
existing = InstalledPlugin.query.filter_by(slug=slug).first()
|
||
if existing and existing.status in ('active', 'installing') and not force:
|
||
raise ValueError(f"Plugin '{slug}' is already installed (v{existing.version}). Uninstall first to reinstall.")
|
||
|
||
# Remember the version we're replacing so we can run lifecycle.upgrade when
|
||
# it actually changes (reinstall/update over an existing row).
|
||
previous_version = existing.version if existing else None
|
||
|
||
# Create DB record early so we can track errors
|
||
if existing:
|
||
plugin = existing
|
||
plugin.status = InstalledPlugin.STATUS_INSTALLING
|
||
plugin.error_message = None
|
||
plugin.version = manifest['version']
|
||
plugin.source_url = source_url
|
||
plugin.source_type = source_type
|
||
plugin.manifest = manifest
|
||
# Refresh display/description/author/etc from the new manifest so
|
||
# the UI reflects the just-installed version, not the original.
|
||
_update_plugin_metadata(plugin, manifest)
|
||
else:
|
||
plugin = InstalledPlugin(
|
||
name=manifest['name'],
|
||
display_name=manifest['display_name'],
|
||
slug=slug,
|
||
version=manifest['version'],
|
||
description=manifest.get('description', ''),
|
||
author=manifest.get('author', ''),
|
||
homepage=manifest.get('homepage', ''),
|
||
repository=manifest.get('repository', ''),
|
||
license=manifest.get('license', ''),
|
||
category=manifest.get('category', 'utility'),
|
||
source_url=source_url,
|
||
source_type=source_type,
|
||
installed_by=user_id,
|
||
status=InstalledPlugin.STATUS_INSTALLING,
|
||
)
|
||
plugin.manifest = manifest
|
||
db.session.add(plugin)
|
||
|
||
db.session.commit()
|
||
|
||
try:
|
||
# First pass — figure out what the archive contains. This lets us
|
||
# bail early with a useful error if the plugin needs a frontend
|
||
# dir we can't write to (the dockerized panel case).
|
||
has_backend = False
|
||
has_frontend = False
|
||
for member in zf.namelist():
|
||
rel = member[len(prefix):] if prefix else member
|
||
if not rel or rel.endswith('/'):
|
||
continue
|
||
if rel.startswith('backend/'):
|
||
has_backend = True
|
||
elif rel.startswith('frontend/'):
|
||
has_frontend = True
|
||
|
||
if has_frontend:
|
||
_ensure_frontend_dir() # raises with a helpful message on failure
|
||
|
||
backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug)
|
||
frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug)
|
||
|
||
# Clean old install
|
||
if os.path.exists(backend_dest):
|
||
shutil.rmtree(backend_dest)
|
||
if has_frontend and os.path.exists(frontend_dest):
|
||
shutil.rmtree(frontend_dest)
|
||
|
||
# Decompression cap (audit L5): refuse archives that expand past
|
||
# _MAX_EXTRACT_BYTES — a small zip can declare or write gigabytes
|
||
# (zip bomb). Pre-check the declared sizes (cheap, catches honest
|
||
# bombs before writing a byte), then count actual bytes written
|
||
# (the declared size can lie). On abort, remove the partial dest
|
||
# dirs so no half-extracted plugin is left behind.
|
||
declared_total = sum(
|
||
info.file_size for info in zf.infolist() if not info.is_dir())
|
||
if declared_total > _MAX_EXTRACT_BYTES:
|
||
raise ValueError(
|
||
f'Archive expands to {declared_total // (1024 * 1024)} MB, '
|
||
f'over the {_MAX_EXTRACT_BYTES // (1024 * 1024)} MB cap — '
|
||
f'refusing to extract (possible zip bomb)')
|
||
extracted_total = 0
|
||
|
||
def _write_member(src, dst):
|
||
nonlocal extracted_total
|
||
while True:
|
||
chunk = src.read(1024 * 1024)
|
||
if not chunk:
|
||
break
|
||
extracted_total += len(chunk)
|
||
if extracted_total > _MAX_EXTRACT_BYTES:
|
||
raise ValueError(
|
||
f'Archive expands past the '
|
||
f'{_MAX_EXTRACT_BYTES // (1024 * 1024)} MB cap — '
|
||
f'refusing to extract (possible zip bomb)')
|
||
dst.write(chunk)
|
||
|
||
try:
|
||
for member in zf.namelist():
|
||
# Strip the GitHub zipball prefix
|
||
rel_path = member[len(prefix):] if prefix else member
|
||
if not rel_path or rel_path.endswith('/'):
|
||
continue
|
||
|
||
if rel_path.startswith('backend/'):
|
||
# Zip Slip defense: _safe_extract_path rejects absolute
|
||
# paths, .. segments, and anything that resolves outside
|
||
# backend_dest after normalization.
|
||
out_path = _safe_extract_path(
|
||
backend_dest, rel_path[len('backend/'):]
|
||
)
|
||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||
with zf.open(member) as src, open(out_path, 'wb') as dst:
|
||
_write_member(src, dst)
|
||
|
||
elif rel_path.startswith('frontend/'):
|
||
out_path = _safe_extract_path(
|
||
frontend_dest, rel_path[len('frontend/'):]
|
||
)
|
||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||
with zf.open(member) as src, open(out_path, 'wb') as dst:
|
||
_write_member(src, dst)
|
||
|
||
elif rel_path == 'requirements.txt':
|
||
# Plugin Python deps are sandboxed by default: installing
|
||
# them runs pip with the backend's privileges, which means
|
||
# arbitrary code via setup.py hooks. Operators must opt in
|
||
# by setting SERVERKIT_ALLOW_PLUGIN_PIP=1.
|
||
# Same predicate the admin surface reports (plan 55 task 6)
|
||
# — if the two ever disagreed, the page would describe a
|
||
# behaviour the installer does not have.
|
||
req_content = zf.read(member).decode('utf-8')
|
||
if req_content.strip() and plugin_pip_enabled():
|
||
_install_requirements(req_content, slug)
|
||
elif req_content.strip():
|
||
# Persist the requirements file so the admin can review
|
||
# and install it manually if they want.
|
||
req_out = _safe_extract_path(backend_dest, 'requirements.txt')
|
||
os.makedirs(os.path.dirname(req_out), exist_ok=True)
|
||
with open(req_out, 'w', encoding='utf-8') as f:
|
||
f.write(req_content)
|
||
logger.warning(
|
||
f"Plugin {slug} ships requirements.txt; skipping install "
|
||
f"(set SERVERKIT_ALLOW_PLUGIN_PIP=1 to enable). "
|
||
f"File saved to {req_out} for manual review."
|
||
)
|
||
except ValueError:
|
||
# Extraction aborted (zip bomb cap) — drop the partial dest dirs so
|
||
# no half-extracted plugin is left behind, then re-raise into the
|
||
# outer error handler (marks the row STATUS_ERROR).
|
||
for dest in (backend_dest, frontend_dest):
|
||
if os.path.isdir(dest):
|
||
shutil.rmtree(dest, ignore_errors=True)
|
||
raise
|
||
|
||
# Also write the manifest into the backend plugin dir for runtime access.
|
||
# The trailing newline matters: these manifests are tracked files that
|
||
# scripts/new-extension.mjs and scripts/sync-builtin-frontends.mjs also
|
||
# write, and both append one. json.dump does not, so without it every
|
||
# install rewrote the file with the newline stripped and left a dirty
|
||
# working tree whose diff contained no actual change.
|
||
# newline='\n' matters for the same reason: .gitattributes pins the repo
|
||
# to `text=auto eol=lf`, but Python's text mode translates \n to \r\n on
|
||
# Windows, so a dev-machine install rewrote every tracked manifest with
|
||
# CRLF.
|
||
if has_backend:
|
||
manifest_out = os.path.join(backend_dest, 'plugin.json')
|
||
with open(manifest_out, 'w', newline='\n') as f:
|
||
json.dump(manifest, f, indent=2)
|
||
f.write('\n')
|
||
|
||
# Also write manifest to frontend dir
|
||
if has_frontend:
|
||
manifest_out = os.path.join(frontend_dest, 'plugin.json')
|
||
with open(manifest_out, 'w', newline='\n') as f:
|
||
json.dump(manifest, f, indent=2)
|
||
f.write('\n')
|
||
|
||
# Determine blueprint info from manifest
|
||
entry_point = manifest.get('entry_point', '')
|
||
url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}')
|
||
|
||
plugin.has_backend = has_backend
|
||
plugin.has_frontend = has_frontend
|
||
plugin.backend_path = f'app/plugins/{slug}' if has_backend else None
|
||
plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None
|
||
plugin.entry_point = entry_point
|
||
plugin.url_prefix = url_prefix
|
||
plugin.frontend_entry = manifest.get('frontend_entry', '')
|
||
# Runtime-frontend integrity (plan 25 #6, core-slim #39): pin the sha256
|
||
# of the prebuilt ESM bundle so the client loader can verify the bytes it
|
||
# blob-imports match what was installed. No-op for baked .jsx builtins.
|
||
if has_frontend:
|
||
_record_frontend_hashes(plugin, manifest, frontend_dest)
|
||
plugin.status = InstalledPlugin.STATUS_ACTIVE
|
||
db.session.commit()
|
||
# Stamp the signature verdict on every successful install (None for
|
||
# local/upload/builtin sources → 'unsigned'), so a reinstall via a
|
||
# different source never keeps a stale 'verified' badge.
|
||
_record_signature_status(plugin, sig_result)
|
||
|
||
# Try to register the blueprint immediately (hot-load)
|
||
if has_backend and entry_point and hot_load:
|
||
try:
|
||
_register_plugin_blueprint(plugin)
|
||
except Exception as e:
|
||
logger.warning(f'Blueprint hot-load failed for {slug} (will load on restart): {e}')
|
||
|
||
# Regenerate frontend plugin manifest
|
||
if has_frontend:
|
||
_regenerate_frontend_manifest()
|
||
|
||
# Install template dependencies declared in manifest
|
||
# (e.g. a "git-server" extension declaring "templates": ["gitea"]).
|
||
# Best effort — record per-template result on the plugin row so the
|
||
# UI can show what happened, but a template install failure does
|
||
# not roll back the plugin install.
|
||
template_results = _install_template_dependencies(plugin, manifest)
|
||
|
||
# Register plugin-owned data models (create ext_<slug>_ tables) and
|
||
# declarative jobs/schedules before the install hook runs, so the hook
|
||
# can seed rows into those tables. Best-effort (never fail the install).
|
||
if has_backend:
|
||
try:
|
||
from app.services import extension_lifecycle
|
||
extension_lifecycle.register_models(plugin, manifest)
|
||
extension_lifecycle.register_jobs(plugin, manifest)
|
||
extension_lifecycle.register_capabilities(plugin, manifest)
|
||
except Exception as e:
|
||
logger.warning(f'Extension lifecycle setup failed for {slug}: {e}')
|
||
try:
|
||
from app.plugins_sdk import sockets as _ext_sockets
|
||
_ext_sockets.register_from_manifest(plugin, manifest)
|
||
except Exception as e:
|
||
logger.warning(f'Extension socket setup failed for {slug}: {e}')
|
||
|
||
# Run plugin's lifecycle.install hook if declared. Failure here is
|
||
# also non-fatal — the plugin is installed; the hook is for
|
||
# convenience setup (e.g. creating default rows).
|
||
_run_lifecycle_hook(plugin, manifest, 'install')
|
||
|
||
# If this was an in-place version change, run the upgrade hook too.
|
||
if previous_version and previous_version != manifest['version']:
|
||
_run_lifecycle_hook(plugin, manifest, 'upgrade')
|
||
|
||
# If the plugin declares an "ai" block, register its tools/context with
|
||
# the core assistant now so installing it teaches the assistant new
|
||
# abilities without a restart. Best effort.
|
||
_refresh_plugin_ai(plugin)
|
||
|
||
logger.info(f'Plugin {slug} v{manifest["version"]} installed successfully')
|
||
if template_results:
|
||
plugin._template_install_results = template_results # surfaced via to_dict if needed
|
||
return plugin
|
||
|
||
except Exception as e:
|
||
plugin.status = InstalledPlugin.STATUS_ERROR
|
||
plugin.error_message = str(e)
|
||
db.session.commit()
|
||
raise
|
||
|
||
|
||
def _install_requirements(req_content, plugin_name):
|
||
"""Install Python requirements for a plugin."""
|
||
if not req_content.strip():
|
||
return
|
||
|
||
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
||
f.write(req_content)
|
||
req_path = f.name
|
||
|
||
try:
|
||
logger.info(f'Installing requirements for plugin {plugin_name}')
|
||
subprocess.check_call(
|
||
[sys.executable, '-m', 'pip', 'install', '-r', req_path, '--quiet'],
|
||
timeout=300,
|
||
)
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error(f'Failed to install requirements for {plugin_name}: {e}')
|
||
raise ValueError(f'Failed to install Python dependencies: {e}')
|
||
finally:
|
||
os.unlink(req_path)
|
||
|
||
|
||
def _attach_status_guard(bp, slug):
|
||
"""Attach a before_request that 503s if the plugin is disabled.
|
||
|
||
Flask blueprints can't be unregistered from a running app, so once a
|
||
plugin's routes are loaded they stay reachable until restart. This
|
||
guard makes the in-DB status authoritative at request time, so
|
||
disabling a plugin actually stops serving its routes.
|
||
"""
|
||
# A blueprint may be mounted at more than one url_prefix (e.g. an alias),
|
||
# but the guard only needs to run once per blueprint object.
|
||
if getattr(bp, '_sk_status_guarded', False):
|
||
return
|
||
def _check():
|
||
from flask import jsonify
|
||
p = InstalledPlugin.query.filter_by(slug=slug).first()
|
||
if not p or p.status != InstalledPlugin.STATUS_ACTIVE:
|
||
return jsonify({
|
||
'error': f"Plugin '{slug}' is not active",
|
||
'status': p.status if p else 'uninstalled',
|
||
}), 503
|
||
return None
|
||
bp.before_request(_check)
|
||
bp._sk_status_guarded = True
|
||
|
||
|
||
def _register_extra_blueprints(register, plugin, manifest):
|
||
"""Register a plugin's ``extra_blueprints`` beyond the single ``entry_point``.
|
||
|
||
Some extensions (WordPress) expose several blueprints and even mount one
|
||
blueprint at more than one prefix (a deprecation-window alias). The manifest
|
||
declares them as::
|
||
|
||
"extra_blueprints": [
|
||
{"module": "wordpress_sites", "attr": "wordpress_sites_bp",
|
||
"url_prefix": "/api/v1/wordpress"},
|
||
{"module": "environment_pipeline", "attr": "environment_pipeline_bp",
|
||
"url_prefix": "/api/v1/wordpress/pipelines",
|
||
"name": "environment_pipeline_pipelines"}
|
||
]
|
||
|
||
``register`` is a ``callable(bp, url_prefix, name=None)`` — either
|
||
``current_app.register_blueprint`` (hot-load) or ``app.register_blueprint``
|
||
(boot). Each blueprint gets the same status guard as the entry blueprint.
|
||
"""
|
||
import importlib
|
||
for extra in (manifest or {}).get('extra_blueprints', []):
|
||
module_name = extra.get('module')
|
||
attr = extra.get('attr')
|
||
url_prefix = extra.get('url_prefix')
|
||
name = extra.get('name')
|
||
if not (module_name and attr):
|
||
continue
|
||
full_module = f'app.plugins.{plugin.slug}.{module_name}'
|
||
mod = importlib.import_module(full_module)
|
||
bp = getattr(mod, attr)
|
||
_attach_status_guard(bp, plugin.slug)
|
||
# Only pass name= when the manifest supplies one (mounting the SAME
|
||
# blueprint at a second prefix needs a distinct name, e.g. the WordPress
|
||
# /pipelines alias); passing name=None would clobber the blueprint name.
|
||
opts = {'url_prefix': url_prefix}
|
||
if name:
|
||
opts['name'] = name
|
||
register(bp, **opts)
|
||
logger.info(f'Registered extra blueprint {attr} at {url_prefix}'
|
||
+ (f' (as {name})' if name else ''))
|
||
|
||
|
||
def _register_plugin_blueprint(plugin):
|
||
"""Dynamically register a plugin's Flask blueprint into the running app."""
|
||
from flask import current_app
|
||
import importlib
|
||
|
||
if not plugin.entry_point:
|
||
return
|
||
|
||
# entry_point format: "blueprint:ai_assistant_bp"
|
||
parts = plugin.entry_point.split(':')
|
||
if len(parts) != 2:
|
||
raise ValueError(f'Invalid entry_point format: {plugin.entry_point}')
|
||
|
||
module_name, bp_name = parts
|
||
full_module = f'app.plugins.{plugin.slug}.{module_name}'
|
||
|
||
try:
|
||
mod = importlib.import_module(full_module)
|
||
bp = getattr(mod, bp_name)
|
||
_attach_status_guard(bp, plugin.slug)
|
||
current_app.register_blueprint(bp, url_prefix=plugin.url_prefix)
|
||
logger.info(f'Registered blueprint {bp_name} at {plugin.url_prefix}')
|
||
_register_extra_blueprints(
|
||
current_app.register_blueprint, plugin, plugin.manifest or {})
|
||
except Exception as e:
|
||
raise ValueError(f'Failed to register blueprint: {e}')
|
||
|
||
|
||
def _regenerate_frontend_manifest():
|
||
"""Generate a plugins-manifest.json for the frontend build system.
|
||
|
||
This file tells the frontend which plugins are installed and where
|
||
their components/styles live so Vite can include them.
|
||
|
||
Best-effort: if the frontend dir doesn't exist (e.g. a pure backend
|
||
plugin install where no frontend was ever extracted, or a Docker
|
||
panel without the bind mount), we just log and move on rather than
|
||
failing the install.
|
||
|
||
Never runs under the testing config: the test DB only has whatever a given
|
||
test installed, so regenerating would clobber the repo's checked-in
|
||
plugins-manifest.json with partial state (the flagship seed runs on every
|
||
test boot). Tests that care about the manifest monkeypatch the dir.
|
||
"""
|
||
try:
|
||
from flask import current_app
|
||
if current_app and current_app.config.get('TESTING'):
|
||
return
|
||
except Exception:
|
||
pass
|
||
if not os.path.isdir(FRONTEND_PLUGINS_DIR):
|
||
logger.info(
|
||
f'Skipping frontend manifest regeneration: '
|
||
f'{FRONTEND_PLUGINS_DIR} does not exist'
|
||
)
|
||
return
|
||
manifest_path = os.path.join(FRONTEND_PLUGINS_DIR, 'plugins-manifest.json')
|
||
|
||
plugins = InstalledPlugin.query.filter(
|
||
InstalledPlugin.has_frontend == True,
|
||
InstalledPlugin.status.in_(['active']),
|
||
).all()
|
||
|
||
entries = []
|
||
for p in plugins:
|
||
entry = {
|
||
'name': p.name,
|
||
'slug': p.slug,
|
||
'display_name': p.display_name,
|
||
'version': p.version,
|
||
'frontend_entry': p.frontend_entry,
|
||
'path': p.slug,
|
||
}
|
||
# Check for styles
|
||
style_dir = os.path.join(FRONTEND_PLUGINS_DIR, p.slug, 'styles')
|
||
if os.path.isdir(style_dir):
|
||
styles = [
|
||
f for f in os.listdir(style_dir)
|
||
if f.endswith('.scss') or f.endswith('.css') or f.endswith('.less')
|
||
]
|
||
entry['styles'] = [f'plugins/{p.slug}/styles/{s}' for s in styles]
|
||
entries.append(entry)
|
||
|
||
# Trailing newline and LF for the same reasons as the per-plugin manifests
|
||
# above: scripts/sync-builtin-frontends.mjs writes this same tracked file
|
||
# with one, and .gitattributes pins the repo to eol=lf.
|
||
with open(manifest_path, 'w', newline='\n') as f:
|
||
json.dump({'plugins': entries}, f, indent=2)
|
||
f.write('\n')
|
||
|
||
logger.info(f'Frontend plugin manifest regenerated with {len(entries)} plugin(s)')
|
||
|
||
|
||
def repair_missing_plugins():
|
||
"""Self-heal installed plugins whose files are gone (#48).
|
||
|
||
A panel update deploys a fresh source tree preserving only `.env` and the
|
||
database, so a URL/upload-installed plugin's extracted files (and a
|
||
copy-installed builtin's backend under ``app/plugins/``) can vanish while
|
||
the InstalledPlugin row stays active — the boot loader would then flip it
|
||
to `error`. Before the loader runs, restore what we can:
|
||
|
||
- builtin slug → reinstall from ``builtin-extensions/`` (in-repo,
|
||
always present in the fresh tree)
|
||
- source_type 'url' or 'registry'
|
||
→ re-download from ``source_url`` (no checksum —
|
||
the original install verified it; best effort)
|
||
- anything else → mark `error` with a re-upload hint
|
||
|
||
Reinstalls run with ``hot_load=False`` — load_all_plugins registers the
|
||
blueprints right after this pass. Never raises; each repair is
|
||
best-effort and logged. The update.sh carry-forward is the first line of
|
||
defense; this is the backstop (e.g. Docker image updates).
|
||
"""
|
||
builtin_by_slug = {}
|
||
try:
|
||
for entry in list_builtin_extensions():
|
||
builtin_by_slug[entry['slug']] = entry
|
||
except Exception:
|
||
pass
|
||
|
||
plugins = InstalledPlugin.query.filter(
|
||
InstalledPlugin.status.in_([
|
||
InstalledPlugin.STATUS_ACTIVE,
|
||
InstalledPlugin.STATUS_ERROR,
|
||
]),
|
||
).all()
|
||
|
||
for plugin in plugins:
|
||
slug = plugin.slug
|
||
# Flagships load in-place from builtin-extensions/ (repo-tracked, never
|
||
# copy-installed) — nothing on disk to go missing.
|
||
if slug in FLAGSHIP_SLUGS:
|
||
continue
|
||
|
||
backend_missing = bool(plugin.has_backend) and not os.path.isdir(
|
||
os.path.join(BACKEND_PLUGINS_DIR, slug))
|
||
frontend_missing = bool(plugin.has_frontend) and not os.path.isdir(
|
||
os.path.join(FRONTEND_PLUGINS_DIR, slug))
|
||
if not backend_missing and not frontend_missing:
|
||
continue
|
||
|
||
halves = ' + '.join(h for h, m in (('backend', backend_missing),
|
||
('frontend', frontend_missing)) if m)
|
||
logger.warning(
|
||
f'Plugin {slug}: {halves} files missing (panel update?) — attempting repair')
|
||
|
||
try:
|
||
if slug in builtin_by_slug:
|
||
install_from_path(builtin_by_slug[slug]['path'],
|
||
user_id=plugin.installed_by,
|
||
force=True, hot_load=False,
|
||
source_type='builtin')
|
||
logger.info(f'Plugin {slug}: restored from builtin-extensions/')
|
||
elif plugin.source_type in ('url', 'registry') and plugin.source_url:
|
||
install_from_url(plugin.source_url, user_id=plugin.installed_by,
|
||
force=True, hot_load=False,
|
||
source_type=plugin.source_type)
|
||
logger.info(f'Plugin {slug}: reinstalled from {plugin.source_url}')
|
||
else:
|
||
plugin.status = InstalledPlugin.STATUS_ERROR
|
||
plugin.error_message = (
|
||
'Plugin files are missing (likely removed by a panel update) '
|
||
'and there is no source URL to reinstall from. Re-upload the '
|
||
'plugin zip from the Marketplace to restore it.'
|
||
)
|
||
db.session.commit()
|
||
except Exception as e:
|
||
logger.error(f'Plugin {slug}: repair failed: {e}')
|
||
try:
|
||
plugin.status = InstalledPlugin.STATUS_ERROR
|
||
plugin.error_message = f'Repair after panel update failed: {e}'
|
||
db.session.commit()
|
||
except Exception:
|
||
db.session.rollback()
|
||
|
||
|
||
def load_all_plugins(app):
|
||
"""Load all active plugin blueprints at app startup.
|
||
|
||
Called from create_app() to register all installed plugin blueprints.
|
||
"""
|
||
_ensure_dirs()
|
||
|
||
with app.app_context():
|
||
# Restore any install whose files a panel update wiped (#48) BEFORE
|
||
# querying/loading, so a repaired plugin loads normally this boot.
|
||
try:
|
||
repair_missing_plugins()
|
||
except Exception as e:
|
||
logger.warning(f'Plugin repair pass failed: {e}')
|
||
|
||
plugins = InstalledPlugin.query.filter_by(
|
||
status=InstalledPlugin.STATUS_ACTIVE,
|
||
has_backend=True,
|
||
).all()
|
||
|
||
for plugin in plugins:
|
||
if not plugin.entry_point:
|
||
continue
|
||
try:
|
||
parts = plugin.entry_point.split(':')
|
||
if len(parts) != 2:
|
||
continue
|
||
|
||
module_name, bp_name = parts
|
||
full_module = f'app.plugins.{plugin.slug}.{module_name}'
|
||
|
||
import importlib
|
||
mod = importlib.import_module(full_module)
|
||
bp = getattr(mod, bp_name)
|
||
_attach_status_guard(bp, plugin.slug)
|
||
app.register_blueprint(bp, url_prefix=plugin.url_prefix)
|
||
logger.info(f'Loaded plugin: {plugin.display_name} v{plugin.version} at {plugin.url_prefix}')
|
||
|
||
manifest = plugin.manifest or {}
|
||
_register_extra_blueprints(app.register_blueprint, plugin, manifest)
|
||
|
||
# Re-establish the plugin's data models, jobs, core-hook
|
||
# registrations, and socket namespace on boot (install-time
|
||
# registration doesn't survive a restart).
|
||
try:
|
||
from app.services import extension_lifecycle
|
||
extension_lifecycle.register_models(plugin, manifest)
|
||
extension_lifecycle.register_jobs(plugin, manifest)
|
||
extension_lifecycle.register_capabilities(plugin, manifest)
|
||
from app.plugins_sdk import sockets as _ext_sockets
|
||
_ext_sockets.register_from_manifest(plugin, manifest)
|
||
except Exception as e:
|
||
logger.warning(f'Extension runtime setup for {plugin.slug}: {e}')
|
||
except Exception as e:
|
||
logger.error(f'Failed to load plugin {plugin.slug}: {e}')
|
||
plugin.status = InstalledPlugin.STATUS_ERROR
|
||
plugin.error_message = f'Failed to load: {e}'
|
||
db.session.commit()
|
||
|
||
|
||
def uninstall_plugin(plugin_id, purge=False):
|
||
"""Uninstall a plugin by removing its files and DB record.
|
||
|
||
`purge=True` also drops the plugin's ``ext_<slug>_`` data tables (keep-data
|
||
is the default, mirroring the installer's --purge/--keep-data semantics).
|
||
"""
|
||
plugin = InstalledPlugin.query.get(plugin_id)
|
||
if not plugin:
|
||
return False
|
||
|
||
slug = plugin.slug
|
||
manifest = plugin.manifest or {}
|
||
|
||
# Run lifecycle.uninstall hook *before* we delete the files (the hook
|
||
# may need to read its own files). It's told whether the caller asked to
|
||
# purge data. Best effort — never block teardown.
|
||
_run_lifecycle_hook(plugin, manifest, 'uninstall', purge=purge)
|
||
|
||
# Tear down declarative schedules, and (only on purge) the data tables and
|
||
# the plugin's key/value store. Store rows follow the same rule as the
|
||
# ext_* tables: an ordinary uninstall leaves them, so a reinstall finds its
|
||
# state where it left it, and "uninstall and delete data" removes them.
|
||
try:
|
||
from app.services import extension_lifecycle
|
||
extension_lifecycle.remove_jobs(plugin, manifest)
|
||
# Audit F1: seam registrations (backup kinds, event types, template
|
||
# providers) must not keep executing after uninstall.
|
||
extension_lifecycle.unregister_capabilities(plugin)
|
||
if purge:
|
||
extension_lifecycle.purge_models(plugin)
|
||
from app.plugins_sdk.store_sdk import purge as purge_store
|
||
removed = purge_store(slug)
|
||
if removed:
|
||
logger.info(f'Purged {removed} store row(s) for {slug}')
|
||
except Exception as e:
|
||
logger.warning(f'Extension lifecycle teardown for {slug}: {e}')
|
||
|
||
# Drop any AI tools/context this plugin contributed to the assistant.
|
||
_unregister_plugin_ai(slug)
|
||
|
||
if slug in FLAGSHIP_SLUGS:
|
||
# Flagships live in-place (backend in builtin-extensions/, frontend
|
||
# pre-bundled under src/plugins/ — both tracked in the repo). Never delete
|
||
# those; just record the user's choice so the boot seed leaves it out, and
|
||
# drop the row (the status guard 503s any still-registered routes).
|
||
_set_flagship_uninstalled(slug, True)
|
||
else:
|
||
# Remove backend files
|
||
backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug)
|
||
if os.path.exists(backend_dest):
|
||
shutil.rmtree(backend_dest)
|
||
|
||
# Remove frontend files
|
||
frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug)
|
||
if os.path.exists(frontend_dest):
|
||
shutil.rmtree(frontend_dest)
|
||
|
||
db.session.delete(plugin)
|
||
db.session.commit()
|
||
|
||
# Regenerate frontend manifest
|
||
_regenerate_frontend_manifest()
|
||
|
||
logger.info(f'Plugin {slug} uninstalled')
|
||
return True
|
||
|
||
|
||
def enable_plugin(plugin_id):
|
||
"""Enable a disabled plugin."""
|
||
plugin = InstalledPlugin.query.get(plugin_id)
|
||
if not plugin:
|
||
return None
|
||
plugin.status = InstalledPlugin.STATUS_ACTIVE
|
||
plugin.error_message = None
|
||
db.session.commit()
|
||
_regenerate_frontend_manifest()
|
||
_refresh_plugin_ai(plugin)
|
||
# Resume the plugin's scheduled jobs (status-guard parity for background work).
|
||
try:
|
||
from app.services import extension_lifecycle
|
||
extension_lifecycle.resume_jobs(plugin, plugin.manifest or {})
|
||
# Audit F4: re-register the core seams disable tore down (idempotent).
|
||
extension_lifecycle.register_capabilities(plugin, plugin.manifest or {})
|
||
except Exception as e:
|
||
logger.warning(f'Could not resume jobs for {plugin.slug}: {e}')
|
||
return plugin
|
||
|
||
|
||
def disable_plugin(plugin_id):
|
||
"""Disable a plugin without removing files."""
|
||
plugin = InstalledPlugin.query.get(plugin_id)
|
||
if not plugin:
|
||
return None
|
||
plugin.status = InstalledPlugin.STATUS_DISABLED
|
||
db.session.commit()
|
||
_regenerate_frontend_manifest()
|
||
_unregister_plugin_ai(plugin.slug)
|
||
# Pause the plugin's scheduled jobs so a disabled plugin does no background work.
|
||
try:
|
||
from app.services import extension_lifecycle
|
||
extension_lifecycle.pause_jobs(plugin, plugin.manifest or {})
|
||
# Audit F1: seam registrations (backup kinds, event types, template
|
||
# providers) must not keep executing while disabled.
|
||
extension_lifecycle.unregister_capabilities(plugin)
|
||
except Exception as e:
|
||
logger.warning(f'Could not pause jobs for {plugin.slug}: {e}')
|
||
return plugin
|
||
|
||
|
||
def _refresh_plugin_ai(plugin):
|
||
"""Register a plugin's AI tools/context with the core assistant (if it has an
|
||
``ai`` manifest block). Idempotent; safe whether or not AI is initialized."""
|
||
try:
|
||
manifest = plugin.manifest or {}
|
||
if isinstance(manifest.get('ai'), dict):
|
||
from app.services.ai_tool_registry import ai_tool_registry
|
||
ai_tool_registry.reload_plugin(plugin.slug)
|
||
except Exception:
|
||
logger.warning(f'AI tool refresh failed for plugin {plugin.slug}', exc_info=True)
|
||
|
||
|
||
def _unregister_plugin_ai(slug):
|
||
"""Drop a plugin's AI tools/context from the core assistant."""
|
||
try:
|
||
from app.services.ai_tool_registry import ai_tool_registry
|
||
ai_tool_registry.unregister_plugin(slug)
|
||
except Exception:
|
||
logger.warning(f'AI tool unregister failed for plugin {slug}', exc_info=True)
|
||
|
||
|
||
def _run_lifecycle_hook(plugin, manifest, phase, **kwargs):
|
||
"""Execute a plugin's lifecycle hook.
|
||
|
||
Manifest format:
|
||
"lifecycle": { "install": "module:func", "upgrade": "module:func",
|
||
"uninstall": "module:func" }
|
||
|
||
The module path is resolved under ``app.plugins.<slug>``. The hook receives
|
||
the InstalledPlugin row as its single positional arg; the uninstall hook may
|
||
additionally accept ``purge`` (whether the caller asked to drop data). Hooks
|
||
written as ``func(plugin)`` still work — extra kwargs are only passed when the
|
||
signature accepts them. Return value is ignored; failure is logged and
|
||
swallowed — lifecycle hooks are convenience, not correctness.
|
||
"""
|
||
lifecycle = (manifest or {}).get('lifecycle') or {}
|
||
target = lifecycle.get(phase)
|
||
if not target or ':' not in target:
|
||
return
|
||
|
||
module_name, func_name = target.split(':', 1)
|
||
full_module = f'app.plugins.{plugin.slug}.{module_name}'
|
||
|
||
try:
|
||
import importlib
|
||
import inspect
|
||
mod = importlib.import_module(full_module)
|
||
func = getattr(mod, func_name, None)
|
||
if not callable(func):
|
||
logger.warning(
|
||
f'Lifecycle {phase} hook {target} for {plugin.slug} is not callable'
|
||
)
|
||
return
|
||
# Only forward kwargs the hook actually declares (keeps func(plugin) valid).
|
||
if kwargs:
|
||
try:
|
||
params = inspect.signature(func).parameters
|
||
accepts_kwargs = any(
|
||
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
||
)
|
||
passable = {k: v for k, v in kwargs.items()
|
||
if accepts_kwargs or k in params}
|
||
except (TypeError, ValueError):
|
||
passable = {}
|
||
func(plugin, **passable)
|
||
else:
|
||
func(plugin)
|
||
logger.info(f'Ran lifecycle.{phase} hook for {plugin.slug}')
|
||
except Exception as e:
|
||
logger.warning(f'Lifecycle {phase} hook for {plugin.slug} failed: {e}')
|
||
|
||
|
||
def _install_template_dependencies(plugin, manifest):
|
||
"""Install app templates declared in the manifest's `templates` key.
|
||
|
||
Format:
|
||
"templates": ["gitea", "umami"] # template ids
|
||
"templates": [{ "id": "gitea",
|
||
"app_name": "git-server", # default: template id
|
||
"variables": {"PORT": "3000"} }]
|
||
|
||
Returns a list of {template_id, success, message} dicts. Failures are
|
||
logged but do not abort the plugin install — the user can install the
|
||
template manually from /templates if it didn't auto-install.
|
||
"""
|
||
deps = (manifest or {}).get('templates') or []
|
||
if not deps:
|
||
return []
|
||
|
||
try:
|
||
from app.services.template_service import TemplateService
|
||
except Exception as e:
|
||
logger.warning(f'TemplateService unavailable; skipping template deps: {e}')
|
||
return []
|
||
|
||
results = []
|
||
for dep in deps:
|
||
if isinstance(dep, str):
|
||
template_id, app_name, variables = dep, dep, {}
|
||
elif isinstance(dep, dict):
|
||
template_id = dep.get('id') or dep.get('template_id')
|
||
app_name = dep.get('app_name') or template_id
|
||
variables = dep.get('variables') or {}
|
||
else:
|
||
continue
|
||
|
||
if not template_id:
|
||
continue
|
||
|
||
try:
|
||
res = TemplateService.install_template(
|
||
template_id=template_id,
|
||
app_name=app_name,
|
||
user_variables=variables,
|
||
user_id=plugin.installed_by,
|
||
)
|
||
ok = bool(res and res.get('success'))
|
||
results.append({
|
||
'template_id': template_id,
|
||
'success': ok,
|
||
'message': res.get('error') if not ok else 'installed',
|
||
})
|
||
if ok:
|
||
logger.info(f'Plugin {plugin.slug} installed template {template_id}')
|
||
else:
|
||
logger.warning(
|
||
f'Plugin {plugin.slug} template dep {template_id} failed: '
|
||
f'{res.get("error")}'
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f'Plugin {plugin.slug} template dep {template_id} raised: {e}'
|
||
)
|
||
results.append({
|
||
'template_id': template_id,
|
||
'success': False,
|
||
'message': str(e),
|
||
})
|
||
|
||
return results
|
||
|
||
|
||
def list_plugins(status=None):
|
||
"""List installed plugins."""
|
||
query = InstalledPlugin.query
|
||
if status:
|
||
query = query.filter_by(status=status)
|
||
return query.order_by(InstalledPlugin.display_name).all()
|
||
|
||
|
||
def get_plugin(plugin_id):
|
||
"""Get a single plugin by ID."""
|
||
return InstalledPlugin.query.get(plugin_id)
|
||
|
||
|
||
def get_plugin_by_slug(slug):
|
||
"""Get a plugin by its slug."""
|
||
return InstalledPlugin.query.filter_by(slug=slug).first()
|
||
|
||
|
||
# Opt-in for installing a plugin's Python dependencies. Off by default: pip runs
|
||
# with the backend's privileges and a setup.py hook is arbitrary code.
|
||
PLUGIN_PIP_ENV = 'SERVERKIT_ALLOW_PLUGIN_PIP'
|
||
# How big a requirements.txt we are willing to echo back to the browser.
|
||
_MAX_REQUIREMENTS_BYTES = 64 * 1024
|
||
|
||
|
||
def plugin_pip_enabled():
|
||
"""Whether the operator has opted into installing plugin Python deps."""
|
||
return os.environ.get(PLUGIN_PIP_ENV, '').lower() in ('1', 'true', 'yes')
|
||
|
||
|
||
def pending_requirements(plugin):
|
||
"""The Python dependencies an install declined to pip-install (plan 55).
|
||
|
||
When the opt-in is off, install writes the plugin's requirements.txt into
|
||
its backend dir and moves on with a log line nobody reads — the extension
|
||
then runs with imports it may not have. This makes that state answerable:
|
||
the file's contents, where it is, and the exact env var that changes the
|
||
behaviour.
|
||
|
||
Read-only by design. Nothing here installs anything, and the plan is
|
||
explicit that this surface introduces no auto-install behaviour change.
|
||
"""
|
||
info = {
|
||
'slug': getattr(plugin, 'slug', None),
|
||
'pending': False,
|
||
'pip_enabled': plugin_pip_enabled(),
|
||
'env_var': PLUGIN_PIP_ENV,
|
||
'path': None,
|
||
'content': '',
|
||
'packages': [],
|
||
'truncated': False,
|
||
}
|
||
backend_path = getattr(plugin, 'backend_path', None)
|
||
if not backend_path:
|
||
return info
|
||
|
||
# backend_path is stored relative to the backend package root; join it the
|
||
# same way the loader does rather than trusting it as absolute.
|
||
candidate = backend_path if os.path.isabs(backend_path) else os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '..',
|
||
backend_path)
|
||
req_path = os.path.normpath(os.path.join(candidate, 'requirements.txt'))
|
||
if not os.path.isfile(req_path):
|
||
return info
|
||
|
||
try:
|
||
size = os.path.getsize(req_path)
|
||
with open(req_path, 'r', encoding='utf-8', errors='replace') as fh:
|
||
content = fh.read(_MAX_REQUIREMENTS_BYTES)
|
||
except OSError as exc:
|
||
logger.warning('could not read %s: %s', req_path, exc)
|
||
return info
|
||
|
||
info.update({
|
||
'pending': True,
|
||
'path': req_path,
|
||
'content': content,
|
||
'truncated': size > _MAX_REQUIREMENTS_BYTES,
|
||
# One line per requirement, comments and blanks dropped — enough for a
|
||
# compact list without pretending to parse PEP 508.
|
||
'packages': [ln.strip() for ln in content.splitlines()
|
||
if ln.strip() and not ln.strip().startswith('#')],
|
||
})
|
||
return info
|
||
|
||
|
||
# Plugin-config keys the panel owns — never user-editable, preserved across a
|
||
# config PUT. The runtime-frontend loader's per-bundle sha256 hashes live here,
|
||
# and so does the install-time signature verdict (plan 55).
|
||
RESERVED_PLUGIN_CONFIG_KEYS = ('_frontend_hashes', '_signature')
|
||
|
||
|
||
def _record_frontend_hashes(plugin, manifest, frontend_dest):
|
||
"""Pin the sha256 of a runtime-frontend ESM bundle at install time.
|
||
|
||
Stored on the plugin's config under the panel-managed ``_frontend_hashes``
|
||
key (a reserved namespace preserved across config edits). Only ESM bundles
|
||
(manifest ``frontend_entry`` ending ``.mjs``) are hashed — baked ``.jsx``
|
||
builtins load via the build-time glob and need no integrity pin. Best
|
||
effort: a missing bundle file just records nothing (the loader then refuses
|
||
to import an extension with no recorded hash).
|
||
"""
|
||
entry = (manifest.get('frontend_entry') or '').strip()
|
||
if not entry.endswith('.mjs'):
|
||
return
|
||
bundle_path = os.path.join(frontend_dest, entry)
|
||
if not os.path.isfile(bundle_path):
|
||
return
|
||
import hashlib
|
||
with open(bundle_path, 'rb') as f:
|
||
digest = hashlib.sha256(f.read()).hexdigest()
|
||
cfg = dict(plugin.config or {})
|
||
hashes = dict(cfg.get('_frontend_hashes') or {})
|
||
hashes[entry] = digest
|
||
cfg['_frontend_hashes'] = hashes
|
||
plugin.config = cfg
|
||
|
||
|
||
def update_plugin_config(slug, config):
|
||
"""Save a plugin's user config, preserving panel-managed reserved keys.
|
||
|
||
A config PUT from the Marketplace replaces the user-visible values but must
|
||
never drop (or let the caller forge) the panel-managed ``_frontend_hashes``
|
||
integrity pins. Reserved keys are stripped from the incoming payload and the
|
||
existing values are re-applied on top.
|
||
"""
|
||
plugin = get_plugin_by_slug(slug)
|
||
if not plugin:
|
||
raise ValueError('Plugin not found')
|
||
|
||
existing = plugin.config or {}
|
||
new_cfg = {
|
||
k: v for k, v in (config or {}).items()
|
||
if k not in RESERVED_PLUGIN_CONFIG_KEYS
|
||
}
|
||
for key in RESERVED_PLUGIN_CONFIG_KEYS:
|
||
if key in existing:
|
||
new_cfg[key] = existing[key]
|
||
plugin.config = new_cfg
|
||
db.session.commit()
|
||
return plugin
|
||
|
||
|
||
def list_builtin_extensions():
|
||
"""Enumerate folders in BUILTIN_EXTENSIONS_DIR that look like plugins.
|
||
|
||
Each entry includes the manifest, the absolute source path, and the
|
||
install state (installed/active/disabled/error/not_installed). The
|
||
Marketplace UI uses this to render one-click installs.
|
||
"""
|
||
out = []
|
||
if not os.path.isdir(BUILTIN_EXTENSIONS_DIR):
|
||
return out
|
||
|
||
for name in sorted(os.listdir(BUILTIN_EXTENSIONS_DIR)):
|
||
folder = os.path.join(BUILTIN_EXTENSIONS_DIR, name)
|
||
manifest_path = os.path.join(folder, 'plugin.json')
|
||
if not os.path.isfile(manifest_path):
|
||
continue
|
||
|
||
try:
|
||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||
manifest = json.load(f)
|
||
except Exception as e:
|
||
logger.warning(f'Skipping builtin {name}: bad plugin.json ({e})')
|
||
continue
|
||
|
||
slug = manifest.get('name') or name
|
||
existing = InstalledPlugin.query.filter_by(slug=slug).first()
|
||
installed_state = existing.status if existing else 'not_installed'
|
||
|
||
out.append({
|
||
'folder': name,
|
||
'path': folder,
|
||
'slug': slug,
|
||
'manifest': manifest,
|
||
'installed': existing is not None,
|
||
'install_id': existing.id if existing else None,
|
||
'status': installed_state,
|
||
})
|
||
|
||
return out
|
||
|
||
|
||
# ── Onboarding wizard: use-case → recommended extension slugs (plan 47) ──
|
||
#
|
||
# Drives the setup wizard's "Recommended for you" install checkboxes. Every slug
|
||
# here MUST resolve to a real bundled builtin (builtin-extensions/) or a registry
|
||
# entry — guarded by tests/test_onboarding_recommendations.py so the map can never
|
||
# point at a dead slug. Order within a use case is significant; the resolver
|
||
# de-dupes across the selected use cases preserving first-seen order.
|
||
RECOMMENDED_EXTENSIONS_BY_USE_CASE = {
|
||
'wordpress': ['serverkit-wordpress', 'serverkit-analytics'],
|
||
'web-apps': ['serverkit-git', 'serverkit-status', 'serverkit-analytics'],
|
||
'self-hosted': ['serverkit-dns-server', 'serverkit-status', 'serverkit-mail'],
|
||
'devops': ['serverkit-k8s', 'serverkit-tramo', 'serverkit-git'],
|
||
}
|
||
|
||
|
||
def _recommendation_index():
|
||
"""slug -> {slug, display_name, description, category, source, installed,
|
||
status}. Merges the registry catalog with bundled builtins so a recommended
|
||
slug resolves regardless of where it lives; builtins win on slug collision."""
|
||
index = {}
|
||
try:
|
||
from app.services import registry_service
|
||
for entry in registry_service.list_catalog(include_bundled=True):
|
||
slug = entry.get('slug')
|
||
if not slug:
|
||
continue
|
||
index[slug] = {
|
||
'slug': slug,
|
||
'display_name': entry.get('display_name') or slug,
|
||
'description': entry.get('description') or '',
|
||
'category': entry.get('category') or 'utility',
|
||
'source': 'registry',
|
||
'installed': bool(entry.get('installed')),
|
||
'status': entry.get('status') or 'not_installed',
|
||
}
|
||
except Exception as e:
|
||
logger.debug(f'Registry unavailable for onboarding recommendations: {e}')
|
||
for entry in list_builtin_extensions():
|
||
manifest = entry.get('manifest') or {}
|
||
slug = entry['slug']
|
||
index[slug] = {
|
||
'slug': slug,
|
||
'display_name': manifest.get('display_name') or slug,
|
||
'description': manifest.get('description') or '',
|
||
'category': manifest.get('category') or 'utility',
|
||
'source': 'builtin',
|
||
'installed': bool(entry.get('installed')),
|
||
'status': entry.get('status') or 'not_installed',
|
||
}
|
||
return index
|
||
|
||
|
||
def recommend_extensions_for_use_cases(use_cases):
|
||
"""Resolve selected onboarding use cases to installable extension metadata.
|
||
|
||
Returns a de-duped, order-preserving list of dicts (one per recommended
|
||
extension). Slugs that don't resolve to a builtin/registry entry are silently
|
||
dropped (defensive — the map is test-guarded against dead slugs)."""
|
||
index = _recommendation_index()
|
||
seen = set()
|
||
out = []
|
||
for use_case in (use_cases or []):
|
||
for slug in RECOMMENDED_EXTENSIONS_BY_USE_CASE.get(use_case, []):
|
||
if slug in seen:
|
||
continue
|
||
seen.add(slug)
|
||
meta = index.get(slug)
|
||
if meta:
|
||
out.append(meta)
|
||
return out
|
||
|
||
|
||
def finalize_setup_flagships():
|
||
"""Keep the panel lean after onboarding completes: any wizard-optional
|
||
flagship (WordPress) the user didn't install during the wizard gets the
|
||
uninstall marker so the boot-time flagship seeder won't re-add it on later
|
||
boots. Called from the complete-onboarding endpoint (plan 47)."""
|
||
for slug in WIZARD_OPTIONAL_FLAGSHIP_SLUGS:
|
||
if not InstalledPlugin.query.filter_by(slug=slug).first():
|
||
_set_flagship_uninstalled(slug, True)
|
||
|
||
|
||
def get_installed_extension_attr(slug, module, attr, default=None):
|
||
"""Return ``attr`` from an installed extension's backend module, else default.
|
||
|
||
A core seam that calls into a feature which now lives in an extracted
|
||
extension (plan 47) uses this so it no-ops cleanly when the extension isn't
|
||
installed. Resolves ``app.plugins.<slug>.<module>`` (the dashed package the
|
||
plugin loader wires up on install); any import/lookup failure yields
|
||
``default``."""
|
||
try:
|
||
import importlib
|
||
mod = importlib.import_module(f'app.plugins.{slug}.{module}')
|
||
return getattr(mod, attr, default)
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def install_builtin_extension(slug, user_id=None):
|
||
"""Install a builtin extension by its slug (folder lookup)."""
|
||
if not os.path.isdir(BUILTIN_EXTENSIONS_DIR):
|
||
raise ValueError(f'Builtin extensions dir not found: {BUILTIN_EXTENSIONS_DIR}')
|
||
|
||
# Flagships (D4) re-install in-place: clear the user-uninstall marker and
|
||
# re-seed the active row — no file copy, since the backend loads from
|
||
# builtin-extensions/ and the frontend is pre-bundled.
|
||
if slug in FLAGSHIP_SLUGS:
|
||
_set_flagship_uninstalled(slug, False)
|
||
for entry in list_builtin_extensions():
|
||
if entry['slug'] == slug:
|
||
_ensure_builtin_backend_importable(slug)
|
||
existing = InstalledPlugin.query.filter_by(slug=slug).first()
|
||
if existing:
|
||
existing.status = InstalledPlugin.STATUS_ACTIVE
|
||
db.session.commit()
|
||
# A builtin re-install carries no signature — never keep a
|
||
# verdict stamped by some earlier install of the slug.
|
||
_record_signature_status(existing, None)
|
||
return existing
|
||
plugin = _seed_flagship_row(entry)
|
||
try:
|
||
_register_plugin_blueprint(plugin)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f'Flagship blueprint hot-load failed for {slug} '
|
||
f'(will load on restart): {e}')
|
||
return plugin
|
||
raise ValueError(f"No builtin extension with slug '{slug}'")
|
||
|
||
for entry in list_builtin_extensions():
|
||
if entry['slug'] == slug:
|
||
return install_from_path(entry['path'], user_id=user_id,
|
||
source_type='builtin')
|
||
|
||
raise ValueError(f"No builtin extension with slug '{slug}'")
|
||
|
||
|
||
# ── Flagship (D4) default-install machinery ──
|
||
|
||
def _flagship_uninstalled_set():
|
||
"""Slugs the user explicitly uninstalled — never re-seeded on boot."""
|
||
from app.services.settings_service import SettingsService
|
||
raw = SettingsService.get(_FLAGSHIP_UNINSTALLED_KEY, '')
|
||
if not raw:
|
||
return set()
|
||
try:
|
||
return set(json.loads(raw))
|
||
except (ValueError, TypeError):
|
||
return {s.strip() for s in str(raw).split(',') if s.strip()}
|
||
|
||
|
||
def _set_flagship_uninstalled(slug, uninstalled):
|
||
from app.services.settings_service import SettingsService
|
||
current = _flagship_uninstalled_set()
|
||
if uninstalled:
|
||
current.add(slug)
|
||
else:
|
||
current.discard(slug)
|
||
SettingsService.set(_FLAGSHIP_UNINSTALLED_KEY, json.dumps(sorted(current)))
|
||
|
||
|
||
def _seed_flagship_row(entry):
|
||
"""Create an active InstalledPlugin row for a flagship without copying files.
|
||
|
||
The backend is loaded in-place from builtin-extensions/ (see
|
||
``_ensure_builtin_backend_importable``) and the frontend is pre-bundled under
|
||
``frontend/src/plugins/<slug>`` (D5), so the row only records install state
|
||
for the Marketplace/contributions surfaces and drives the status guard.
|
||
"""
|
||
manifest = entry['manifest']
|
||
slug = entry['slug']
|
||
has_backend = os.path.isdir(os.path.join(entry['path'], 'backend'))
|
||
has_frontend = os.path.isdir(os.path.join(entry['path'], 'frontend'))
|
||
|
||
plugin = InstalledPlugin(
|
||
name=manifest['name'],
|
||
display_name=manifest.get('display_name', slug),
|
||
slug=slug,
|
||
version=manifest['version'],
|
||
description=manifest.get('description', ''),
|
||
author=manifest.get('author', ''),
|
||
homepage=manifest.get('homepage', ''),
|
||
repository=manifest.get('repository', ''),
|
||
license=manifest.get('license', ''),
|
||
category=manifest.get('category', 'utility'),
|
||
source_type='builtin',
|
||
status=InstalledPlugin.STATUS_ACTIVE,
|
||
)
|
||
plugin.manifest = manifest
|
||
plugin.has_backend = has_backend
|
||
plugin.has_frontend = has_frontend
|
||
plugin.backend_path = f'builtin-extensions/{slug}/backend' if has_backend else None
|
||
plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None
|
||
plugin.entry_point = manifest.get('entry_point', '')
|
||
plugin.url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}')
|
||
plugin.frontend_entry = manifest.get('frontend_entry', '')
|
||
db.session.add(plugin)
|
||
db.session.commit()
|
||
|
||
# Register plugin-owned data models / jobs / core hooks / sockets, same as
|
||
# a copy-install.
|
||
try:
|
||
from app.services import extension_lifecycle
|
||
extension_lifecycle.register_models(plugin, manifest)
|
||
extension_lifecycle.register_jobs(plugin, manifest)
|
||
extension_lifecycle.register_capabilities(plugin, manifest)
|
||
from app.plugins_sdk import sockets as _ext_sockets
|
||
_ext_sockets.register_from_manifest(plugin, manifest)
|
||
except Exception as e:
|
||
logger.warning(f'Flagship lifecycle setup for {slug}: {e}')
|
||
|
||
_refresh_plugin_ai(plugin)
|
||
# Only touch the frontend build manifest if the flagship actually ships a
|
||
# frontend (WordPress is backend-only — its UI stays core for now). A
|
||
# pre-bundled flagship frontend (D5) is already represented in the checked-in
|
||
# manifest, so there's nothing to regenerate here.
|
||
if plugin.has_frontend:
|
||
try:
|
||
_regenerate_frontend_manifest()
|
||
except Exception as e:
|
||
logger.warning(f'Frontend manifest regen after flagship seed for {slug}: {e}')
|
||
logger.info(f'Seeded flagship extension row: {slug} v{manifest["version"]}')
|
||
return plugin
|
||
|
||
|
||
def _wizard_optional_flagships_suppressed():
|
||
"""True when wizard-optional flagships (WordPress) should NOT be boot-seeded:
|
||
a fresh install whose setup wizard hasn't completed (plan 47).
|
||
|
||
Bypassed under TESTING so the extensive WordPress test suite keeps its
|
||
seeded flagship; the real gate is exercised by dedicated proving tests that
|
||
toggle ``app.config['TESTING']``."""
|
||
try:
|
||
from flask import current_app
|
||
if current_app and current_app.config.get('TESTING'):
|
||
return False
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from app.services.settings_service import SettingsService
|
||
return bool(SettingsService.needs_setup())
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def seed_flagship_extensions():
|
||
"""Ensure bundled flagship extensions (D4) have an active row on EVERY panel
|
||
— fresh AND upgrade — unless the user explicitly uninstalled them.
|
||
|
||
Called from ``create_app`` right before ``load_all_plugins`` so the loader
|
||
picks up the seeded row and registers the blueprints (in-place). Best-effort.
|
||
"""
|
||
uninstalled = _flagship_uninstalled_set()
|
||
suppress_optional = _wizard_optional_flagships_suppressed()
|
||
available = {e['slug']: e for e in list_builtin_extensions()}
|
||
for slug in FLAGSHIP_SLUGS:
|
||
if slug in uninstalled:
|
||
continue
|
||
# Fresh install (plan 47): don't seed wizard-optional flagships (WordPress)
|
||
# until the setup wizard installs them. Existing/completed-setup installs
|
||
# fall through and seed as before.
|
||
if suppress_optional and slug in WIZARD_OPTIONAL_FLAGSHIP_SLUGS:
|
||
continue
|
||
entry = available.get(slug)
|
||
if not entry:
|
||
continue
|
||
# Make the backend importable in-place regardless (the bridge and the
|
||
# loader both rely on app.plugins.<slug> resolving).
|
||
_ensure_builtin_backend_importable(slug)
|
||
existing = InstalledPlugin.query.filter_by(slug=slug).first()
|
||
if existing:
|
||
# Heal a row left in ERROR by a prior failed load so the flagship
|
||
# comes back active on the next boot (the seed is authoritative for a
|
||
# flagship the user hasn't uninstalled). A deliberate DISABLE is kept.
|
||
if existing.status == InstalledPlugin.STATUS_ERROR:
|
||
existing.status = InstalledPlugin.STATUS_ACTIVE
|
||
existing.error_message = None
|
||
db.session.commit()
|
||
continue
|
||
try:
|
||
_seed_flagship_row(entry)
|
||
except Exception as e:
|
||
logger.warning(f'Flagship seed for {slug} failed (retry next boot): {e}')
|
||
|
||
|
||
def _assert_panel_compatible(entry):
|
||
"""Raise if the panel version is outside an entry's min/max_panel_version."""
|
||
from app.utils.version import version_satisfies, get_panel_version
|
||
panel = get_panel_version()
|
||
minv = entry.get('min_panel_version')
|
||
maxv = entry.get('max_panel_version')
|
||
if not version_satisfies(panel, minv, maxv):
|
||
raise ValueError(
|
||
f"{entry.get('display_name') or entry.get('slug')} "
|
||
f"v{entry.get('version')} needs panel "
|
||
f"{minv or '*'}–{maxv or '*'} (this panel is {panel})."
|
||
)
|
||
|
||
|
||
def install_registry_extension(slug, user_id=None):
|
||
"""Install an extension listed in the remote registry (checksum-verified).
|
||
|
||
When the index entry carries a ``signature``/``publisher_key_id`` pair
|
||
(index schema v3, plan 55 D3) the downloaded zip is also verified against
|
||
the panel's pinned publisher keys — a bad signature is a hard failure
|
||
even when the sha256 matches."""
|
||
from app.services import registry_service
|
||
entry = registry_service.get_entry(slug)
|
||
if not entry:
|
||
raise ValueError(f"No registry extension with slug '{slug}'")
|
||
_assert_panel_compatible(entry)
|
||
source = (entry.get('source') or '').strip()
|
||
if not source:
|
||
raise ValueError(f"Registry entry '{slug}' has no source URL.")
|
||
return install_from_url(
|
||
source, user_id=user_id, expected_sha256=entry.get('sha256'),
|
||
source_type='registry',
|
||
expected_signature=entry.get('signature'),
|
||
expected_key_id=entry.get('publisher_key_id'),
|
||
)
|
||
|
||
|
||
def check_for_updates():
|
||
"""Compare installed plugins to the registry; return update candidates.
|
||
|
||
Update v1 is registry-driven: a plugin whose slug is in the registry with a
|
||
higher version than installed is flagged. Panel-version compatibility is
|
||
surfaced so the UI can gray out an update the panel can't run yet. The
|
||
installed plugin's ``source_type`` is included so callers can see the
|
||
provenance the update would cross (audit M2 — a registry entry only ever
|
||
matches on slug; the update route's consent gate is the enforcement).
|
||
"""
|
||
from app.services import registry_service
|
||
from app.utils.version import compare_versions, version_satisfies, get_panel_version
|
||
panel = get_panel_version()
|
||
out = []
|
||
for p in InstalledPlugin.query.all():
|
||
entry = registry_service.get_entry(p.slug)
|
||
if not entry:
|
||
continue
|
||
available = entry.get('version')
|
||
out.append({
|
||
'slug': p.slug,
|
||
'plugin_id': p.id,
|
||
'installed_version': p.version,
|
||
'available_version': available,
|
||
'update_available': compare_versions(available, p.version) > 0,
|
||
'compatible': version_satisfies(
|
||
panel, entry.get('min_panel_version'), entry.get('max_panel_version')
|
||
),
|
||
'source': entry.get('source'),
|
||
'installed_source_type': p.source_type,
|
||
})
|
||
return out
|
||
|
||
|
||
def update_plugin(plugin_id, user_id=None):
|
||
"""Update an installed plugin to the registry's version (reinstall in place)."""
|
||
from app.services import registry_service
|
||
plugin = InstalledPlugin.query.get(plugin_id)
|
||
if not plugin:
|
||
raise ValueError('Plugin not found')
|
||
entry = registry_service.get_entry(plugin.slug)
|
||
if not entry:
|
||
raise ValueError(
|
||
f"No registry entry for '{plugin.slug}' — cannot update from the registry."
|
||
)
|
||
_assert_panel_compatible(entry)
|
||
source = (entry.get('source') or '').strip()
|
||
if not source:
|
||
raise ValueError('Registry entry has no source URL.')
|
||
# The update itself is registry-driven, so plain url/registry rows get
|
||
# stamped 'registry'; builtin/local/upload rows keep their identity.
|
||
if plugin.source_type in (None, '', 'url', 'registry'):
|
||
stamped = 'registry'
|
||
else:
|
||
stamped = plugin.source_type
|
||
return install_from_url(
|
||
source, user_id=user_id, expected_sha256=entry.get('sha256'), force=True,
|
||
source_type=stamped,
|
||
expected_signature=entry.get('signature'),
|
||
expected_key_id=entry.get('publisher_key_id'),
|
||
)
|