Skip to content

Commit 1b29211

Browse files
committed
feat: support manifest-driven plugin iframe embeds
1 parent 544005c commit 1b29211

2 files changed

Lines changed: 224 additions & 2 deletions

File tree

api/routes.py

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7272,10 +7272,141 @@ def handle_get(handler, parsed) -> bool:
72727272
return False
72737273
dashboard_dir = _PLUGIN_STATIC_ROOTS.get(name)
72747274
if dashboard_dir:
7275+
dashboard_path = dashboard_dir.resolve()
7276+
7277+
webui_manifest = manifest.get("webui", {}) if isinstance(manifest.get("webui", {}), dict) else {}
7278+
7279+
def _safe_js_global(value: str) -> str | None:
7280+
"""Return a safe dotted JS global name from manifest config."""
7281+
parts = str(value or "").split(".")
7282+
if not parts:
7283+
return None
7284+
if all(re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", part or "") for part in parts):
7285+
return ".".join(parts)
7286+
return None
7287+
7288+
def _plugin_inline_payload(
7289+
_webui_manifest=webui_manifest,
7290+
_dashboard_path=dashboard_path,
7291+
) -> tuple[str, str | None]:
7292+
"""Return manifest-declared read-only bootstrap payload script."""
7293+
payload_cfg = _webui_manifest.get("inline_payload", {})
7294+
if not isinstance(payload_cfg, dict):
7295+
return "", None
7296+
global_name = _safe_js_global(payload_cfg.get("global"))
7297+
candidates = payload_cfg.get("candidates", [])
7298+
if not global_name or not isinstance(candidates, list):
7299+
return "", global_name
7300+
for rel in candidates:
7301+
if not isinstance(rel, str):
7302+
continue
7303+
payload_path = (_dashboard_path / rel).resolve()
7304+
try:
7305+
payload_path.relative_to(_dashboard_path)
7306+
except ValueError:
7307+
continue
7308+
if not payload_path.is_file():
7309+
continue
7310+
try:
7311+
payload = json.loads(payload_path.read_text(encoding="utf-8"))
7312+
encoded = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
7313+
return f"<script>{global_name}={encoded};</script>\n", global_name
7314+
except Exception:
7315+
logger.debug("Failed to embed dashboard plugin payload from %s", payload_path, exc_info=True)
7316+
return "", global_name
7317+
7318+
def _inline_declared_dist_assets(
7319+
html_text: str,
7320+
_webui_manifest=webui_manifest,
7321+
_manifest=manifest,
7322+
_dashboard_path=dashboard_path,
7323+
_name=name,
7324+
) -> str:
7325+
"""Inline manifest-approved dist CSS/JS for sandboxed WebUI plugin iframes."""
7326+
if _webui_manifest.get("inline_dist_assets") is not True:
7327+
return html_text
7328+
css_rel = _manifest.get("css") or "dist/style.css"
7329+
entry_rel = _manifest.get("entry") or "dist/index.js"
7330+
if isinstance(css_rel, str):
7331+
css_path = (_dashboard_path / css_rel).resolve()
7332+
try:
7333+
css_path.relative_to(_dashboard_path)
7334+
except ValueError:
7335+
css_path = None
7336+
if css_path and css_path.is_file():
7337+
style_text = css_path.read_text(encoding="utf-8").replace("</style", "<\\/style")
7338+
html_text = html_text.replace(
7339+
'<link rel="stylesheet" href="style.css">',
7340+
f"<style>{style_text}</style>",
7341+
1,
7342+
)
7343+
html_text = html_text.replace(
7344+
f'<link rel="stylesheet" href="/{_name}/style.css">',
7345+
f"<style>{style_text}</style>",
7346+
1,
7347+
)
7348+
if isinstance(entry_rel, str):
7349+
script_path = (_dashboard_path / entry_rel).resolve()
7350+
try:
7351+
script_path.relative_to(_dashboard_path)
7352+
except ValueError:
7353+
script_path = None
7354+
if script_path and script_path.is_file():
7355+
script_text = script_path.read_text(encoding="utf-8").replace("</script", "<\\/script")
7356+
inline_bundle = f" <script>{script_text}</script>"
7357+
script_marker = '<script src="index.js"></script>'
7358+
if script_marker in html_text:
7359+
return html_text.replace(script_marker, inline_bundle, 1)
7360+
if "</body>" in html_text:
7361+
return html_text.replace("</body>", inline_bundle + "</body>", 1)
7362+
return html_text + inline_bundle
7363+
return html_text
7364+
7365+
def _inject_plugin_payload(
7366+
html_text: str,
7367+
_webui_manifest=webui_manifest,
7368+
_name=name,
7369+
) -> str:
7370+
payload_script, payload_global = _plugin_inline_payload()
7371+
html_text = _inline_declared_dist_assets(html_text)
7372+
# Generic plugin fallback: a plugin's dist/index.html is served
7373+
# at tab.path (for example /demo), so relative dist assets like
7374+
# "index.js" would resolve to /index.js. Rebase known bundle
7375+
# assets to the authenticated dashboard-plugin route unless
7376+
# the manifest requested inline self-containment above.
7377+
name_escaped_for_path = _name.replace('"', '')
7378+
if _webui_manifest.get("inline_dist_assets") is not True:
7379+
html_text = html_text.replace(
7380+
'href="style.css"',
7381+
f'href="/dashboard-plugins/{name_escaped_for_path}/dist/style.css"',
7382+
1,
7383+
)
7384+
script_marker = '<script src="index.js"></script>'
7385+
rebased_script = f'<script src="/dashboard-plugins/{name_escaped_for_path}/dist/index.js"></script>'
7386+
if script_marker in html_text:
7387+
replacement = rebased_script
7388+
html_text = html_text.replace(script_marker, replacement, 1)
7389+
payload_assignment = f"{payload_global}=" if payload_global else ""
7390+
payload_already_injected = False
7391+
if payload_assignment and payload_global:
7392+
payload_re = re.compile(
7393+
rf"<script\b[^>]*>[^<]*{re.escape(payload_global)}\s*=",
7394+
re.I | re.S,
7395+
)
7396+
payload_already_injected = bool(payload_re.search(html_text))
7397+
if payload_script and not payload_already_injected:
7398+
first_script = html_text.find("<script")
7399+
if first_script >= 0:
7400+
return html_text[:first_script] + payload_script + html_text[first_script:]
7401+
if "</body>" in html_text:
7402+
return html_text.replace("</body>", payload_script + "</body>", 1)
7403+
return html_text + payload_script
7404+
return html_text
7405+
72757406
# 1) dashboard/dist/index.html (full SPA build)
72767407
index_html = dashboard_dir / "dist" / "index.html"
72777408
if index_html.is_file():
7278-
data = index_html.read_bytes()
7409+
data = _inject_plugin_payload(index_html.read_text(encoding="utf-8")).encode("utf-8")
72797410
handler.send_response(200)
72807411
handler.send_header("Content-Type", "text/html; charset=utf-8")
72817412
handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
@@ -7287,7 +7418,7 @@ def handle_get(handler, parsed) -> bool:
72877418
plugin_root = dashboard_dir.parent
72887419
static_html = plugin_root / "static" / "index.html"
72897420
if static_html.is_file():
7290-
data = static_html.read_bytes()
7421+
data = _inject_plugin_payload(static_html.read_text(encoding="utf-8")).encode("utf-8")
72917422
handler.send_response(200)
72927423
handler.send_header("Content-Type", "text/html; charset=utf-8")
72937424
handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
@@ -7303,6 +7434,7 @@ def handle_get(handler, parsed) -> bool:
73037434
css = html.escape(manifest.get("css", ""))
73047435
name_escaped = html.escape(name)
73057436
css_tag = f'<link rel="stylesheet" href="/dashboard-plugins/{name_escaped}/{css}">' if css else ""
7437+
payload_script, _payload_global = _plugin_inline_payload()
73067438
html_content = (
73077439
f"<!doctype html>\n"
73087440
f"<html lang=\"en\">\n"
@@ -7313,6 +7445,7 @@ def handle_get(handler, parsed) -> bool:
73137445
f"</head>\n"
73147446
f"<body>\n"
73157447
f' <div id="pluginPageContainer"></div>\n'
7448+
f" {payload_script}"
73167449
f' <script src="/dashboard-plugins/{name_escaped}/dist/index.js"></script>\n'
73177450
f"</body>\n"
73187451
f"</html>\n"

tests/test_plugins_panel.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,95 @@ def test_both_plugin_routes_enforce_enable_gate_server_side(self):
373373
assert "_dashboard_plugin_enabled" in page_seg
374374

375375

376+
def test_plugin_page_embed_inlines_declared_assets_and_payload_behaviorally(self, tmp_path):
377+
import io
378+
import json
379+
import api.plugins as plugins
380+
import api.routes as routes
381+
382+
plugin_dir = tmp_path / "demo" / "dashboard"
383+
dist = plugin_dir / "dist"
384+
dist.mkdir(parents=True)
385+
(plugin_dir / "manifest.json").write_text(json.dumps({
386+
"name": "demo",
387+
"label": "Demo",
388+
"tab": {"path": "/demo"},
389+
"css": "dist/style.css",
390+
"entry": "dist/index.js",
391+
"webui": {
392+
"inline_payload": {
393+
"global": "window.__DEMO_PAYLOAD__",
394+
"candidates": ["payload.json"],
395+
},
396+
"inline_dist_assets": True,
397+
},
398+
}), encoding="utf-8")
399+
(dist / "style.css").write_text("body { color: red; }", encoding="utf-8")
400+
(dist / "index.js").write_text("window.demoLoaded = true;", encoding="utf-8")
401+
(plugin_dir / "payload.json").write_text(json.dumps({"ok": True}), encoding="utf-8")
402+
(dist / "index.html").write_text(
403+
'''<!doctype html><html><head><link rel="stylesheet" href="style.css"></head><body><!-- window.__DEMO_PAYLOAD__= in a comment must not suppress injection --><script src="index.js"></script></body></html>''',
404+
encoding="utf-8",
405+
)
406+
407+
class Handler:
408+
def __init__(self):
409+
self.status = None
410+
self.headers = []
411+
self.wfile = io.BytesIO()
412+
def send_response(self, status):
413+
self.status = status
414+
def send_header(self, key, value):
415+
self.headers.append((key, value))
416+
def end_headers(self):
417+
pass
418+
419+
old_manifests = dict(plugins.PLUGIN_MANIFESTS)
420+
old_roots = dict(plugins._PLUGIN_STATIC_ROOTS)
421+
linked_plugin_dir = tmp_path / "linked-dashboard"
422+
try:
423+
linked_plugin_dir.symlink_to(plugin_dir, target_is_directory=True)
424+
except (OSError, NotImplementedError):
425+
linked_plugin_dir = plugin_dir
426+
try:
427+
plugins.PLUGIN_MANIFESTS.clear()
428+
plugins._PLUGIN_STATIC_ROOTS.clear()
429+
plugins.PLUGIN_MANIFESTS["demo"] = json.loads((plugin_dir / "manifest.json").read_text(encoding="utf-8"))
430+
plugins._PLUGIN_STATIC_ROOTS["demo"] = linked_plugin_dir
431+
handler = Handler()
432+
with patch("api.routes._dashboard_plugin_enabled", return_value=True):
433+
handled = routes.handle_get(handler, urlparse("/demo"))
434+
body = handler.wfile.getvalue().decode("utf-8")
435+
finally:
436+
plugins.PLUGIN_MANIFESTS.clear()
437+
plugins.PLUGIN_MANIFESTS.update(old_manifests)
438+
plugins._PLUGIN_STATIC_ROOTS.clear()
439+
plugins._PLUGIN_STATIC_ROOTS.update(old_roots)
440+
441+
assert handled is True
442+
assert handler.status == 200
443+
assert "<style>body { color: red; }</style>" in body
444+
assert "<script>window.demoLoaded = true;</script>" in body
445+
assert "window.__DEMO_PAYLOAD__={\"ok\": true};" in body
446+
assert 'href="style.css"' not in body
447+
assert '<script src="index.js"></script>' not in body
448+
449+
def test_plugin_page_embed_is_manifest_driven_not_name_special_cased(self):
450+
# Sandboxed iframe plugins may opt into server-side bootstrap payloads
451+
# and inline dist assets via manifest["webui"]. The route must not
452+
# hard-code a Project-Cockpit plugin name to decide this behavior.
453+
routes = read("api/routes.py")
454+
page_seg = routes[routes.find("# ── Plugin pages"):routes.find("# ── Plugin pages") + 7000]
455+
assert 'manifest.get("webui"' in page_seg
456+
assert 'inline_payload' in page_seg
457+
assert 'inline_dist_assets' in page_seg
458+
assert '_safe_js_global' in page_seg
459+
assert '<style>{style_text}</style>' in page_seg
460+
assert '<script>{script_text}</script>' in page_seg
461+
assert 'window.__LOCAL_PLUGIN_OVERVIEW__' not in page_seg
462+
assert 'name == "local-plugin"' not in page_seg
463+
464+
376465
class TestSettingsAllowlistGuard:
377466
"""The dashboard_plugins save path must not weaken the settings allowlist.
378467

0 commit comments

Comments
 (0)