mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
feat(web): serve plugin-declared widgets, and actually ask for them
LEDMatrixWidgets.loadPluginWidget has always fetched /static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has always documented that path, but nothing served it. soccer-scoreboard has shipped a 17KB widgets/custom-leagues.js since August that could never load. Both halves were missing, not just the route: - serve_plugin_widget serves the script from the plugin's widgets/ directory as text/javascript. The manifest is the allowlist -- only a widget the plugin declares is reachable -- so installing a plugin does not publish everything it ships. Path handling mirrors the sibling serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() + relative_to() containment, and the ledmatrix- prefix fallback. The declared script name is guarded too, since it comes from the plugin rather than the request. - The config form never requested one. Its x-widget dispatch is a hardcoded list of core widget names, so a plugin's own widget fell through to a plain text input. An unrecognised x-widget on a string field now asks ensureWidget() for it. The text input stays as the fallback and is removed only once the widget has actually rendered, so a missing or broken widget costs the user an editor rather than their configured value on the next save. - manifest_schema.json gains "widgets", so the declaration is validated rather than merely tolerated by additionalProperties. Verified in a browser against the real partial: a declared widget loads, registers and renders, and its field posts exactly one value; a field whose widget 404s keeps its text input and still posts its value. Not addressed: loadPluginWidgetsFromManifest still has no caller. The per-field ensureWidget path is lazier and is what the form now uses, so that bulk helper is dead weight -- worth removing, but left alone here rather than inventing a call site for it. Known limitation, documented: only string-typed fields take this path. object/array/boolean/number fields and enums are dispatched by the template's own branches, which still only know core widgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+51
-11
@@ -285,7 +285,9 @@ Guidelines:
|
|||||||
|
|
||||||
### Step 1: Create Widget File
|
### Step 1: Create Widget File
|
||||||
|
|
||||||
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
|
Create a JavaScript file in your plugin's `widgets/` directory, named
|
||||||
|
`widgets/[widget-name].js`. The directory is not optional: it is the only
|
||||||
|
place the core will serve a widget from.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Ensure LEDMatrixWidgets registry is available
|
// Ensure LEDMatrixWidgets registry is available
|
||||||
@@ -366,7 +368,29 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Reference Widget in Schema
|
### Step 2: Declare the Widget in `manifest.json`
|
||||||
|
|
||||||
|
The manifest is the allowlist. A widget is served only if the plugin declares
|
||||||
|
it, so shipping a file under `widgets/` does not by itself publish it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"name": "my-custom-widget",
|
||||||
|
"script": "my-custom-widget.js",
|
||||||
|
"description": "What this widget is for"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is what you use in `x-widget` and in the URL. `script` is optional and
|
||||||
|
defaults to `[name].js`; it must be a plain filename directly inside
|
||||||
|
`widgets/` (no paths). Both are validated against
|
||||||
|
`schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
### Step 3: Reference Widget in Schema
|
||||||
|
|
||||||
In your plugin's `config_schema.json`:
|
In your plugin's `config_schema.json`:
|
||||||
|
|
||||||
@@ -383,15 +407,30 @@ In your plugin's `config_schema.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Widget Loading
|
### Step 4: Widget Loading
|
||||||
|
|
||||||
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
|
The widget is loaded on demand when the plugin's configuration form renders a
|
||||||
|
field that references it. The system will:
|
||||||
|
|
||||||
1. Check if widget is registered in the core registry
|
1. Check whether the widget is already registered in the core registry.
|
||||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
||||||
3. Render the widget using the registered `render` function
|
That route serves the declared `script` from your plugin's `widgets/`
|
||||||
|
directory, as `text/javascript`.
|
||||||
|
3. Render it by calling the `render` function your script registered.
|
||||||
|
|
||||||
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
|
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
|
||||||
|
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
|
||||||
|
|
||||||
|
**If the widget fails to load** (not declared, file missing, script throws, or
|
||||||
|
it never calls `register`), the field falls back to a plain text input holding
|
||||||
|
the current value. This is deliberate: a broken widget costs the user an
|
||||||
|
editor, not their configured value.
|
||||||
|
|
||||||
|
**Limitation:** the on-demand path applies to `string`-typed fields (the
|
||||||
|
default branch of the config-form renderer). Fields typed `object`, `array`,
|
||||||
|
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
|
||||||
|
dispatched by the server-side template to its own built-in renderers, so a
|
||||||
|
plugin-supplied `x-widget` on one of those is ignored today.
|
||||||
|
|
||||||
## Widget API Reference
|
## Widget API Reference
|
||||||
|
|
||||||
@@ -497,10 +536,11 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
|
|||||||
- ✅ Plugin widget loading system implemented
|
- ✅ Plugin widget loading system implemented
|
||||||
|
|
||||||
**Current Behavior:**
|
**Current Behavior:**
|
||||||
- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||||
- Widget handlers are registered and available globally
|
- Widget handlers are registered and available globally
|
||||||
- Custom widgets can be created and registered
|
- Custom widgets can be created, declared in `manifest.json`, and are served
|
||||||
- Full client-side rendering is a future enhancement
|
and rendered on demand for `string`-typed fields
|
||||||
|
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
||||||
|
|
||||||
**Backwards Compatibility:**
|
**Backwards Compatibility:**
|
||||||
- All existing plugins using widgets continue to work without changes
|
- All existing plugins using widgets continue to work without changes
|
||||||
|
|||||||
@@ -257,6 +257,30 @@
|
|||||||
},
|
},
|
||||||
"description": "Web UI action definitions"
|
"description": "Web UI action definitions"
|
||||||
},
|
},
|
||||||
|
"widgets": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
|
||||||
|
"description": "Widget name, as used in x-widget and in the URL."
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
|
||||||
|
"description": "Filename inside widgets/. Defaults to <name>.js."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable summary shown to plugin authors."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledmatrix_version": {
|
"ledmatrix_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""The server half of the plugin-supplied widget feature.
|
||||||
|
|
||||||
|
``LEDMatrixWidgets.loadPluginWidget`` (static/v3/js/widgets/plugin-loader.js)
|
||||||
|
has always fetched ``/static/plugin-widgets/<plugin>/<widget>.js``, and
|
||||||
|
docs/widget-guide.md has always documented that path, but nothing served it --
|
||||||
|
so a plugin could declare a widget, ship the file, and still never load it.
|
||||||
|
soccer-scoreboard has shipped exactly that since August.
|
||||||
|
|
||||||
|
The load-bearing property here is that the manifest is the allowlist. A plugin
|
||||||
|
directory is attacker-influenced in the sense that matters -- plugins are
|
||||||
|
user-installed, and the store installs them -- so "serve files from the plugin
|
||||||
|
directory" would publish everything a plugin ships. Only a widget the manifest
|
||||||
|
declares is reachable, and only from that plugin's widgets/ directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
WIDGET_BODY = "(function(){ window.LEDMatrixWidgets.register('custom-leagues', {}); })();\n"
|
||||||
|
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_plugin(plugins_dir, plugin_id="soccer-scoreboard", widgets=None,
|
||||||
|
files=None):
|
||||||
|
"""Write a plugin directory with a manifest and a widgets/ folder."""
|
||||||
|
d = plugins_dir / plugin_id
|
||||||
|
(d / "widgets").mkdir(parents=True)
|
||||||
|
manifest = {"id": plugin_id, "name": plugin_id, "version": "1.0.0"}
|
||||||
|
if widgets is not None:
|
||||||
|
manifest["widgets"] = widgets
|
||||||
|
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
for name, body in (files or {}).items():
|
||||||
|
(d / "widgets" / name).write_text(body, encoding="utf-8")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_client(tmp_path):
|
||||||
|
"""Build a test client whose plugin manager points at a temp plugins dir.
|
||||||
|
|
||||||
|
pages_v3 is a module-level Blueprint singleton shared across the test
|
||||||
|
process, so the original plugin_manager is restored on teardown.
|
||||||
|
"""
|
||||||
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
|
||||||
|
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||||
|
|
||||||
|
def _build(plugins_dir=None, plugin_manager=_UNSET):
|
||||||
|
base = PROJECT_ROOT / "web_interface"
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder=str(base / "templates"),
|
||||||
|
static_folder=str(base / "static"))
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
if plugin_manager is _UNSET:
|
||||||
|
plugin_manager = MagicMock()
|
||||||
|
plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
|
||||||
|
pv.pages_v3.plugin_manager = plugin_manager
|
||||||
|
|
||||||
|
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield _build
|
||||||
|
finally:
|
||||||
|
pv.pages_v3.plugin_manager = original_pm
|
||||||
|
|
||||||
|
|
||||||
|
URL = "/static/plugin-widgets/{}/{}.js"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeclaredWidgetIsServed:
|
||||||
|
def test_a_declared_widget_is_served(self, tmp_path, make_client):
|
||||||
|
_make_plugin(tmp_path,
|
||||||
|
widgets=[{"name": "custom-leagues",
|
||||||
|
"script": "custom-leagues.js"}],
|
||||||
|
files={"custom-leagues.js": WIDGET_BODY})
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_data(as_text=True) == WIDGET_BODY
|
||||||
|
|
||||||
|
def test_it_is_served_as_javascript(self, tmp_path, make_client):
|
||||||
|
"""The loader uses dynamic import(); a wrong MIME type is refused."""
|
||||||
|
_make_plugin(tmp_path,
|
||||||
|
widgets=[{"name": "custom-leagues",
|
||||||
|
"script": "custom-leagues.js"}],
|
||||||
|
files={"custom-leagues.js": WIDGET_BODY})
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
|
||||||
|
assert "javascript" in r.headers["Content-Type"]
|
||||||
|
|
||||||
|
def test_script_defaults_to_the_widget_name(self, tmp_path, make_client):
|
||||||
|
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
|
||||||
|
files={"custom-leagues.js": WIDGET_BODY})
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_the_ledmatrix_prefix_fallback_resolves(self, tmp_path, make_client):
|
||||||
|
"""PluginManager resolves 'music' to 'ledmatrix-music'; so must this."""
|
||||||
|
_make_plugin(tmp_path, plugin_id="ledmatrix-music",
|
||||||
|
widgets=[{"name": "deck", "script": "deck.js"}],
|
||||||
|
files={"deck.js": WIDGET_BODY})
|
||||||
|
r = make_client().get(URL.format("music", "deck"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheManifestIsTheAllowlist:
|
||||||
|
def test_an_undeclared_file_in_widgets_is_not_served(self, tmp_path, make_client):
|
||||||
|
"""The whole point: shipping a file does not publish it."""
|
||||||
|
_make_plugin(tmp_path, widgets=[],
|
||||||
|
files={"secrets.js": "const KEY='hunter2';"})
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "secrets"))
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert "hunter2" not in r.get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_a_manifest_with_no_widgets_key_serves_nothing(self, tmp_path, make_client):
|
||||||
|
_make_plugin(tmp_path, files={"anything.js": WIDGET_BODY})
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "anything"))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_a_declared_widget_whose_file_is_missing_is_404(self, tmp_path, make_client):
|
||||||
|
_make_plugin(tmp_path, widgets=[{"name": "ghost", "script": "ghost.js"}])
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "ghost"))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
def test_a_malformed_manifest_serves_nothing(self, tmp_path, make_client):
|
||||||
|
d = tmp_path / "broken"
|
||||||
|
(d / "widgets").mkdir(parents=True)
|
||||||
|
(d / "manifest.json").write_text("{not json", encoding="utf-8")
|
||||||
|
(d / "widgets" / "w.js").write_text(WIDGET_BODY, encoding="utf-8")
|
||||||
|
assert make_client().get(URL.format("broken", "w")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestPathTraversal:
|
||||||
|
@pytest.mark.parametrize("plugin_id", ["../etc", "..%2f..", "a/b", "a\\b", ""])
|
||||||
|
def test_a_hostile_plugin_id_never_reaches_the_filesystem(
|
||||||
|
self, plugin_id, tmp_path, make_client):
|
||||||
|
r = make_client().get(URL.format(plugin_id, "custom-leagues"))
|
||||||
|
assert r.status_code in (400, 404), r.status_code
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("widget", ["../manifest", "..%2fsecret", "a/b"])
|
||||||
|
def test_a_hostile_widget_name_never_reaches_the_filesystem(
|
||||||
|
self, widget, tmp_path, make_client):
|
||||||
|
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
|
||||||
|
files={"custom-leagues.js": WIDGET_BODY})
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", widget))
|
||||||
|
assert r.status_code in (400, 404), r.status_code
|
||||||
|
|
||||||
|
def test_a_manifest_cannot_escape_the_widgets_directory(self, tmp_path, make_client):
|
||||||
|
"""A hostile manifest is the traversal vector the URL allowlist can't
|
||||||
|
cover: the script name comes from the plugin, not the request."""
|
||||||
|
(tmp_path / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
|
||||||
|
_make_plugin(tmp_path,
|
||||||
|
widgets=[{"name": "evil", "script": "../../loot.js"}])
|
||||||
|
r = make_client().get(URL.format("soccer-scoreboard", "evil"))
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert "hunter2" not in r.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDegradation:
|
||||||
|
def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
|
||||||
|
assert make_client().get(URL.format("nope", "w")).status_code == 404
|
||||||
|
|
||||||
|
def test_no_plugin_manager_is_503(self, make_client):
|
||||||
|
r = make_client(plugin_manager=None).get(URL.format("any", "w"))
|
||||||
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_with_widget(widget_name):
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": False},
|
||||||
|
"leagues": {"type": "string", "default": "eng.1",
|
||||||
|
"x-widget": widget_name},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_form(tmp_path):
|
||||||
|
"""Render a plugin's config partial with a temp plugin on disk."""
|
||||||
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
|
||||||
|
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||||
|
orig_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||||
|
|
||||||
|
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
|
||||||
|
files=None):
|
||||||
|
d = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files)
|
||||||
|
(d / "config_schema.json").write_text(
|
||||||
|
json.dumps(schema or {"type": "object", "properties": {}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.plugins_dir = str(tmp_path)
|
||||||
|
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
|
||||||
|
pm.get_plugin.return_value = None
|
||||||
|
pv.pages_v3.plugin_manager = pm
|
||||||
|
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.load_config.return_value = {plugin_id: {"enabled": True}}
|
||||||
|
pv.pages_v3.config_manager = cm
|
||||||
|
|
||||||
|
base = PROJECT_ROOT / "web_interface"
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder=str(base / "templates"),
|
||||||
|
static_folder=str(base / "static"))
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||||
|
return app.test_client().get(f"/partials/plugin-config/{plugin_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield _render
|
||||||
|
finally:
|
||||||
|
pv.pages_v3.plugin_manager = orig_pm
|
||||||
|
pv.pages_v3.config_manager = orig_cm
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheFormRequestsPluginWidgets:
|
||||||
|
"""Without this the feature is still dead: the route can serve a widget,
|
||||||
|
but nothing ever asks for one. The server-side form only knows a hardcoded
|
||||||
|
list of core widget names, so a plugin's own x-widget fell through to a
|
||||||
|
plain text input and was never fetched."""
|
||||||
|
|
||||||
|
def test_an_unknown_widget_name_triggers_a_plugin_load(self, config_form):
|
||||||
|
r = config_form(schema=_schema_with_widget("custom-leagues"),
|
||||||
|
widgets=[{"name": "custom-leagues"}],
|
||||||
|
files={"custom-leagues.js": WIDGET_BODY})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.get_data(as_text=True)
|
||||||
|
assert "ensureWidget" in body
|
||||||
|
assert '"custom-leagues"' in body
|
||||||
|
|
||||||
|
def test_the_text_input_remains_as_the_fallback(self, config_form):
|
||||||
|
"""A widget that fails to load must not cost the user their value."""
|
||||||
|
r = config_form(schema=_schema_with_widget("custom-leagues"),
|
||||||
|
widgets=[{"name": "custom-leagues"}],
|
||||||
|
files={"custom-leagues.js": WIDGET_BODY})
|
||||||
|
body = r.get_data(as_text=True)
|
||||||
|
assert 'name="leagues"' in body
|
||||||
|
assert 'value="eng.1"' in body
|
||||||
|
|
||||||
|
def test_a_plain_string_field_asks_for_no_widget(self, config_form):
|
||||||
|
r = config_form(schema={"type": "object", "properties": {
|
||||||
|
"leagues": {"type": "string", "default": "eng.1"}}})
|
||||||
|
assert "ensureWidget" not in r.get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_a_core_widget_does_not_take_the_plugin_path(self, config_form):
|
||||||
|
r = config_form(schema=_schema_with_widget("font-selector"))
|
||||||
|
body = r.get_data(as_text=True)
|
||||||
|
assert "ensureWidget" not in body
|
||||||
|
assert "LEDMatrixWidgets.get('font-selector')" in body
|
||||||
@@ -12,6 +12,8 @@ from pathlib import Path
|
|||||||
# Strict allowlists for URL-derived values used in path and script operations.
|
# Strict allowlists for URL-derived values used in path and script operations.
|
||||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||||
|
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
|
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -358,6 +360,122 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_dir_for(safe_id):
|
||||||
|
"""Resolve a sanitised plugin id to its directory, or None.
|
||||||
|
|
||||||
|
Mirrors serve_plugin_web_ui: containment-guarded against the configured
|
||||||
|
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
|
||||||
|
"""
|
||||||
|
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
|
plugin_dir = (plugins_base / safe_id).resolve()
|
||||||
|
plugin_dir.relative_to(plugins_base) # containment guard
|
||||||
|
|
||||||
|
if not plugin_dir.exists():
|
||||||
|
alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||||
|
alt = (plugins_base / alt_id).resolve()
|
||||||
|
try:
|
||||||
|
alt.relative_to(plugins_base)
|
||||||
|
plugin_dir = alt
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _declared_widget_script(plugin_dir, widget_name):
|
||||||
|
"""The script filename a plugin's manifest declares for ``widget_name``.
|
||||||
|
|
||||||
|
The manifest is the allowlist: only a widget the plugin actually declares
|
||||||
|
can be served, so this route never exposes arbitrary files under the
|
||||||
|
plugin directory even though the directory itself is attacker-influenced
|
||||||
|
(plugins are user-installed). Returns None when the widget is not
|
||||||
|
declared, the manifest is unreadable, or the declared script name is not
|
||||||
|
a plain ``<name>.js`` basename.
|
||||||
|
"""
|
||||||
|
manifest_path = plugin_dir / 'manifest.json'
|
||||||
|
try:
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for entry in manifest.get('widgets') or ():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get('name') != widget_name:
|
||||||
|
continue
|
||||||
|
script = entry.get('script') or f'{widget_name}.js'
|
||||||
|
if not isinstance(script, str) or not _SAFE_WIDGET_SCRIPT_RE.match(script):
|
||||||
|
return None
|
||||||
|
return script
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pages_v3.route('/static/plugin-widgets/<plugin_id>/<widget_name>.js')
|
||||||
|
def serve_plugin_widget(plugin_id, widget_name):
|
||||||
|
"""Serve a plugin-declared widget script from its ``widgets/`` directory.
|
||||||
|
|
||||||
|
This is the server half of ``LEDMatrixWidgets.loadPluginWidget`` (see
|
||||||
|
static/v3/js/widgets/plugin-loader.js), which fetches exactly this path.
|
||||||
|
The loader uses a dynamic ``import()``, so the response must carry a
|
||||||
|
JavaScript MIME type or the browser refuses the module.
|
||||||
|
|
||||||
|
The route is deliberately narrower than the plugin directory: a script is
|
||||||
|
served only when the plugin's own manifest declares a widget by that name,
|
||||||
|
so installing a plugin does not publish everything it ships.
|
||||||
|
"""
|
||||||
|
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||||
|
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
|
||||||
|
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
# os.path.basename() is the CodeQL-recognised path sanitizer used
|
||||||
|
# throughout this codebase; the allowlists above already exclude
|
||||||
|
# separators, this breaks the taint chain.
|
||||||
|
safe_id = os.path.basename(plugin_id)
|
||||||
|
safe_widget = os.path.basename(widget_name)
|
||||||
|
if not safe_id or not safe_widget:
|
||||||
|
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
if not pages_v3.plugin_manager:
|
||||||
|
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin_dir = _plugin_dir_for(safe_id)
|
||||||
|
if not plugin_dir.exists():
|
||||||
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
script = _declared_widget_script(plugin_dir, safe_widget)
|
||||||
|
if script is None:
|
||||||
|
# Undeclared is a 404 rather than a 403: whether a plugin happens
|
||||||
|
# to ship an undeclared file is not something to confirm.
|
||||||
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
widgets_dir = (plugin_dir / 'widgets').resolve()
|
||||||
|
script_path = (widgets_dir / os.path.basename(script)).resolve()
|
||||||
|
script_path.relative_to(widgets_dir) # second containment guard
|
||||||
|
|
||||||
|
if not script_path.is_file():
|
||||||
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
body = script_path.read_text(encoding='utf-8')
|
||||||
|
return body, 200, {
|
||||||
|
'Content-Type': 'text/javascript; charset=utf-8',
|
||||||
|
# Plugin updates replace this file in place; revalidate so a
|
||||||
|
# stale widget cannot outlive the plugin version that shipped it.
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||||
|
except Exception:
|
||||||
|
logger.error('Error serving plugin widget %s/%s', plugin_id, widget_name,
|
||||||
|
exc_info=True)
|
||||||
|
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
|
||||||
def _load_overview_partial():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -280,14 +280,40 @@ In your plugin's `config_schema.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Widget Loading
|
### Step 3: Declare the Widget in `manifest.json`
|
||||||
|
|
||||||
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
|
The manifest is the allowlist -- a widget is served only if the plugin declares
|
||||||
|
it, so shipping a file under `widgets/` does not by itself publish it:
|
||||||
|
|
||||||
1. Check if widget is registered in the core registry
|
```json
|
||||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
{
|
||||||
|
"widgets": [
|
||||||
|
{ "name": "my-custom-widget", "script": "my-custom-widget.js" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`script` is optional and defaults to `[name].js`. It must be a plain filename
|
||||||
|
directly inside the plugin's `widgets/` directory.
|
||||||
|
|
||||||
|
### Step 4: Widget Loading
|
||||||
|
|
||||||
|
The widget is loaded on demand when the config form renders a field that
|
||||||
|
references it. The system will:
|
||||||
|
|
||||||
|
1. Check if the widget is registered in the core registry
|
||||||
|
2. If not, fetch `/static/plugin-widgets/[plugin-id]/[widget-name].js`, which
|
||||||
|
serves the declared script from the plugin's `widgets/` directory
|
||||||
3. Render the widget using the registered `render` function
|
3. Render the widget using the registered `render` function
|
||||||
|
|
||||||
|
The fetch is a dynamic `import()`, so the file must parse as an ES module (a
|
||||||
|
plain IIFE does). If anything fails, the field falls back to a plain text input
|
||||||
|
holding the current value, so a broken widget never costs the user their
|
||||||
|
configured value.
|
||||||
|
|
||||||
|
Only `string`-typed fields take this path today; see `docs/widget-guide.md`
|
||||||
|
for the full details and limitations.
|
||||||
|
|
||||||
## Widget API Reference
|
## Widget API Reference
|
||||||
|
|
||||||
### Widget Definition Object
|
### Widget Definition Object
|
||||||
|
|||||||
@@ -872,6 +872,65 @@
|
|||||||
name="{{ full_key }}"
|
name="{{ full_key }}"
|
||||||
value="{{ str_value }}"
|
value="{{ str_value }}"
|
||||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
||||||
|
{% if str_widget %}
|
||||||
|
{# An x-widget the core does not ship may be supplied by the
|
||||||
|
plugin itself (manifest "widgets", served from its widgets/
|
||||||
|
directory). Ask the loader for it; the text input above is
|
||||||
|
the fallback and stays put unless the widget really renders,
|
||||||
|
so a missing or broken widget degrades to an editable field
|
||||||
|
rather than dropping the value on save. #}
|
||||||
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var WIDGET = {{ str_widget|tojson|safe }};
|
||||||
|
var PLUGIN = {{ plugin_id|tojson|safe }};
|
||||||
|
function initPluginWidget() {
|
||||||
|
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
|
||||||
|
setTimeout(initPluginWidget, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var container = document.getElementById('{{ field_id }}_container');
|
||||||
|
var fallback = document.getElementById('{{ field_id }}');
|
||||||
|
if (!container) { return; }
|
||||||
|
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) {
|
||||||
|
if (!available) { container.remove(); return; }
|
||||||
|
var widget = window.LEDMatrixWidgets.get(WIDGET);
|
||||||
|
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
|
||||||
|
var value = {{ str_value|tojson|safe }};
|
||||||
|
var config = {
|
||||||
|
'type': '{{ field_type }}',
|
||||||
|
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||||
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
||||||
|
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
widget.render(container, config, value, {
|
||||||
|
fieldId: '{{ field_id }}',
|
||||||
|
name: '{{ full_key }}',
|
||||||
|
pluginId: PLUGIN
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginWidget] render failed for ' + WIDGET, e);
|
||||||
|
container.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only now is the widget authoritative for this
|
||||||
|
// field; drop the fallback so the form posts one
|
||||||
|
// value rather than two under the same name.
|
||||||
|
if (fallback) { fallback.remove(); }
|
||||||
|
}).catch(function(e) {
|
||||||
|
console.warn('[PluginWidget] could not load ' + WIDGET, e);
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initPluginWidget);
|
||||||
|
} else {
|
||||||
|
setTimeout(initPluginWidget, 50);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user