Files
LEDMatrix/web_interface/blueprints/pages_v3.py
T
ChuckandClaude Opus 5 f6367d63ae security: triage the CodeQL backlog — 129 alerts, three of them live (#561)
* fix(web): escape quotes in every HTML escaper, not just & < >

The escapers are all `div.textContent = x; return div.innerHTML`. That
round-trip escapes &, < and > -- the only characters the HTML serializer
must escape in a text node -- and leaves quotes alone. Every widget then
interpolates the result into a quoted attribute value:

    value="${escapeHtml(v)}"   title="${escapeHtml(v)}"

so a value of `x" onmouseover="alert(1)` closes the attribute and adds an
event handler of its own. CodeQL reported this 83 times
(js/incomplete-html-attribute-sanitization) across the widget files.

It is one bug, not 83: the widgets each carry a standalone fallback that
did escape quotes, but they all prefer BaseWidget.escapeHtml when
window.BaseWidget exists -- which it always does in the shipped page -- so
the correct fallbacks were dead code and the incomplete shared one ran.
Fixed at each source instead of at the call sites.

app-shell.js already documented this exact gap in a comment and worked
around it by building DOM nodes by hand; that workaround stays (setting a
property cannot be got wrong), the comment is now accurate.

cache.html's delete button interpolated the cache key into
`onclick="deleteCacheFile('...')"`. Escaping cannot help there -- the
browser HTML-decodes the attribute before parsing it as JS, so `&#39;`
becomes a real `'` again -- so the key moves to a data-cache-key
attribute that the handler reads back.

url-input.js additionally wrote a value straight into an <a href> after
validating it against a schema-supplied protocol list, and that list
accepted any RFC 3986 scheme -- "javascript" included. Scriptable schemes
(javascript, data, vbscript, blob, filesystem) are now refused both when
the list is normalised and when a URL is checked against it, and the
render path routes its href through the same check instead of emitting
whatever was stored (js/xss-through-dom).

test/js/unit/test_html_escaping.js reads each escaper out of the shipped
file and runs it, so losing the quote handling again fails a test rather
than a scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): stop request-supplied names from reaching paths outside their base

Three of the py/path-injection alerts were live, not lint:

* GET /api/v3/plugins/<plugin_id>/static/<path:file_path> read any file
  whose resolved path *string-prefixed* the plugin directory. Flask's
  default converter forbids a slash but not dots, and
  get_plugin_directory('..') returned the parent of the plugins directory
  because it exists -- so every file under the project root then prefixed
  that directory, config/config_secrets.json included. The prefix check
  was also wrong on its own terms: with plugin dir "plugin-repos/foo",
  "../foo-evil/x" resolves to "plugin-repos/foo-evil/x", whose string does
  start with "plugin-repos/foo".

* POST /api/v3/plugins/of-the-day/json/delete interpolated the request
  body's file_id into f"{file_id}.json" and unlinked it, unvalidated. A
  file_id of "../../../../etc/something" deleted that file. This is the
  one finding in the batch that destroyed data rather than exposing it.

* POST /api/v3/cache/delete passed the body's key through
  CacheManager.clear_cache to DiskCache, which joined it as a filename and
  called os.remove. Same shape, same result. The guard goes in
  DiskCache.get_cache_path, the single choke point get/set/clear share, so
  every caller is covered rather than just this route. Real keys are the
  stems of files already flat in the cache directory -- that is how
  list_cache_files derives them -- so nothing legitimate is turned away.

The rest of the cluster (web_interface/app.py's asset route, the plugin
update handler, _get_plugin_version, the plugin-schema read in config.py)
was guarded in ways that held, but each had grown its own version of the
check. They now go through one helper, src/common/path_safety.py, which
returns the *sanitised value* rather than a verdict -- so a caller cannot
validate one string and open another, which is how the two real bugs
above were shaped.

Also: WiFiManager.connect_to_network took the SSID and password straight
from POST /api/v3/wifi/connect into nmcli's argv. There is no shell there,
so CodeQL's py/command-line-injection alert overstates the risk -- but
nmcli reads a leading "-" as an option, so an SSID of "--ask" asks nmcli
to run differently rather than to join a network. Both values are now
checked for shape (802.11's 32-octet SSID limit, WPA's 8-63 char
passphrase or 64-char hex key, no control characters, no leading dash)
before any subprocess runs.

