mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(web): accept every panel size and row address type the rgbmatrix library does The Display form capped columns at 128 and chain length at 24, and its submit handler (fixInvalidNumberInputs) rewrote anything larger to the cap, so wide panels and long chains silently saved as the wrong size. The config API checked none of the hardware numbers, so values the library rejects (odd rows, parallel 4, PWM dither bits 3) saved and the matrix then refused to start. - Form limits now match the pinned library: rows even 8-64, cols >= 16 and chain_length >= 1 with no upper bound, parallel 1-3, PWM dither bits 0-2, PWM LSB nanoseconds 50-3000. - save_main_config rejects out-of-range rows, cols, chain_length, parallel, brightness, scan_mode, pwm_bits, pwm_dither_bits, pwm_lsb_nanoseconds and gpio_slowdown with a 400. - A stored gpio_slowdown or pwm_dither_bits of 0 renders as 0 instead of the default, so saving the tab no longer overwrites it. - Row Address Type offers 5 (SM5368 / B707 row shift register). Verified on a Waveshare 96x48 V2 (24S-A1) on a Pi 4 with the Adafruit Triple LED Matrix Bonnet: rows 48, cols 96, row address type 5, BGR, GPIO slowdown 8. - Help text and docs: FM6124-family panels use Panel Type Standard; on a Pi 5 the library supports only row address types 0 and 2. No change to the rpi-rgb-led-matrix submodule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): drop the rows cap and document every display setting accurately Rows: no upper limit in the form or the API. Still even and at least 8. The current rgbmatrix library rejects more than 64 per panel, so a larger value saves but the matrix won't start; the help tip, README, config reference and troubleshooting section all say so, and nothing here needs changing if the library lifts the limit. limit_refresh_rate_hz: the form accepts 0 (the library's "no cap"), a stored 0 no longer renders and re-saves as 120, and the API rejects negatives. pwm_dither_bits stays 0-2: the library rejects 3 and 4, so the old form's 0-4 only ever let users save a config the display couldn't start with. Docs and help tips, checked against the pinned library and its README: - panel_type and rp1_rio get README entries - show_refresh_rate prints to stdout; it never drew on the panel - dither bits raise the refresh rate; the tip said they lowered it - scan_mode is about interlacing at low refresh, not wrong colours - disable_hardware_pulsing: hardware pulsing needs OE on GPIO 18 and the onboard sound driver off; software timing makes rows flash brighter - gpio_slowdown guidance agrees between the README and the UI - all 22 multiplexing values listed; every numeric setting states its range - troubleshooting for a blank panel after a settings change, jumping rows and brightness flashes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): reject true and 5.5 for row_address_type and multiplexing Both still went straight through int(), so a JSON true saved as 1 and 5.5 as 5. They now use the shared hardware range check like the other panel fields. Review feedback on #586. Also: the RP1 Backend tooltip said it is ignored on Pi 3/4 (it is ignored on every model but the Pi 5), and the README gave the dynamic-duration default cap as 90s; the code default is 180s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: refuse matrix settings a Raspberry Pi 5 can't drive On a Pi 5 the pinned rgbmatrix library drives the panel through the RP1 chip, and that path supports only row address types 0 and 2, parallel 1-3 and the regular / regular-pi1 / classic / adafruit-hat(-pwm) mappings (Rp1PioConfigSupported in lib/rp1/rp1_pio_backend.cc). For anything else CreateFromOptions returns NULL; the Python binding doesn't check, so the display process crashed on its first call into the matrix and systemd restarted it into the same crash every 10 seconds. - src/pi5_matrix_support.py: the rule and Pi 5 detection, matching the library's /proc/device-tree/model check - DisplayManager raises before creating the matrix, so it is a logged init failure (reported by /api/v3/hardware/status) and fallback mode - the config API rejects those settings on a Pi 5 when a request sets row_address_type, parallel or hardware_mapping - the Display form offers only row address types 0 and 2 on a Pi 5, and warns when a stored value can't be used - CLAUDE.md: re-check the rule whenever the submodule is bumped Review feedback on #586. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1037 lines
46 KiB
Python
1037 lines
46 KiB
Python
from flask import Blueprint, Response, render_template, flash, jsonify, url_for
|
|
from jinja2 import TemplateNotFound
|
|
from markupsafe import escape
|
|
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_secret_fields
|
|
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__)
|
|
|
|
# Will be initialized when blueprint is registered
|
|
config_manager = None
|
|
plugin_manager = None
|
|
plugin_store_manager = None
|
|
schema_manager = None
|
|
|
|
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"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
# Load configuration data
|
|
main_config = pages_v3.config_manager.load_config()
|
|
schedule_config = main_config.get('schedule', {})
|
|
|
|
# Get raw config files for JSON editor
|
|
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
|
secrets_config_data = 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)
|
|
else:
|
|
raise Exception("Config manager not initialized")
|
|
|
|
except Exception as e:
|
|
flash(f"Error loading configuration: {e}", "error")
|
|
schedule_config = {}
|
|
main_config_json = "{}"
|
|
secrets_config_json = "{}"
|
|
main_config_data = {}
|
|
secrets_config_data = {}
|
|
|
|
return render_template('v3/index.html',
|
|
schedule_config=schedule_config,
|
|
main_config_json=main_config_json,
|
|
secrets_config_json=secrets_config_json,
|
|
main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "",
|
|
secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "",
|
|
main_config=main_config_data,
|
|
secrets_config=secrets_config_data)
|
|
|
|
@pages_v3.route('/partials/<partial_name>')
|
|
def load_partial(partial_name):
|
|
"""Load HTMX partials dynamically"""
|
|
try:
|
|
# Map partial names to specific data loading
|
|
if partial_name == 'overview':
|
|
return _load_overview_partial()
|
|
elif partial_name == 'general':
|
|
return _load_general_partial()
|
|
elif partial_name == 'display':
|
|
return _load_display_partial()
|
|
elif partial_name == 'durations':
|
|
return _load_durations_partial()
|
|
elif partial_name == 'schedule':
|
|
return _load_schedule_partial()
|
|
elif partial_name == 'plugins':
|
|
return _load_plugins_partial()
|
|
elif partial_name == 'fonts':
|
|
return _load_fonts_partial()
|
|
elif partial_name == 'logs':
|
|
return _load_logs_partial()
|
|
elif partial_name == 'raw-json':
|
|
return _load_raw_json_partial()
|
|
elif partial_name == 'backup-restore':
|
|
return _load_backup_restore_partial()
|
|
elif partial_name == 'wifi':
|
|
return _load_wifi_partial()
|
|
elif partial_name == 'cache':
|
|
return _load_cache_partial()
|
|
elif partial_name == 'operation-history':
|
|
return _load_operation_history_partial()
|
|
elif partial_name == 'tools':
|
|
return _load_tools_partial()
|
|
else:
|
|
return "Partial not found", 404
|
|
|
|
except Exception as e:
|
|
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_manager:
|
|
try:
|
|
pages_v3.plugin_manager.discover_plugins()
|
|
plugin_ids = sorted(
|
|
pi.get('id') for pi in pages_v3.plugin_manager.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_manager.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_manager:
|
|
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
|
|
|
try:
|
|
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
|
|
|
_plugin_dir = resolve_under(_plugins_base, safe_id)
|
|
if _plugin_dir is None:
|
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
|
|
|
# Mirror PluginManager's ledmatrix- prefix fallback.
|
|
if not _plugin_dir.exists():
|
|
_alt = resolve_under(_plugins_base, f'ledmatrix-{safe_id}')
|
|
if _alt is not None:
|
|
_plugin_dir = _alt
|
|
|
|
web_ui_path = resolve_under(_plugin_dir / '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'
|
|
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
|
'<link rel="stylesheet" '
|
|
'href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" '
|
|
'crossorigin="anonymous">\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 _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 = 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_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'}
|
|
|
|
# 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"""
|
|
try:
|
|
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)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_general_partial():
|
|
"""Load general settings partial"""
|
|
try:
|
|
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
|
|
return render_template('v3/partials/general.html',
|
|
main_config=main_config,
|
|
auto_update_status=auto_update_status)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_display_partial():
|
|
"""Load display settings partial"""
|
|
try:
|
|
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())
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_durations_partial():
|
|
"""Load rotation & durations partial.
|
|
|
|
Builds one duration entry per display mode of every enabled plugin
|
|
(falling back to the display controller's 30s default), overlaid with any
|
|
values saved in display.display_durations. Historically the template only
|
|
looped over saved keys, and nothing ever populated them, so the page
|
|
rendered empty.
|
|
"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config = pages_v3.config_manager.load_config()
|
|
duration_groups = []
|
|
covered_keys = set()
|
|
if pages_v3.plugin_manager:
|
|
try:
|
|
pages_v3.plugin_manager.discover_plugins()
|
|
saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {}
|
|
infos = sorted(pages_v3.plugin_manager.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_manager.get_plugin_display_modes(pid) or [pid]
|
|
covered_keys.update(modes)
|
|
duration_groups.append({
|
|
'plugin_id': pid,
|
|
'plugin_name': info.get('name') or pid,
|
|
'modes': [{'key': m, 'value': saved.get(m, 30)} 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)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_schedule_partial():
|
|
"""Load schedule settings partial"""
|
|
try:
|
|
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)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
|
|
def _load_plugins_partial():
|
|
"""Load plugins management partial"""
|
|
try:
|
|
import json
|
|
from pathlib import Path
|
|
|
|
# Load plugin data from the plugin system
|
|
plugins_data = []
|
|
|
|
# Get installed plugins if managers are available
|
|
if pages_v3.plugin_manager and pages_v3.plugin_store_manager:
|
|
try:
|
|
# Get all installed plugin info
|
|
all_plugin_info = pages_v3.plugin_manager.get_all_plugin_info()
|
|
|
|
# Load config once before the loop (not per-plugin)
|
|
full_config = pages_v3.config_manager.load_config() if pages_v3.config_manager else {}
|
|
|
|
# Format for the web interface
|
|
for plugin_info in all_plugin_info:
|
|
plugin_id = plugin_info.get('id')
|
|
|
|
# Re-read manifest from disk to ensure we have the latest metadata
|
|
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
|
if manifest_path.exists():
|
|
try:
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
|
fresh_manifest = json.load(f)
|
|
# Update plugin_info with fresh manifest data
|
|
plugin_info.update(fresh_manifest)
|
|
except Exception as e:
|
|
# If we can't read the fresh manifest, use the cached one
|
|
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
|
|
|
# Get enabled status from config (source of truth)
|
|
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
|
enabled = None
|
|
if pages_v3.config_manager:
|
|
plugin_config = full_config.get(plugin_id, {})
|
|
# Check if 'enabled' key exists in config (even if False)
|
|
if 'enabled' in plugin_config:
|
|
enabled = bool(plugin_config['enabled'])
|
|
|
|
# Fallback to plugin instance if config doesn't have enabled key
|
|
if enabled is None:
|
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
|
if plugin_instance:
|
|
enabled = plugin_instance.enabled
|
|
else:
|
|
# Default to True if no config key and plugin not loaded (matches BasePlugin default)
|
|
enabled = True
|
|
|
|
# Get verified status from store registry (no GitHub API calls needed)
|
|
store_info = pages_v3.plugin_store_manager.get_registry_info(plugin_id)
|
|
verified = store_info.get('verified', False) if store_info else False
|
|
|
|
last_updated = plugin_info.get('last_updated')
|
|
last_commit = plugin_info.get('last_commit') or plugin_info.get('last_commit_sha')
|
|
branch = plugin_info.get('branch')
|
|
|
|
if store_info:
|
|
last_updated = last_updated or store_info.get('last_updated') or store_info.get('last_updated_iso')
|
|
last_commit = last_commit or store_info.get('last_commit') or store_info.get('last_commit_sha')
|
|
branch = branch or store_info.get('branch') or store_info.get('default_branch')
|
|
|
|
plugins_data.append({
|
|
'id': plugin_id,
|
|
'name': plugin_info.get('name', plugin_id),
|
|
'author': plugin_info.get('author', 'Unknown'),
|
|
'category': plugin_info.get('category', 'General'),
|
|
'description': plugin_info.get('description', 'No description available'),
|
|
'tags': plugin_info.get('tags', []),
|
|
'enabled': enabled,
|
|
'verified': verified,
|
|
'loaded': plugin_info.get('loaded', False),
|
|
'last_updated': last_updated,
|
|
'last_commit': last_commit,
|
|
'branch': branch
|
|
})
|
|
except Exception as e:
|
|
logger.error("Error loading plugin data", exc_info=True)
|
|
|
|
return render_template('v3/partials/plugins.html',
|
|
plugins=plugins_data)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_fonts_partial():
|
|
"""Load fonts management partial"""
|
|
try:
|
|
# This would load font data from the font system
|
|
fonts_data = {} # Placeholder for font data
|
|
return render_template('v3/partials/fonts.html',
|
|
fonts=fonts_data)
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_logs_partial():
|
|
"""Load logs viewer partial"""
|
|
try:
|
|
return render_template('v3/partials/logs.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_raw_json_partial():
|
|
"""Load raw JSON editor partial"""
|
|
try:
|
|
if pages_v3.config_manager:
|
|
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
|
secrets_config_data = 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())
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_backup_restore_partial():
|
|
"""Load backup & restore partial."""
|
|
try:
|
|
return render_template('v3/partials/backup_restore.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
@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"""
|
|
try:
|
|
return render_template('v3/partials/wifi.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_cache_partial():
|
|
"""Load cache management partial"""
|
|
try:
|
|
return render_template('v3/partials/cache.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
def _load_operation_history_partial():
|
|
"""Load operation history partial"""
|
|
try:
|
|
return render_template('v3/partials/operation_history.html')
|
|
except Exception as e:
|
|
logger.error("Error loading partial", exc_info=True)
|
|
return "Error loading partial", 500
|
|
|
|
|
|
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
|
|
|
|
|
|
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_manager:
|
|
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_manager.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_manager.get_plugin_info(plugin_id)
|
|
|
|
# If not found, re-discover plugins (handles plugins added after startup)
|
|
if not plugin_info:
|
|
pages_v3.plugin_manager.discover_plugins()
|
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
|
|
|
if not plugin_info:
|
|
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
|
|
|
# Get plugin instance (may be None if not loaded)
|
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
|
|
|
# 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)
|
|
|
|
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
|
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
|
|
config = mask_secret_fields(config, schema_properties)
|
|
|
|
# Determine enabled status
|
|
enabled = config.get('enabled', True)
|
|
if plugin_instance:
|
|
enabled = plugin_instance.enabled
|
|
|
|
# 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 as e:
|
|
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:
|
|
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
|
|
|
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 as e:
|
|
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
|