Files
LEDMatrix/web_interface/start.py
ChuckandClaude Opus 5.5 6cfcf2e384 fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety, BDF preview (#655)
* fix(web): plugin dir resolver in routes, nmcli AP detection, daemon config reload, upload safety

- Route plugin lookups (installed list, update, recorded version, config
  form, web UI pages) through the plugin manager's resolver so plugins in
  ledmatrix-<id> directories work.
- Captive-portal detection also sees the nmcli fallback AP (cached).
- WiFi monitor daemon re-reads wifi_config.json when its mtime changes.
- Drop the AP check in disconnect_from_network that could never fire.
- LED status file per WiFiManager; config path falls back to this checkout.
- BDF font preview via src.common.bdf_font.
- Asset uploads validate every file before saving; metadata and calendar
  credentials written atomically; no absolute path in the response;
  asset delete answers 400 for a missing body.
- Coerce string booleans in plugin toggle, on-demand start and AP force.
- SSE broadcaster clears its thread handle before exiting.
- start.py log filter handles every exc_info form.
- Cleanups: unused plugins/fonts partial work, duplicate backup catch-alls,
  raw-config error helper, update-route tidy, redundant imports.

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

* fix(web): request BDF font previews now that the server renders them

The Fonts tab skipped the preview request for .bdf files because the server
used to refuse them; /fonts/preview now draws BDF with the shared loader.

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

* fix(web): take the update route's plugin directory from a directory listing

CodeQL flagged the path built from the request's plugin_id (the id was
already validated with safe_path_component, which CodeQL doesn't model; the
same flow on main is alerts 738/739). The directory is now the entry of
plugins_dir matched by name, so nothing built from user input reaches the
filesystem; an id with nothing installed goes to the store manager, which
reports it not found as before.

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

* fix(web): read the blueprint's plugin_manager defensively in _plugin_directory

_get_plugin_version now goes through _plugin_directory, which read
api_v3.plugin_manager directly; the attribute exists only once the app sets
it, so test_path_traversal_guards::test_a_real_manifest_is_read failed
when run on its own (order-dependent in the full suite).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:07 -04:00

150 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""
LED Matrix Web Interface V3 Startup Script
Modern web interface with real-time display preview and plugin management.
"""
import os
import socket
import subprocess
import sys
import logging
from pathlib import Path
logger = logging.getLogger('web_interface.start')
# No route to host, broken pipe, connection reset: a client went away.
_CLIENT_DISCONNECT_ERRNOS = (113, 32, 104)
def _exc_info_value(exc_info):
"""The exception a logging call's ``exc_info`` argument refers to.
logging accepts a (type, value, traceback) tuple, an exception instance,
or True for the exception being handled. The werkzeug filter below used
to unpack it as a tuple, so True or an instance raised TypeError from
inside the logging call itself.
"""
if isinstance(exc_info, BaseException):
return exc_info
if isinstance(exc_info, tuple):
return exc_info[1] if len(exc_info) > 1 else None
return sys.exc_info()[1]
def get_local_ips():
"""Get list of local IP addresses the service will be accessible on."""
ips = []
# Check if AP mode is active
try:
result = subprocess.run(
["systemctl", "is-active", "hostapd"],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0 and result.stdout.strip() == "active":
ips.append("192.168.4.1 (AP Mode)")
except Exception: # nosec B110 - AP mode IP detection is non-critical startup info; systemctl may not exist
pass
# Get IPs from hostname -I
try:
result = subprocess.run(
["hostname", "-I"],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0:
for ip in result.stdout.strip().split():
ip = ip.strip()
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
ips.append(ip)
except Exception: # nosec B110 - hostname -I output parsing; non-critical startup info
pass
# Fallback: try socket method
if not ips:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
if ip and not ip.startswith("127."):
ips.append(ip)
finally:
s.close()
except Exception:
pass
return ips if ips else ["localhost"]
def main():
"""Main startup function."""
# Change to project root directory
project_root = Path(__file__).parent.parent
os.chdir(project_root)
# Add to Python path
sys.path.insert(0, str(project_root))
# Configure logging to suppress non-critical socket errors
# These occur when clients disconnect and are harmless
werkzeug_logger = logging.getLogger('werkzeug')
original_log_exception = werkzeug_logger.error
def log_exception_filtered(message, *args, **kwargs):
"""Filter out non-critical socket errors from werkzeug logs."""
if isinstance(message, str):
# Suppress "No route to host" and similar connection errors
if 'No route to host' in message or 'errno 113' in message:
# Log at debug level instead of error
werkzeug_logger.debug(message, *args, **kwargs)
return
# Suppress broken pipe errors (client disconnected)
if 'Broken pipe' in message or 'errno 32' in message:
werkzeug_logger.debug(message, *args, **kwargs)
return
# For exceptions, check if it's a socket error
if 'exc_info' in kwargs and kwargs['exc_info']:
exc_value = _exc_info_value(kwargs['exc_info'])
if isinstance(exc_value, OSError):
if exc_value.errno in _CLIENT_DISCONNECT_ERRNOS:
werkzeug_logger.debug(message, *args, **kwargs)
return
# Log everything else normally
original_log_exception(message, *args, **kwargs)
werkzeug_logger.error = log_exception_filtered
# Importing the app also sets up logging, so the lines below reach the
# journal through it.
from web_interface.app import app, start_auto_update_scheduler
start_auto_update_scheduler()
logger.info("Starting LED Matrix Web Interface V3, binding to 0.0.0.0:5000")
# get_local_ips() always returns at least "localhost".
logger.info("Access the interface at:")
for ip in get_local_ips():
if "AP Mode" in ip:
logger.info(" - http://192.168.4.1:5000 (AP Mode - connect to LEDMatrix-Setup WiFi)")
else:
logger.info(" - http://%s:5000", ip)
try:
# threaded=True is Flask's default since 1.0, but set it explicitly
# so it's self-documenting: the three /api/v3/stream/* SSE endpoints
# hold long-lived connections and would starve other requests under
# a single-threaded server.
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True)
except OSError as e:
if e.errno not in _CLIENT_DISCONNECT_ERRNOS:
raise
werkzeug_logger.debug("Client disconnected: %s", e, exc_info=True)
if __name__ == '__main__':
main()