test/test_path_traversal_guards.py asserts on the filesystem, not just
the status code: a handler that returns 403 and deletes the file anyway
would pass the weaker check. Twelve of its cases fail against the
unpatched code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): refuse a plugin id that is not a plain name, don't truncate it

pages_v3 and scripts/dev_server.py ran request ids through
os.path.basename and carried on with what came out, so "../weather"
rendered the config form for "weather". Nothing escaped the plugins
directory -- the relative_to guards held -- but the handler answered a
request nobody made, and validating one string while the filesystem sees
another is the shape both live traversals earlier in this branch had.

Same treatment as the rest: safe_path_component rejects rather than
truncates, resolve_under returns the path it checked, and the call sites
use what those return. The three handlers that had hand-rolled
resolve-and-relative_to blocks lose about twenty lines to the shared one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(web): say what the plugin web_ui iframe actually is

The docstring claimed the fragment runs "in a sandboxed iframe". The
iframe in plugin_config.html carries no sandbox attribute, so the
fragment runs with the interface's own origin. That is fine -- the file
belongs to an installed plugin, and an installed plugin already runs
Python on the device, so the trust boundary is install rather than this
route -- but a comment promising containment that is not there is worse
than no comment. This is the context for the py/reflective-xss alert on
this handler.

Also drops the now-unused os/os.path imports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): inline url-input's scheme guard at the previewLink.href sink

CodeQL flagged this line as a new high-severity js/xss-through-dom alert
on this PR even though it is already covered by SCRIPTABLE_SCHEMES: the
guard reached the sink through safeHref -> isValidUrl, two function calls
away, which its DOM-based-XSS sanitizer recognition does not trace.

Behavior is unchanged -- same scheme check, same SCRIPTABLE_SCHEMES list,
same allowedProtocols gate -- just inlined directly above the
previewLink.href assignment it guards, so the barrier is visible in the
same scope as the sink.

Added a regression test that runs the shipped onInput handler (not just
the extracted helpers) against a mocked DOM, so a future change that
reintroduces an unguarded previewLink.href assignment fails here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): address CodeRabbit findings on the CodeQL triage PR

- src/wifi_manager.py: reject non-ASCII WPA-PSK passphrases before any
  credential-saving or connect flow runs. NetworkManager only accepts
  printable ASCII passphrases (or a 64-char hex key); a non-ASCII value
  was previously saved/attempted before nmcli itself rejected it.

- web_interface/blueprints/api_v3/config.py: fail closed when the
  plugin config schema path can't be resolved under the plugins
  directory (e.g. a symlinked plugin dir). Previously this fell
  through with secret_fields left empty, so submitted credentials for
  that plugin were saved as ordinary, unencrypted configuration.

- web_interface/static/v3/js/widgets/plugin-file-manager.js: stop
  splicing the JSON day/column key into an inline oninput="..." handler
  string. escHtml() escapes quotes for a normal HTML attribute, but the
  browser HTML-decodes the attribute before running it as script, which
  undoes that escaping and lets a crafted column name (e.g. from an
  uploaded JSON file) break out of the JS string and execute. Cell
  edits now travel through data-day/data-col attributes read by one
  delegated 'input' listener instead.

  While in this file: fixed 6 pre-existing missing-')' typos on
  multi-line safeSetHTML(...) calls (already flagged by Biome in this
  PR's own CodeRabbit run as syntax errors blocking its lint pass).
  These predate this PR (present on main too) but made the whole file
  fail to parse in any JS engine, which is a bigger problem than the
  XSS finding itself and directly touches the same lines.

Added/extended regression tests for each fix; full suites pass
(pytest: 4580 passed, 62 skipped; JS: 84 assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:03 -04:00

856 lines
38 KiB
Python

from flask import Blueprint, render_template, flash, jsonify
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$')
from src.web_interface.secret_helpers import mask_secret_fields
from src.common.path_safety import resolve_under, safe_path_component
logger = logging.getLogger(__name__)
# Will be initialized when blueprint is registered
config_manager = None
plugin_manager = None
plugin_store_manager = None
pages_v3 = Blueprint('pages_v3', __name__)
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 _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()
return render_template('v3/partials/general.html',
main_config=main_config)
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)
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
schema = {}
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