mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): mask the Config Editor's secrets like GET /config/secrets The Config Editor tab (/partials/raw-json) filled its config_secrets.json editor with the file as it is on disk. GET /api/v3/config/secrets masks every value because the interface is reachable without a login by default, but this page handed the same credentials (GitHub token, Home Assistant token, plugin API keys) to anyone who loaded it. The masked-save path in save_raw_secrets_config was written for a masked editor and never got one. _load_raw_json_partial now masks the section with mask_all_secret_values after strip_auth_section, exactly as the GET does. Saving it back is safe: save_raw_secrets_config drops the masks (strip_masked_values) and merges the rest onto the stored file (deep_merge), so an untouched secret stays as it is and a replaced mask is the only value that changes. The config.json editor is left as it is. Its save (save_raw_main_config) writes the posted object verbatim, with no mask stripping or merge, so a masked main editor would write the bullets over any credential it holds. Masking it needs a merge-on-save of its own first. Tests: TestConfigEditorRoundTrip renders the partial over a real ConfigManager, checks no real value is in the editor, and posts the editor back unchanged (the file is identical) and with one mask replaced (only that value changes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions PluginOrderList draws one row per enabled plugin and, once drawn, rewrites its hidden inputs (plugin_rotation_order, vegas_plugin_order, vegas_excluded_plugins) from those rows. A disabled plugin has no row, so merely opening the Display or Rotation & Durations tab took it out of the inputs, and the next save of that form stored the lists without it. Exclude Clock from Vegas, disable it, change the brightness, re-enable it: Clock was scrolling in Vegas again and had moved to the end of the rotation. syncInputs now keeps the saved ids that have no row. In the order, each one keeps its saved slot and the rows fill the other slots in their current order, with rows not in the saved order last, as before. In the exclusions they follow the unchecked rows. Only string ids are carried over, once each: /config/main refuses a list holding anything else, which would block every later save of the tab. Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids) and is in run_all.js and the README. The durations DOM suite now reads only its own rows' ids from the input, since a rig's saved order can hold others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore reinstalls only the plugins that are missing POST /backup/restore with reinstall_plugins (the "Reinstall missing plugins" box) passed every plugin in the backup's plugins.json to install_plugin(). That replaces an installed copy with a fresh download, so a restore onto the same device re-downloaded every plugin inside the request. A plugin installed from its own URL is not in the registry, so its install returned False, plugins_failed set success to False, and the restore answered 500 "Restore incomplete ... plugins not reinstalled: <id>" (shown as "Restore failed") with the plugin still installed and the config restored. Each plugin is now looked up first with the store's _existing_install, the same lookup install_plugin makes to decide a copy exists: the id, or an id the registry proves is the same plugin (aliases, the plugin_path name), and never a bare ledmatrix-<id> folder (#686). One that is installed is recorded in result.skipped as "plugin:<id> (installed)", which the page lists under Skipped; a missing one is installed as before. The list_installed_plugins docstring said every listed plugin is reinstalled and now says otherwise. Tests: TestInstalledPluginsAreNotReinstalled, with a mocked store (installed skipped, missing installed; an installed plugin the store can't install is not a failure) and with a real PluginStoreManager (a registry alias and a third-party install are skipped, a missing plugin installed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): /config/main answers malformed JSON with a 400 save_main_config read a JSON body with request.get_json(), which raises Werkzeug's BadRequest for a body that does not parse (or an empty one sent as application/json). That happened inside the handler's try, so the catch-all answered 500 CONFIG_SAVE_FAILED with "Check file permissions on config directory" among its suggested fixes and logged a traceback at ERROR, for what was the caller's mistake. It now reads with get_json(silent=True), as save_raw_main_config does, and answers a sent-but-unparseable body with the same 400 {"status": "error", "message": "Invalid JSON in request body"}. An empty JSON body falls through to the existing 400 "No data provided". The change is limited to the lines that read the body. Tests: TestMalformedBody in test_api_v3_partial_main_save.py (the 400 and its shape, identical to /config/raw/main's, and nothing saved; the empty body). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore that brings back fonts clears the font catalog cache GET /api/v3/fonts/catalog caches its answer as fonts_catalog for five minutes. Font upload and delete clear that entry (fonts.py), but POST /backup/restore copies user fonts into assets/fonts without touching it, so restored fonts were missing from the Fonts tab and every font picker until the cache expired. backup_restore now clears fonts_catalog when the result lists restored fonts (restore_backup records them as "fonts (<count>)"). A restore that restored no fonts leaves the cache alone. Tests: TestFontsCatalogCache in test_api_v3_backup_restore.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): drop uninstalled plugins from the carried-over order and exclusions2b34f254made the plugin order list keep every saved id that has no row, so a disabled plugin keeps its rotation slot and Vegas exclusion. That also kept the ids of plugins that have since been uninstalled: they stayed in plugin_rotation_order and vegas_excluded_plugins for good, where before the next save of the tab dropped them. The widget already fetches /api/v3/plugins/installed, every installed plugin with its enabled flag, and draws only the enabled ones. It now keeps that response's full id set and carries over only saved ids that are installed but have no row (disabled). An id outside the set is dropped, as before. With no list, nothing is dropped: a failed request draws no rows and leaves the inputs as saved, and the carry-over keeps everything if the set was never filled. Tests: test/js/unit/test_plugin_order_list.js adds a disabled plugin kept while an uninstalled one is dropped (order and exclusions; fails on2b34f254), and a failed plugin list leaving both inputs as saved. The CHANGELOG bullet and the README row say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(js): register the order-list suite apart from other branches' suites Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
953 lines
42 KiB
Python
953 lines
42 KiB
Python
from flask import Blueprint, Response, render_template, jsonify, url_for
|
|
from jinja2 import TemplateNotFound
|
|
from html.parser import HTMLParser
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
|
|
# 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_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_all_secret_values, mask_secret_fields
|
|
from src.plugin_system.schema_manager import plugin_config_defaults, prepare_plugin_config
|
|
from src.common.path_safety import resolve_under, safe_path_component
|
|
from src.pi5_matrix_support import is_raspberry_pi_5
|
|
from web_interface import widget_bundle
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The managers live on the blueprint object: app.py sets
|
|
# pages_v3.config_manager, pages_v3.plugin_catalog and the rest. The catalog
|
|
# reads plugins as files; this process never runs plugin code.
|
|
pages_v3 = Blueprint('pages_v3', __name__)
|
|
|
|
|
|
@pages_v3.route('/assets/widgets.js')
|
|
def widgets_bundle():
|
|
"""Every widget script in one request (see web_interface/widget_bundle.py).
|
|
|
|
The individual files stay served from /static for plugin-loader.js and
|
|
debugging; this saves the Pi ~30 round trips on each first page load.
|
|
Cached as immutable by app.py because the URL carries the version.
|
|
"""
|
|
body, version = widget_bundle.build_bundle()
|
|
response = Response(body, mimetype='application/javascript')
|
|
response.headers['X-Widget-Bundle-Version'] = str(version)
|
|
return response
|
|
|
|
|
|
@pages_v3.app_context_processor
|
|
def inject_widget_bundle_url():
|
|
"""`widgets_bundle_url()` for templates, versioned by file mtime."""
|
|
return {
|
|
'widgets_bundle_url': lambda: url_for(
|
|
'pages_v3.widgets_bundle', v=widget_bundle.bundle_version()
|
|
)
|
|
}
|
|
|
|
|
|
class _SettingsIndexParser(HTMLParser):
|
|
"""Extract searchable settings fields from a rendered partial's HTML.
|
|
|
|
Captures one entry per ``<div class="form-group" id="setting-…">``: the
|
|
anchor id, ``data-setting-key``, the field's ``<label>`` text, the
|
|
``.help-tip`` tooltip text (``data-tooltip``), and the nearest preceding
|
|
``<h3>``/``<h4>`` section heading. Parsing the *rendered* HTML (rather than
|
|
the schema) guarantees the anchor ids match the live DOM exactly, so the
|
|
search index cannot drift from what users actually see.
|
|
"""
|
|
|
|
def __init__(self, tab, tab_label):
|
|
super().__init__(convert_charrefs=True)
|
|
self.tab = tab
|
|
self.tab_label = tab_label
|
|
self.fields = []
|
|
self._section = ''
|
|
self._field = None
|
|
self._depth = 0 # open-div depth within the current field
|
|
self._in_label = False
|
|
self._label_parts = []
|
|
self._in_heading = False
|
|
self._heading_parts = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
a = {k: (v or '') for k, v in attrs}
|
|
classes = a.get('class', '').split()
|
|
# Section headings (only when not already inside a field)
|
|
if tag in ('h3', 'h4') and self._field is None:
|
|
self._in_heading = True
|
|
self._heading_parts = []
|
|
if tag == 'div':
|
|
fid = a.get('id', '')
|
|
if self._field is None and 'form-group' in classes and fid.startswith('setting-'):
|
|
self._field = {
|
|
'anchorId': fid,
|
|
'key': a.get('data-setting-key', '') or fid[len('setting-'):],
|
|
'label': '',
|
|
'help': '',
|
|
'section': self._section,
|
|
'tab': self.tab,
|
|
'tabLabel': self.tab_label,
|
|
}
|
|
self._depth = 1
|
|
return
|
|
if self._field is not None:
|
|
self._depth += 1
|
|
if self._field is not None:
|
|
if tag == 'label' and not self._field['label']:
|
|
self._in_label = True
|
|
self._label_parts = []
|
|
if tag == 'button' and 'help-tip' in classes and not self._field['help']:
|
|
self._field['help'] = a.get('data-tooltip', '')
|
|
|
|
def handle_data(self, data):
|
|
if self._in_label:
|
|
self._label_parts.append(data)
|
|
elif self._in_heading:
|
|
self._heading_parts.append(data)
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag in ('h3', 'h4') and self._in_heading:
|
|
self._in_heading = False
|
|
self._section = ' '.join(''.join(self._heading_parts).split()).strip()
|
|
return
|
|
if self._field is None:
|
|
return
|
|
if tag == 'label' and self._in_label:
|
|
self._in_label = False
|
|
self._field['label'] = ' '.join(''.join(self._label_parts).split()).strip()
|
|
elif tag == 'div':
|
|
self._depth -= 1
|
|
if self._depth <= 0:
|
|
if self._field['label']:
|
|
self.fields.append(self._field)
|
|
self._field = None
|
|
self._depth = 0
|
|
|
|
|
|
def _partial_html(loader):
|
|
"""Run a partial loader and return its HTML string ('' on error)."""
|
|
try:
|
|
result = loader()
|
|
except Exception:
|
|
logger.warning("search-index: partial render failed", exc_info=True)
|
|
return ''
|
|
if isinstance(result, str):
|
|
return result
|
|
if isinstance(result, tuple): # loaders return (msg, status) on error
|
|
return ''
|
|
try:
|
|
return result.get_data(as_text=True)
|
|
except Exception:
|
|
return ''
|
|
|
|
|
|
def _extract_settings_fields(html, tab, tab_label):
|
|
parser = _SettingsIndexParser(tab, tab_label)
|
|
parser.feed(html)
|
|
return parser.fields
|
|
|
|
|
|
# Cache the built index keyed on the installed-plugin set. Core labels/tooltips
|
|
# are static template text, so only a change in installed plugins invalidates it.
|
|
_SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
|
|
|
|
|
|
@pages_v3.route('/')
|
|
def index():
|
|
"""Main v3 interface page: the app shell. Every tab loads as a partial."""
|
|
return render_template('v3/base.html')
|
|
|
|
@pages_v3.route('/partials/<partial_name>')
|
|
def load_partial(partial_name):
|
|
"""One tab's HTML for HTMX, by the names in _PARTIAL_LOADERS; 404 otherwise."""
|
|
loader = _PARTIAL_LOADERS.get(partial_name)
|
|
if loader is None:
|
|
return "Partial not found", 404
|
|
try:
|
|
return loader()
|
|
except Exception:
|
|
logger.error("Error loading partial %s", partial_name, exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
|
|
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
|
def load_plugin_config_partial(plugin_id):
|
|
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
|
try:
|
|
return _load_plugin_config_partial(plugin_id)
|
|
except Exception:
|
|
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
|
|
|
|
|
@pages_v3.route('/settings/search-index')
|
|
def settings_search_index():
|
|
"""Return a flat JSON index of every searchable setting (core + plugin).
|
|
|
|
Powers the web UI's global settings search. Built by rendering the settings
|
|
partials server-side and extracting field metadata, then cached per
|
|
installed-plugin set so it is off the display's hot path.
|
|
"""
|
|
# Core settings tabs: (activeTab value, human label, loader).
|
|
core_tabs = [
|
|
('general', 'General', _load_general_partial),
|
|
('display', 'Display', _load_display_partial),
|
|
('durations', 'Durations', _load_durations_partial),
|
|
('schedule', 'Schedule', _load_schedule_partial),
|
|
('wifi', 'WiFi', _load_wifi_partial),
|
|
]
|
|
try:
|
|
plugin_ids = []
|
|
if pages_v3.plugin_catalog:
|
|
try:
|
|
pages_v3.plugin_catalog.discover_plugins()
|
|
plugin_ids = sorted(
|
|
pi.get('id') for pi in pages_v3.plugin_catalog.get_all_plugin_info()
|
|
if pi.get('id')
|
|
)
|
|
except Exception:
|
|
logger.warning("search-index: could not enumerate plugins", exc_info=True)
|
|
|
|
sig = tuple(plugin_ids)
|
|
if _SEARCH_INDEX_CACHE['sig'] == sig and _SEARCH_INDEX_CACHE['fields'] is not None:
|
|
return jsonify({'fields': _SEARCH_INDEX_CACHE['fields']})
|
|
|
|
fields = []
|
|
for tab, label, loader in core_tabs:
|
|
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))
|
|
|
|
for pid in plugin_ids:
|
|
info = pages_v3.plugin_catalog.get_plugin_info(pid) or {}
|
|
label = info.get('name', pid)
|
|
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
|
|
fields.extend(_extract_settings_fields(html, pid, label))
|
|
|
|
_SEARCH_INDEX_CACHE['sig'] = sig
|
|
_SEARCH_INDEX_CACHE['fields'] = fields
|
|
return jsonify({'fields': fields})
|
|
except Exception:
|
|
logger.error("Error building settings search index", exc_info=True)
|
|
return jsonify({'fields': []}), 500
|
|
|
|
|
|
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
|
def serve_plugin_web_ui(plugin_id, filename):
|
|
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
|
|
|
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
|
and loads Tailwind CSS so the fragment runs correctly inside the iframe
|
|
that plugin_config.html embeds it in.
|
|
|
|
That iframe carries no ``sandbox`` attribute, so the fragment runs with
|
|
the interface's own origin. That is deliberate rather than an oversight:
|
|
the fragment is a file from an installed plugin, and an installed plugin
|
|
already runs Python on the device. The trust boundary is plugin install,
|
|
not this route. It is worth knowing when reading the code, which is why
|
|
it says so here instead of claiming a sandbox that is not there.
|
|
"""
|
|
# Validate URL-derived values against strict allowlists before any path or
|
|
# script operations.
|
|
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
|
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
|
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
|
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
|
|
|
# The allowlists above already forbid a separator, but the value that gets
|
|
# joined has to be the checked one, not the argument -- see
|
|
# src/common/path_safety.py. safe_path_component rejects rather than
|
|
# truncates, so these cannot disagree.
|
|
safe_id = safe_path_component(plugin_id)
|
|
safe_fn = safe_path_component(filename)
|
|
if not safe_id or not safe_fn:
|
|
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
|
|
|
if not pages_v3.plugin_catalog:
|
|
return 'Plugin catalog not available', 503, {'Content-Type': 'text/plain'}
|
|
|
|
try:
|
|
web_ui_path = resolve_under(_plugin_dir_for(safe_id) / 'web_ui', safe_fn)
|
|
if web_ui_path is None:
|
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
|
|
|
if not web_ui_path.exists():
|
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
|
|
|
fragment = web_ui_path.read_text(encoding='utf-8')
|
|
|
|
# json.dumps wraps the value in quotes. Replace HTML meta-chars with
|
|
# their JS Unicode escape sequences so the value cannot close or escape
|
|
# the enclosing <script> tag.
|
|
# r'<' is the 6-char literal string <, which JavaScript
|
|
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
|
|
safe_plugin_id_js = (
|
|
json.dumps(safe_id)
|
|
.replace('<', '\\u003c')
|
|
.replace('>', '\\u003e')
|
|
.replace('&', '\\u0026')
|
|
)
|
|
|
|
page = (
|
|
'<!DOCTYPE html>\n'
|
|
'<html lang="en">\n'
|
|
'<head>\n'
|
|
'<meta charset="UTF-8">\n'
|
|
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
|
'<script>\n'
|
|
# Inject plugin context before the fragment runs.
|
|
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
|
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
|
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
|
|
'</script>\n'
|
|
# Served locally (not from a CDN) so fragments are styled in AP
|
|
# mode with no internet. Built by scripts/build_css.py; see
|
|
# web_interface/tailwind/plugin-frame.config.js.
|
|
'<link rel="stylesheet" href="'
|
|
+ url_for('static', filename='v3/plugin-frame.css')
|
|
+ '">\n'
|
|
'<style>body{margin:0;padding:0;background:#fff;}</style>\n'
|
|
'</head>\n'
|
|
'<body>\n'
|
|
+ fragment +
|
|
'\n</body>\n</html>'
|
|
)
|
|
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
|
|
|
except ValueError:
|
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
|
except Exception:
|
|
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
|
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
|
|
|
|
|
def _resolved_plugin_dir(plugin_id):
|
|
"""The plugin manager's answer for where ``plugin_id`` lives, or None.
|
|
|
|
Its discovery map is authoritative (a plugin whose directory name is not
|
|
its id is found there), and it refuses anything that is not one plain
|
|
path segment. See src/plugin_system/plugin_dirs.py.
|
|
"""
|
|
found = pages_v3.plugin_catalog.get_plugin_directory(plugin_id)
|
|
if isinstance(found, (str, Path)) and Path(found).exists():
|
|
return Path(found)
|
|
return None
|
|
|
|
|
|
def _plugin_dir_for(safe_id):
|
|
"""A sanitised plugin id's directory, which may not exist.
|
|
|
|
The plugin manager's resolver first; otherwise contained under the
|
|
configured plugins directory, with PluginManager's ``ledmatrix-`` prefix
|
|
fallback. Raises ValueError for an id that would leave it; the routes
|
|
answer that with a 403.
|
|
"""
|
|
resolved = _resolved_plugin_dir(safe_id)
|
|
if resolved is not None:
|
|
return resolved
|
|
|
|
plugins_base = Path(pages_v3.plugin_catalog.plugins_dir).resolve()
|
|
plugin_dir = resolve_under(plugins_base, safe_id)
|
|
if plugin_dir is None:
|
|
raise ValueError('plugin id escapes the plugins directory')
|
|
|
|
if not plugin_dir.exists():
|
|
alt = resolve_under(plugins_base, f'ledmatrix-{safe_id}')
|
|
if alt is not None:
|
|
plugin_dir = alt
|
|
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'}
|
|
|
|
# safe_path_component is this codebase's sanitiser (src/common/
|
|
# path_safety.py): it rejects rather than mangles, so a name that is not
|
|
# a plain path component never reaches the filesystem.
|
|
safe_id = safe_path_component(plugin_id)
|
|
safe_widget = safe_path_component(widget_name)
|
|
if not safe_id or not safe_widget:
|
|
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
|
|
|
if not pages_v3.plugin_catalog:
|
|
return 'Plugin catalog 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'}
|
|
|
|
# Contain widgets/ itself first: a symlinked widgets directory would
|
|
# otherwise become the base the script is checked against.
|
|
widgets_dir = resolve_under(plugin_dir, 'widgets')
|
|
if widgets_dir is None:
|
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
|
# The script name comes from the plugin's manifest, not the request,
|
|
# so it gets the same containment treatment the URL parts got.
|
|
script_path = resolve_under(widgets_dir, script)
|
|
if script_path is None or 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():
|
|
"""Load overview partial with system stats"""
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
# This would be populated with real system stats via SSE
|
|
return render_template('v3/partials/overview.html',
|
|
main_config=main_config)
|
|
|
|
def _load_general_partial():
|
|
"""Load general settings partial"""
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
try:
|
|
from web_interface.auto_update import describe_status
|
|
auto_update_status = describe_status(main_config)
|
|
except Exception:
|
|
logger.debug("Could not read auto-update status", exc_info=True)
|
|
auto_update_status = None
|
|
try:
|
|
# Local refs only: a page load must not wait on the network.
|
|
from web_interface import update_channel
|
|
update_channel_status = update_channel.resolve(update_channel.PROJECT_ROOT, main_config)
|
|
except Exception:
|
|
logger.debug("Could not read the update channel", exc_info=True)
|
|
update_channel_status = None
|
|
return render_template('v3/partials/general.html',
|
|
main_config=main_config,
|
|
auto_update_status=auto_update_status,
|
|
update_channel_status=update_channel_status,
|
|
web_login=_web_login_state())
|
|
|
|
|
|
def _web_login_state():
|
|
"""What the General tab's Security section shows; None hides it.
|
|
|
|
None when the app has no login store (a bare test app), so the section
|
|
only appears where it can work. Never includes a hash.
|
|
"""
|
|
from web_interface import auth as web_auth
|
|
store = web_auth.get_store()
|
|
if store is None:
|
|
return None
|
|
return {
|
|
'enabled': store.is_enabled(),
|
|
'tokens': store.list_tokens(),
|
|
'min_length': web_auth.MIN_PASSWORD_LENGTH,
|
|
}
|
|
|
|
def _load_display_partial():
|
|
"""Load display settings partial"""
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
return render_template('v3/partials/display.html',
|
|
main_config=main_config,
|
|
is_pi5=is_raspberry_pi_5())
|
|
|
|
def _plugin_default_duration(plugin_id, plugin_config):
|
|
"""Seconds a plugin shows each screen when the Rotation page sets none.
|
|
|
|
Mirrors BasePlugin.get_display_duration's config fallback: the plugin's
|
|
display_duration, else its schema default, else 15.
|
|
"""
|
|
def _valid(v):
|
|
return isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0
|
|
|
|
value = plugin_config.get('display_duration')
|
|
if not _valid(value):
|
|
schema = None
|
|
schema_mgr = getattr(pages_v3, 'schema_manager', None)
|
|
if schema_mgr is not None:
|
|
try:
|
|
schema = schema_mgr.load_schema(plugin_id)
|
|
except Exception:
|
|
logger.debug("durations: no schema for %s", plugin_id, exc_info=True)
|
|
value = plugin_config_defaults(schema if isinstance(schema, dict) else None).get(
|
|
'display_duration')
|
|
if not _valid(value):
|
|
value = 15
|
|
return int(value) if float(value).is_integer() else value
|
|
|
|
|
|
def _load_durations_partial():
|
|
"""Load rotation & durations partial.
|
|
|
|
Builds one duration entry per display mode of every enabled plugin. A mode
|
|
with a value saved in display.display_durations shows it; the rest are
|
|
blank, with the plugin's own duration as the placeholder, because a saved
|
|
value overrides the plugin (see DisplayController._get_display_duration).
|
|
Pre-filling every mode would pin them all on the first save.
|
|
"""
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
duration_groups = []
|
|
covered_keys = set()
|
|
if pages_v3.plugin_catalog:
|
|
try:
|
|
pages_v3.plugin_catalog.discover_plugins()
|
|
saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {}
|
|
infos = sorted(pages_v3.plugin_catalog.get_all_plugin_info(),
|
|
key=lambda i: (i.get('name') or i.get('id') or '').lower())
|
|
for info in infos:
|
|
pid = info.get('id')
|
|
if not pid or not (main_config.get(pid, {}) or {}).get('enabled', False):
|
|
continue
|
|
modes = pages_v3.plugin_catalog.get_plugin_display_modes(pid) or [pid]
|
|
covered_keys.update(modes)
|
|
default = _plugin_default_duration(pid, main_config.get(pid, {}) or {})
|
|
duration_groups.append({
|
|
'plugin_id': pid,
|
|
'plugin_name': info.get('name') or pid,
|
|
'modes': [{'key': m, 'value': saved.get(m, ''), 'default': default}
|
|
for m in modes],
|
|
})
|
|
# Saved keys not owned by any enabled plugin (disabled or
|
|
# uninstalled plugins) stay visible rather than vanishing.
|
|
leftovers = [{'key': k, 'value': v} for k, v in saved.items()
|
|
if k not in covered_keys]
|
|
if leftovers:
|
|
duration_groups.append({
|
|
'plugin_id': '',
|
|
'plugin_name': 'Other saved entries',
|
|
'modes': leftovers,
|
|
})
|
|
except Exception:
|
|
logger.warning("durations: could not enumerate plugin modes", exc_info=True)
|
|
return render_template('v3/partials/durations.html',
|
|
main_config=main_config,
|
|
duration_groups=duration_groups)
|
|
|
|
def _load_schedule_partial():
|
|
"""Load schedule settings partial"""
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
schedule_config = main_config.get('schedule', {})
|
|
dim_schedule_config = main_config.get('dim_schedule', {})
|
|
# Get normal brightness for display in dim schedule UI
|
|
normal_brightness = main_config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
|
return render_template('v3/partials/schedule.html',
|
|
schedule_config=schedule_config,
|
|
dim_schedule_config=dim_schedule_config,
|
|
normal_brightness=normal_brightness)
|
|
|
|
|
|
def _load_plugins_partial():
|
|
"""Load plugins management partial"""
|
|
# plugins.html takes no plugin data: plugins_manager.js fetches the list
|
|
# from /api/v3/plugins/installed. Building it here read every manifest on
|
|
# disk on each Plugins-tab load for nothing.
|
|
return render_template('v3/partials/plugins.html')
|
|
|
|
def _load_fonts_partial():
|
|
"""Load fonts management partial"""
|
|
# The page fetches its font data from /api/v3/fonts/* itself.
|
|
return render_template('v3/partials/fonts.html')
|
|
|
|
def _load_logs_partial():
|
|
"""Load logs viewer partial"""
|
|
return render_template('v3/partials/logs.html')
|
|
|
|
def _load_raw_json_partial():
|
|
"""Load raw JSON editor partial"""
|
|
if pages_v3.config_manager:
|
|
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
|
# The web login section (password and token hashes) is managed in
|
|
# General > Security, never in this editor; its save keeps it.
|
|
# The rest is masked, as GET /api/v3/config/secrets masks it: this
|
|
# page is served to anyone who can reach the port while the web
|
|
# login is off, and it was handing them every credential in the
|
|
# file. The save strips the masks and merges onto the stored file
|
|
# (save_raw_secrets_config), so a value left masked stays as it is.
|
|
from web_interface.auth import strip_auth_section
|
|
secrets_config_data = mask_all_secret_values(strip_auth_section(
|
|
pages_v3.config_manager.get_raw_file_content('secrets')))
|
|
main_config_json = json.dumps(main_config_data, indent=4)
|
|
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
|
|
|
return render_template('v3/partials/raw_json.html',
|
|
main_config_json=main_config_json,
|
|
secrets_config_json=secrets_config_json,
|
|
main_config_path=pages_v3.config_manager.get_config_path(),
|
|
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
|
|
|
def _load_backup_restore_partial():
|
|
"""Load backup & restore partial."""
|
|
return render_template('v3/partials/backup_restore.html')
|
|
|
|
@pages_v3.route('/setup')
|
|
def captive_setup():
|
|
"""Lightweight captive portal setup page — self-contained, no frameworks."""
|
|
return render_template('v3/captive_setup.html')
|
|
|
|
def _load_wifi_partial():
|
|
"""Load WiFi setup partial"""
|
|
return render_template('v3/partials/wifi.html')
|
|
|
|
def _load_cache_partial():
|
|
"""Load cache management partial"""
|
|
return render_template('v3/partials/cache.html')
|
|
|
|
def _load_operation_history_partial():
|
|
"""Load operation history partial"""
|
|
return render_template('v3/partials/operation_history.html')
|
|
|
|
|
|
def _load_tools_partial():
|
|
"""Load tools/utilities partial."""
|
|
try:
|
|
return render_template('v3/partials/tools.html')
|
|
except TemplateNotFound:
|
|
logger.error("[Pages V3][Tools] Template not found: v3/partials/tools.html", exc_info=True)
|
|
return "[Pages V3][Tools] Template is missing.", 500
|
|
except OSError as exc:
|
|
logger.error("[Pages V3][Tools] I/O error loading tools partial: %s", exc, exc_info=True)
|
|
return "[Pages V3][Tools] Failed to load due to a file system error. Check logs.", 500
|
|
|
|
|
|
_PARTIAL_LOADERS = {
|
|
'overview': _load_overview_partial,
|
|
'general': _load_general_partial,
|
|
'display': _load_display_partial,
|
|
'durations': _load_durations_partial,
|
|
'schedule': _load_schedule_partial,
|
|
'plugins': _load_plugins_partial,
|
|
'fonts': _load_fonts_partial,
|
|
'logs': _load_logs_partial,
|
|
'raw-json': _load_raw_json_partial,
|
|
'backup-restore': _load_backup_restore_partial,
|
|
'wifi': _load_wifi_partial,
|
|
'cache': _load_cache_partial,
|
|
'operation-history': _load_operation_history_partial,
|
|
'tools': _load_tools_partial,
|
|
}
|
|
|
|
|
|
def _load_plugin_config_partial(plugin_id):
|
|
"""
|
|
Load plugin configuration partial - server-side rendered form.
|
|
This replaces the client-side generateConfigForm() JavaScript.
|
|
"""
|
|
# Refuse an id that is not a plain directory name, rather than quietly
|
|
# basename-ing it down to one: "../weather" used to become "weather" and
|
|
# render a partial the caller never asked for.
|
|
plugin_id = safe_path_component(plugin_id)
|
|
if not plugin_id or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
|
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
|
|
try:
|
|
if not pages_v3.plugin_catalog:
|
|
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
|
|
|
# Handle starlark app config (starlark:<app_id>)
|
|
if plugin_id.startswith('starlark:'):
|
|
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
|
|
|
# Resolve and validate all plugin paths against the plugins base directory
|
|
_plugins_base = Path(pages_v3.plugin_catalog.plugins_dir).resolve()
|
|
_plugin_dir = resolve_under(_plugins_base, plugin_id)
|
|
if _plugin_dir is None:
|
|
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
|
|
# Try to get plugin info first
|
|
plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)
|
|
|
|
# If not found, re-discover plugins (handles plugins added after startup)
|
|
if not plugin_info:
|
|
pages_v3.plugin_catalog.discover_plugins()
|
|
plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)
|
|
|
|
if not plugin_info:
|
|
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
|
|
|
# The containment check above only validates the id. The files are
|
|
# read from wherever the plugin manager says the plugin lives, which
|
|
# for one installed as ledmatrix-<id> is not plugins_dir/<id>.
|
|
_plugin_dir = _resolved_plugin_dir(plugin_id) or _plugin_dir
|
|
|
|
# Get plugin configuration from config file
|
|
config = {}
|
|
if pages_v3.config_manager:
|
|
full_config = pages_v3.config_manager.load_config()
|
|
config = full_config.get(plugin_id, {})
|
|
|
|
# Load uploaded images from metadata file if images field exists in schema
|
|
schema_path_temp = resolve_under(_plugin_dir, "config_schema.json")
|
|
if schema_path_temp is not None and schema_path_temp.exists():
|
|
try:
|
|
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
|
temp_schema = json.load(f)
|
|
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
|
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
|
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
|
metadata_file = resolve_under(
|
|
_assets_base, plugin_id, 'uploads', '.metadata.json'
|
|
)
|
|
if metadata_file and metadata_file.exists():
|
|
try:
|
|
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
|
metadata = json.load(mf)
|
|
images_from_metadata = list(metadata.values())
|
|
if not config.get('images') or len(config.get('images', [])) == 0:
|
|
config['images'] = images_from_metadata
|
|
else:
|
|
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
|
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
|
if new_images:
|
|
config['images'] = config.get('images', []) + new_images
|
|
except Exception as e:
|
|
logger.warning("Could not load plugin upload metadata: %s", e)
|
|
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
|
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
|
|
|
# Get plugin schema.
|
|
#
|
|
# Through SchemaManager, not a raw json.load, because that is what
|
|
# the save route uses (api_v3.save_plugin_config) -- and the two
|
|
# disagreeing is not academic. SchemaManager applies
|
|
# expand_style_elements, which turns a compact
|
|
# customization.x-style-elements declaration into the per-element
|
|
# blocks this form renders. Reading the file directly meant a plugin
|
|
# using that form (of-the-day ships one) had a customization section
|
|
# that rendered nothing at all, while saving still validated against
|
|
# the expanded shape.
|
|
#
|
|
# use_cache=False matches the save route: a plugin's schema changes
|
|
# on disk during development, and a cached copy would keep serving
|
|
# the old form.
|
|
#
|
|
# The raw read stays as a fallback for callers that never set a
|
|
# schema_manager (several tests, and any embedder of this blueprint).
|
|
schema = {}
|
|
schema_mgr = getattr(pages_v3, 'schema_manager', None)
|
|
if schema_mgr is not None:
|
|
try:
|
|
schema = schema_mgr.load_schema(plugin_id, use_cache=False) or {}
|
|
except Exception as e:
|
|
logger.warning("SchemaManager could not load schema for %s: %s",
|
|
plugin_id, e)
|
|
if not schema:
|
|
# resolve_under keeps the containment guard main added here; the
|
|
# SchemaManager path above does its own.
|
|
schema_path = resolve_under(_plugin_dir, "config_schema.json")
|
|
if schema_path is not None and schema_path.exists():
|
|
try:
|
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
schema = json.load(f)
|
|
except Exception as e:
|
|
logger.warning("Could not load schema for plugin: %s", e)
|
|
|
|
# Get web UI actions from plugin manifest
|
|
web_ui_actions = []
|
|
manifest_path = resolve_under(_plugin_dir, "manifest.json")
|
|
if manifest_path is not None and manifest_path.exists():
|
|
try:
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
manifest = json.load(f)
|
|
web_ui_actions = manifest.get('web_ui_actions', [])
|
|
except Exception as e:
|
|
logger.warning("Could not load manifest for plugin: %s", e)
|
|
|
|
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
|
if not isinstance(schema_properties, dict):
|
|
return '<div class="text-red-500 p-4">Error loading plugin config securely: schema unavailable.</div>', 500
|
|
|
|
# Fill in schema defaults for keys the saved config doesn't have yet,
|
|
# as GET /api/v3/plugins/config does. Without this, an option added in
|
|
# a plugin update (geochron 1.2.0's show_date, default true) renders
|
|
# as an unchecked box, and because the save treats every drawn but
|
|
# unposted checkbox as false, the first save turns it off for good.
|
|
try:
|
|
defaults = plugin_config_defaults(schema)
|
|
if schema_mgr is not None:
|
|
defaults = schema_mgr.apply_device_location(defaults)
|
|
config = prepare_plugin_config(config, schema, defaults)
|
|
except Exception as e:
|
|
logger.warning("Could not merge schema defaults for %s: %s", plugin_id, e)
|
|
|
|
# Mask secret fields before rendering template (fail closed — never
|
|
# leak secrets). After the merge, so a secret's default is masked too.
|
|
config = mask_secret_fields(config, schema_properties)
|
|
|
|
# Determine enabled status
|
|
enabled = config.get('enabled', True)
|
|
|
|
# Build plugin data for template
|
|
plugin_data = {
|
|
'id': plugin_id,
|
|
'name': plugin_info.get('name', plugin_id),
|
|
'author': plugin_info.get('author', 'Unknown'),
|
|
'version': plugin_info.get('version', ''),
|
|
'description': plugin_info.get('description', ''),
|
|
'category': plugin_info.get('category', 'General'),
|
|
'tags': plugin_info.get('tags', []),
|
|
'enabled': enabled,
|
|
'last_commit': plugin_info.get('last_commit') or plugin_info.get('last_commit_sha', ''),
|
|
'branch': plugin_info.get('branch', ''),
|
|
}
|
|
|
|
return render_template(
|
|
'v3/partials/plugin_config.html',
|
|
plugin=plugin_data,
|
|
config=config,
|
|
schema=schema,
|
|
web_ui_actions=web_ui_actions
|
|
)
|
|
|
|
except Exception:
|
|
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
|
|
|
|
|
def _load_starlark_config_partial(app_id):
|
|
"""Load configuration partial for a Starlark app."""
|
|
# Refuse an id that is not a plain directory name rather than basename-ing
|
|
# it down to one -- see _load_plugin_config_partial for why.
|
|
app_id = safe_path_component(app_id)
|
|
if not app_id or not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
|
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
|
|
|
try:
|
|
# Always None: the web process runs no plugin code. See
|
|
# api_v3._get_starlark_plugin, the one seam for this.
|
|
from web_interface.blueprints.api_v3 import _get_starlark_plugin
|
|
starlark_plugin = _get_starlark_plugin()
|
|
|
|
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
|
app = starlark_plugin.apps.get(app_id)
|
|
if not app:
|
|
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
|
return render_template(
|
|
'v3/partials/starlark_config.html',
|
|
app_id=app_id,
|
|
app_name=app.manifest.get('name', app_id),
|
|
app_enabled=app.is_enabled(),
|
|
render_interval=app.get_render_interval(),
|
|
display_duration=app.get_display_duration(),
|
|
config=app.config,
|
|
schema=app.schema,
|
|
has_frames=app.frames is not None,
|
|
frame_count=len(app.frames) if app.frames else 0,
|
|
last_render_time=app.last_render_time,
|
|
)
|
|
|
|
# Standalone: read from manifest file
|
|
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
|
|
manifest_file = starlark_base / 'manifest.json'
|
|
if not manifest_file.exists():
|
|
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
|
|
|
with open(manifest_file, 'r') as f:
|
|
manifest = json.load(f)
|
|
|
|
app_data = manifest.get('apps', {}).get(app_id)
|
|
if not app_data:
|
|
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
|
|
|
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
|
schema = None
|
|
schema_file = resolve_under(starlark_base, app_id, 'schema.json')
|
|
if schema_file and schema_file.exists():
|
|
try:
|
|
with open(schema_file, 'r') as f:
|
|
schema = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning("Could not load starlark schema for app: %s", e)
|
|
|
|
# Load config from config.json if it exists — validate path stays within starlark_base
|
|
config = {}
|
|
config_file = resolve_under(starlark_base, app_id, 'config.json')
|
|
if config_file and config_file.exists():
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
config = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.warning("Could not load starlark config for app: %s", e)
|
|
|
|
return render_template(
|
|
'v3/partials/starlark_config.html',
|
|
app_id=app_id,
|
|
app_name=app_data.get('name', app_id),
|
|
app_enabled=app_data.get('enabled', True),
|
|
render_interval=app_data.get('render_interval', 300),
|
|
display_duration=app_data.get('display_duration', 15),
|
|
config=config,
|
|
schema=schema,
|
|
has_frames=False,
|
|
frame_count=0,
|
|
last_render_time=None,
|
|
)
|
|
|
|
except Exception:
|
|
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
|
|
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500
|