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>
This commit is contained in:
Chuck
2026-09-28 10:42:07 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c00bf5e8e6
commit 6cfcf2e384
23 changed files with 1300 additions and 426 deletions
+58 -18
View File
@@ -44,6 +44,7 @@ from src.plugin_system.operation_history import OperationHistory
_JOURNALCTL = shutil.which('journalctl')
_SYSTEMCTL = shutil.which('systemctl')
_NMCLI = shutil.which('nmcli')
_VCGENCMD = shutil.which('vcgencmd')
from web_interface import display_preview
@@ -288,6 +289,7 @@ except ImportError:
# (AP mode) or per SSE tick (display service). A failed check keeps the last
# known answer for the same TTL rather than retrying on every request.
from web_interface.cache import TTLCache
from src.wifi_manager import AP_PROFILE_NAME
_service_status_cache = TTLCache()
_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine
_LEDMATRIX_SERVICE_CACHE_TTL = 15 # seconds
@@ -319,12 +321,49 @@ def _unit_is_active(unit, ttl):
_service_status_cache.set(unit, active, ttl=ttl)
return active
def _nmcli_ap_is_active(ttl):
"""Whether NetworkManager has our access-point profile up, cached for
``ttl`` seconds.
WiFiManager.enable_ap_mode falls back to an nmcli AP when hostapd is not
available, and there is no hostapd unit running then. The match is
WiFiManager._get_ap_status_nmcli's: our profile name, or a connection of
a hotspot type. False where there is no nmcli; on a failed check, the
last known answer.
"""
active = _service_status_cache.get('nmcli-ap')
if active is not None:
return active
active = _service_status_cache.peek('nmcli-ap', False)
if _NMCLI:
try:
result = subprocess.run( # nosec B603 - fixed argv # nosemgrep
[_NMCLI, '-t', '-f', 'NAME,TYPE', 'connection', 'show', '--active'],
capture_output=True, text=True, timeout=2)
active = False
for line in result.stdout.splitlines():
parts = line.split(':')
if len(parts) < 2:
continue
if parts[0].strip() == AP_PROFILE_NAME or 'hotspot' in parts[1].strip().lower():
active = True
break
except (subprocess.SubprocessError, OSError) as e:
logging.getLogger('web_interface').warning(
"nmcli active-connection check failed: %s", e)
_service_status_cache.set('nmcli-ap', active, ttl=ttl)
return active
def is_ap_mode_active():
"""
Check if access point mode is currently active (cached, 30s TTL).
Uses a direct systemctl check instead of instantiating WiFiManager.
Uses direct systemctl/nmcli checks instead of instantiating WiFiManager,
and the same two WiFiManager._is_ap_mode_active uses: hostapd, else the
nmcli AP it falls back to. Checking hostapd alone meant the captive
portal never triggered on a Pi whose AP came up through nmcli.
"""
return _unit_is_active('hostapd', _AP_MODE_CACHE_TTL)
return (_unit_is_active('hostapd', _AP_MODE_CACHE_TTL)
or _nmcli_ap_is_active(_AP_MODE_CACHE_TTL))
# Captive portal detection endpoints
# When AP mode is active, return responses that TRIGGER the captive portal popup.
@@ -377,7 +416,6 @@ def not_found_error(error):
@app.errorhandler(500)
def internal_error(error):
"""Handle 500 errors."""
import logging
logger = logging.getLogger('web_interface')
logger.error("Internal server error", exc_info=True)
payload = {
@@ -416,7 +454,6 @@ def handle_exception(error):
'message': error.description,
}), error.code or 500
import logging
logger = logging.getLogger('web_interface')
logger.error("Unhandled exception", exc_info=True)
return jsonify({
@@ -620,6 +657,11 @@ class _StreamBroadcaster:
if not self._clients:
# No subscribers — exit so the thread doesn't spin indefinitely.
# subscribe() will restart it when a new client arrives.
# Drop the handle here, under the lock: between this break
# and the thread actually ending (closing the generator
# can take a while) is_alive() is still True, and a
# client subscribing then got no thread at all.
self._thread = None
break
for q in self._clients:
try:
@@ -870,8 +912,7 @@ def favicon():
return '', 204
_reconciliation_started = False
import threading as _threading
_reconciliation_lock = _threading.Lock()
_reconciliation_lock = threading.Lock()
def _run_startup_reconciliation() -> None:
"""Run state reconciliation in background to auto-repair missing plugins.
@@ -911,7 +952,7 @@ def _run_startup_reconciliation() -> None:
# Write status file so the web UI can surface unresolved issues as a
# banner without the user having to read journalctl. Mirrors the
# hw_status pattern (/tmp/led_matrix_hw_status.json).
import json as _json, tempfile as _tempfile, os as _os
import tempfile
_recon_status = {
"done": True,
"successful": result.reconciliation_successful,
@@ -925,21 +966,21 @@ def _run_startup_reconciliation() -> None:
for inc in result.inconsistencies_manual
],
}
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
_tmp = None
try:
if not _os.path.islink(_recon_path):
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
with _os.fdopen(_fd, "w") as _f:
_json.dump(_recon_status, _f)
_os.replace(_tmp, _recon_path)
if not os.path.islink(_recon_path):
_fd, _tmp = tempfile.mkstemp(dir=tempfile.gettempdir(), prefix=".led_recon_")
with os.fdopen(_fd, "w") as _f:
json.dump(_recon_status, _f)
os.replace(_tmp, _recon_path)
_tmp = None # Rename succeeded; nothing to clean up
except (OSError, ValueError, TypeError) as _e:
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
finally:
if _tmp is not None and _os.path.exists(_tmp):
if _tmp is not None and os.path.exists(_tmp):
try:
_os.unlink(_tmp)
os.unlink(_tmp)
except OSError:
pass
except Exception as e:
@@ -953,7 +994,7 @@ def start_startup_reconciliation():
with _reconciliation_lock:
if not _reconciliation_started:
_reconciliation_started = True
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
_auto_updater = None
@@ -983,10 +1024,9 @@ def start_auto_update_scheduler():
if __name__ == '__main__':
import os as _os
start_auto_update_scheduler()
# threaded=True is Flask's default since 1.0 but stated explicitly so that
# long-lived /api/v3/stream/* SSE connections don't starve other requests.
# Debug mode is off by default; opt in with FLASK_DEBUG=1 in the environment.
_debug = _os.environ.get('FLASK_DEBUG', '0') == '1'
_debug = os.environ.get('FLASK_DEBUG', '0') == '1'
app.run(host='0.0.0.0', port=5000, debug=_debug, threaded=True) # nosec B104 - intentional; local network device
+14 -5
View File
@@ -170,9 +170,16 @@ def _get_plugin_version(plugin_id: str) -> str:
that arrived in a request body, so the name is validated here rather
than relying on each of them to have done it.
"""
manifest_path = resolve_under(
api_v3.plugin_store_manager.plugins_dir, plugin_id, "manifest.json"
)
# The resolver first: a plugin installed as ledmatrix-<id>, or whose
# directory is named differently from its manifest id, is not at
# plugins_dir/<id>, and recording '' as its version hid that it worked.
plugin_dir = _plugin_directory(plugin_id)
if plugin_dir is not None:
manifest_path = plugin_dir / "manifest.json"
else:
manifest_path = resolve_under(
api_v3.plugin_store_manager.plugins_dir, plugin_id, "manifest.json"
)
if manifest_path is None:
logger.warning("[PluginVersion] Rejected unsafe plugin id %r", plugin_id)
return ''
@@ -1420,9 +1427,11 @@ def _plugin_directory(plugin_id: str) -> Optional[Path]:
is no fallback to the legacy plugins/ directory: the loader never scans
it, so a plugin found only there is one that never runs.
"""
if not api_v3.plugin_manager:
# getattr: the blueprint only has plugin_manager once the app has set it.
manager = getattr(api_v3, 'plugin_manager', None)
if not manager:
return None
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
plugin_dir = manager.get_plugin_directory(plugin_id)
if not plugin_dir or not Path(plugin_dir).exists():
return None
return Path(plugin_dir)
+138 -154
View File
@@ -2,6 +2,10 @@
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names are unchanged by living here.
No route here catches Exception: the blueprint's handler in `__init__`
logs it and answers 500 with the same `status`/`message` fields the
backup page reads (backup_restore.html), plus `details`.
"""
from web_interface.blueprints.api_v3 import (
PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3,
@@ -17,78 +21,62 @@ import web_interface.blueprints.api_v3 as _pkg
@api_v3.route('/backup/preview', methods=['GET'])
def backup_preview():
"""Return a summary of what a new backup would include."""
try:
from src.backup_manager import preview_backup_contents
data = preview_backup_contents(PROJECT_ROOT)
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_preview failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
from src.backup_manager import preview_backup_contents
data = preview_backup_contents(PROJECT_ROOT)
return jsonify({'status': 'success', 'data': data})
@api_v3.route('/backup/list', methods=['GET'])
def backup_list():
"""List backup ZIPs stored in the export directory."""
try:
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file() or p.suffix != '.zip':
continue
st = p.stat()
entries.append({
'filename': p.name,
'size': st.st_size,
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
})
return jsonify({'status': 'success', 'data': entries})
except Exception as e:
logger.error("backup_list failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
_pkg._BACKUP_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
entries = []
for p in sorted(_pkg._BACKUP_EXPORT_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file() or p.suffix != '.zip':
continue
st = p.stat()
entries.append({
'filename': p.name,
'size': st.st_size,
'created_at': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
})
return jsonify({'status': 'success', 'data': entries})
@api_v3.route('/backup/export', methods=['POST'])
def backup_export():
"""Create a new backup ZIP and return its filename."""
try:
from src.backup_manager import create_backup
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
return jsonify({'status': 'success', 'filename': zip_path.name})
except Exception as e:
logger.error("backup_export failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
from src.backup_manager import create_backup
zip_path = create_backup(PROJECT_ROOT, output_dir=_pkg._BACKUP_EXPORT_DIR)
return jsonify({'status': 'success', 'filename': zip_path.name})
@api_v3.route('/backup/validate', methods=['POST'])
def backup_validate():
"""Validate an uploaded backup ZIP and return its manifest."""
from src.backup_manager import validate_backup
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
from src.backup_manager import validate_backup
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
ok, err_msg, manifest = validate_backup(Path(tmp_path))
finally:
try:
ok, err_msg, manifest = validate_backup(Path(tmp_path))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if not ok:
logger.warning("Backup validation failed: %s", err_msg)
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
safe_manifest = {
'schema_version': manifest.get('schema_version'),
'created_at': manifest.get('created_at'),
'ledmatrix_version': manifest.get('ledmatrix_version'),
'hostname': manifest.get('hostname'),
'contents': manifest.get('contents', []),
'detected_contents': manifest.get('detected_contents', []),
'plugins': manifest.get('plugins', []),
'total_uncompressed': manifest.get('total_uncompressed'),
'file_count': manifest.get('file_count'),
}
return jsonify({'status': 'success', 'data': safe_manifest})
except Exception as e:
logger.error("backup_validate failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
os.unlink(tmp_path)
except OSError:
pass
if not ok:
logger.warning("Backup validation failed: %s", err_msg)
return jsonify({'status': 'error', 'message': 'Invalid or corrupted backup file'}), 400
safe_manifest = {
'schema_version': manifest.get('schema_version'),
'created_at': manifest.get('created_at'),
'ledmatrix_version': manifest.get('ledmatrix_version'),
'hostname': manifest.get('hostname'),
'contents': manifest.get('contents', []),
'detected_contents': manifest.get('detected_contents', []),
'plugins': manifest.get('plugins', []),
'total_uncompressed': manifest.get('total_uncompressed'),
'file_count': manifest.get('file_count'),
}
return jsonify({'status': 'success', 'data': safe_manifest})
#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g.
#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(),
#: leaving that flag at its True default -- restoring secrets a caller's
@@ -100,105 +88,101 @@ _RESTORE_OPTION_KEYS = frozenset((
@api_v3.route('/backup/restore', methods=['POST'])
def backup_restore():
"""Restore a backup ZIP with optional RestoreOptions."""
from src.backup_manager import restore_backup, RestoreOptions
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
options_raw = request.form.get('options', '{}')
try:
from src.backup_manager import restore_backup, RestoreOptions
if 'backup_file' not in request.files:
return jsonify({'status': 'error', 'message': 'No backup_file in request'}), 400
f = request.files['backup_file']
options_raw = request.form.get('options', '{}')
opts_dict = json.loads(options_raw)
except json.JSONDecodeError:
opts_dict = None
if not isinstance(opts_dict, dict):
# Every option defaults to True, so falling back to {} on a
# parse failure would silently perform a FULL restore —
# secrets and all — for a caller who asked for a narrow one
# and mis-serialized it. Refuse instead of guessing.
return jsonify({
'status': 'error',
'message': 'Invalid options: expected a JSON object',
}), 400
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
if unknown_keys:
return jsonify({
'status': 'error',
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
}), 400
# _coerce_to_bool (not bare bool()) because a request can send these
# as JSON strings: bool("false") is True in Python, so a caller who
# explicitly asked to skip secrets would have had them restored
# anyway.
options = RestoreOptions(
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
)
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
finally:
try:
opts_dict = json.loads(options_raw)
except json.JSONDecodeError:
opts_dict = None
if not isinstance(opts_dict, dict):
# Every option defaults to True, so falling back to {} on a
# parse failure would silently perform a FULL restore —
# secrets and all — for a caller who asked for a narrow one
# and mis-serialized it. Refuse instead of guessing.
return jsonify({
'status': 'error',
'message': 'Invalid options: expected a JSON object',
}), 400
unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS
if unknown_keys:
return jsonify({
'status': 'error',
'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}',
}), 400
# _coerce_to_bool (not bare bool()) because a request can send these
# as JSON strings: bool("false") is True in Python, so a caller who
# explicitly asked to skip secrets would have had them restored
# anyway.
options = RestoreOptions(
restore_config=_coerce_to_bool(opts_dict.get('restore_config', True)),
restore_secrets=_coerce_to_bool(opts_dict.get('restore_secrets', True)),
restore_wifi=_coerce_to_bool(opts_dict.get('restore_wifi', True)),
restore_fonts=_coerce_to_bool(opts_dict.get('restore_fonts', True)),
restore_plugin_uploads=_coerce_to_bool(opts_dict.get('restore_plugin_uploads', True)),
reinstall_plugins=_coerce_to_bool(opts_dict.get('reinstall_plugins', True)),
)
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
f.save(tmp_path)
try:
result = restore_backup(Path(tmp_path), PROJECT_ROOT, options)
finally:
os.unlink(tmp_path)
except OSError:
pass
# Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install:
psm = getattr(api_v3, 'plugin_store_manager', None)
for plug in result.plugins_to_install:
pid = plug.get('plugin_id')
if not pid:
continue
try:
os.unlink(tmp_path)
except OSError:
pass
# Reinstall plugins if requested and store manager available
if options.reinstall_plugins and result.plugins_to_install:
psm = getattr(api_v3, 'plugin_store_manager', None)
for plug in result.plugins_to_install:
pid = plug.get('plugin_id')
if not pid:
continue
try:
if psm and hasattr(psm, 'install_plugin'):
ok = psm.install_plugin(pid)
if ok:
result.plugins_installed.append(pid)
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
if psm and hasattr(psm, 'install_plugin'):
ok = psm.install_plugin(pid)
if ok:
result.plugins_installed.append(pid)
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
except Exception as pe:
logger.error(
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
)
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
result.plugins_failed.append({'plugin_id': pid, 'error': 'install_plugin returned False'})
else:
result.plugins_failed.append({'plugin_id': pid, 'error': 'Store manager unavailable'})
except Exception as pe:
logger.error(
"[Backup] Failed to reinstall plugin %r: %s", pid, pe, exc_info=True
)
result.plugins_failed.append({'plugin_id': pid, 'error': 'Installation failed; see server logs'})
# A restore that dropped files can still report success if the only
# failures were plugin reinstalls, since those don't touch result.errors.
if result.plugins_failed:
result.success = False
# A restore that dropped files can still report success if the only
# failures were plugin reinstalls, since those don't touch result.errors.
if result.plugins_failed:
result.success = False
data = result.to_dict()
if not result.success:
# Name what failed, and what nonetheless landed. A restore is
# partial far more often than it is total -- a fresh install can
# leave config_secrets.json unwritable by the web service, so
# config restores and secrets do not. "Restore had errors" alone
# left the user unable to tell a wholly failed restore from one
# that quietly dropped their API keys.
failed_plugins = [
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
]
parts = []
if result.restored:
parts.append(f"restored: {', '.join(result.restored)}")
if result.errors:
parts.append(f"failed: {'; '.join(result.errors)}")
if failed_plugins:
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_restore failed: %s", e, exc_info=True)
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
data = result.to_dict()
if not result.success:
# Name what failed, and what nonetheless landed. A restore is
# partial far more often than it is total -- a fresh install can
# leave config_secrets.json unwritable by the web service, so
# config restores and secrets do not. "Restore had errors" alone
# left the user unable to tell a wholly failed restore from one
# that quietly dropped their API keys.
failed_plugins = [
str(p.get('plugin_id')) for p in (result.plugins_failed or []) if p.get('plugin_id')
]
parts = []
if result.restored:
parts.append(f"restored: {', '.join(result.restored)}")
if result.errors:
parts.append(f"failed: {'; '.join(result.errors)}")
if failed_plugins:
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
return jsonify({'status': 'success', 'data': data})
@api_v3.route('/backup/download/<path:filename>', methods=['GET'])
def backup_download(filename):
"""Stream a backup ZIP to the browser."""
+23 -37
View File
@@ -1150,6 +1150,27 @@ def get_secrets_config():
# alone so a client can still tell "set" from "not set".
return jsonify({'status': 'success',
'data': mask_all_secret_values(config)})
def _raw_config_save_error(e):
"""The 500 both raw-config save routes answer a failed save with.
A ConfigError that names its file says which one; the rest is the same
for every failure. raw_json.html reads only ``message``, which this
keeps, alongside ``status`` and ``details`` as before.
"""
from src.exceptions import ConfigError
error_message = 'An error occurred; see logs for details'
config_path = getattr(e, 'config_path', None) if isinstance(e, ConfigError) else None
if config_path:
error_message = f"{error_message} (config_path: {config_path})"
return error_response(
ErrorCode.CONFIG_SAVE_FAILED if isinstance(e, ConfigError) else ErrorCode.UNKNOWN_ERROR,
error_message,
details=describe_exception(e),
context={'config_path': config_path} if config_path else None,
status_code=500
)
@api_v3.route('/config/raw/main', methods=['POST'])
def save_raw_main_config():
"""Save raw main configuration JSON"""
@@ -1193,31 +1214,8 @@ def save_raw_main_config():
logger.warning("Automatic update setup could not be started", exc_info=True)
return jsonify({'status': 'success', 'message': message})
except Exception as e:
from src.exceptions import ConfigError
logger.error("Error saving raw main config", exc_info=True)
# Extract more specific error message if it's a ConfigError
if isinstance(e, ConfigError):
error_message = 'An error occurred; see logs for details'
if hasattr(e, 'config_path') and e.config_path:
error_message = f"{error_message} (config_path: {e.config_path})"
return error_response(
ErrorCode.CONFIG_SAVE_FAILED,
error_message,
details=describe_exception(e),
context={'config_path': e.config_path} if hasattr(e, 'config_path') and e.config_path else None,
status_code=500
)
else:
error_message = 'An error occurred; see logs for details'
return error_response(
ErrorCode.UNKNOWN_ERROR,
error_message,
details=describe_exception(e),
status_code=500
)
return _raw_config_save_error(e)
@api_v3.route('/config/raw/secrets', methods=['POST'])
def save_raw_secrets_config():
"""Save raw secrets configuration JSON"""
@@ -1257,17 +1255,5 @@ def save_raw_secrets_config():
return jsonify({'status': 'success', 'message': 'Secrets configuration saved successfully'})
except Exception as e:
from src.exceptions import ConfigError
logger.error("Error saving raw secrets config", exc_info=True)
# Extract more specific error message if it's a ConfigError
if isinstance(e, ConfigError):
# ConfigError has a message attribute and may have context
error_message = 'An error occurred; see logs for details'
if hasattr(e, 'config_path') and e.config_path:
error_message = f"{error_message} (config_path: {e.config_path})"
else:
error_message = 'An error occurred; see logs for details'
return jsonify({'status': 'error', 'message': error_message,
'details': describe_exception(e)}), 500
return _raw_config_save_error(e)
+5 -3
View File
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names are unchanged by living here.
"""
from web_interface.blueprints.api_v3 import (
_ensure_display_service_running,
_coerce_to_bool, _ensure_display_service_running,
_get_display_service_status, _stop_display_service, api_v3,
jsonify, logger, request, uuid,
)
@@ -151,8 +151,10 @@ def start_on_demand_display():
plugin_id = data.get('plugin_id')
mode = data.get('mode')
duration = data.get('duration')
pinned = bool(data.get('pinned', False))
start_service = data.get('start_service', True)
# _coerce_to_bool: bool("false") is True, so a string "false" pinned the
# mode or (re)started the service it asked to leave alone.
pinned = _coerce_to_bool(data.get('pinned', False))
start_service = _coerce_to_bool(data.get('start_service', True))
if not plugin_id and not mode:
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
+35 -14
View File
@@ -309,13 +309,30 @@ def get_font_preview() -> tuple[Response, int] | Response:
# Load font
font = None
if str(font_path).endswith('.bdf'):
# BDF fonts require complex per-glyph rendering via freetype
# Return explicit error rather than showing misleading preview with default font
return jsonify({
'status': 'error',
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
}), 400
bdf_face = None
lines = text.split('\n')
line_height = 0
if font_path.suffix.lower() == '.bdf':
# PIL cannot draw a BDF strike, so it goes through the same loader
# and glyph rasterizer the panel uses (src/common/bdf_font.py). The
# face comes back at the file's native size when it has no strike at
# the one asked for -- which is also what the panel would draw.
from src.common.bdf_font import load_bdf_face, draw_bdf_text
try:
bdf_face, realised_px = load_bdf_face(str(font_path), size)
except Exception as e:
logger.warning("[FontPreview] Failed to load BDF font %s: %s", font_path, e)
return jsonify({'status': 'error', 'message': 'Could not load BDF font'}), 400
line_height = (bdf_face.size.height >> 6) or realised_px
line_widths = []
for line in lines:
width = 0
for char in line:
bdf_face.load_char(char)
width += bdf_face.glyph.advance.x >> 6
line_widths.append(width)
text_width = max(line_widths)
text_height = line_height * len(lines)
else:
# TTF/OTF fonts
try:
@@ -325,12 +342,12 @@ def get_font_preview() -> tuple[Response, int] | Response:
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
font = ImageFont.load_default()
# Calculate text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Calculate text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create image with padding
padding = 10
@@ -350,7 +367,11 @@ def get_font_preview() -> tuple[Response, int] | Response:
x = (img_width - text_width) // 2
y = (img_height - text_height) // 2
draw.text((x, y), text, font=font, fill=fg_rgb)
if bdf_face is not None:
for index, line in enumerate(lines):
draw_bdf_text(draw, line, x, y + index * line_height, bdf_face, color=fg_rgb)
else:
draw.text((x, y), text, font=font, fill=fg_rgb)
# Convert to base64
buffer = io.BytesIO()
+99 -56
View File
@@ -26,7 +26,9 @@ from src.common.path_safety import (
)
from src.web_interface.config_arrays import coerce_array_shapes
from src.web_interface.validators import dedup_unique_arrays
from src.config_manager_atomic import atomic_write_text
import web_interface.blueprints.api_v3 as _pkg
from typing import Optional
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
@@ -70,9 +72,12 @@ def get_installed_plugins():
plugin_state = state_info.get('state')
plugin_error_info = state_info.get('error_info')
# Re-read manifest from disk to ensure we have the latest metadata
manifest_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
if manifest_path.exists():
# Re-read manifest from disk to ensure we have the latest metadata.
# Through the resolver, not plugins_dir/<id>: a plugin installed as
# ledmatrix-<id> otherwise never had its manifest refreshed here.
plugin_path = _plugin_directory(plugin_id)
manifest_path = plugin_path / "manifest.json" if plugin_path else None
if manifest_path is not None and manifest_path.exists():
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
fresh_manifest = json.load(f)
@@ -103,8 +108,7 @@ def get_installed_plugins():
update_available = _is_plugin_update_available(installed_version, latest_version)
# Local git info (single subprocess on cache miss, zero on hit)
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path.exists() else None
local_git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path) if plugin_path else None
if local_git_info:
sha = local_git_info.get('sha', '')
@@ -411,7 +415,10 @@ def toggle_plugin():
if not data or 'plugin_id' not in data or 'enabled' not in data:
return jsonify({'status': 'error', 'message': 'plugin_id and enabled required'}), 400
plugin_id = data['plugin_id']
enabled = data['enabled']
# Coerced, not stored raw: "false" is a truthy string, and this
# value is written to config.json and handed to the Starlark
# toggle, so {"enabled": "false"} used to switch a plugin ON.
enabled = _coerce_to_bool(data['enabled'])
else:
# Form data or query string (HTMX submission)
plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id')
@@ -863,6 +870,22 @@ def get_plugin_config():
return success_response(data=plugin_config)
except Exception as e:
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
"""The entry of ``base`` called ``name``, or None.
The path returned comes from listing ``base``, not from joining ``name``
onto it, so a caller that validated ``name`` doesn't have to rely on that
validation alone: nothing reaches the filesystem unless it's already there.
"""
try:
for entry in base.iterdir():
if entry.name == name:
return entry
except OSError:
pass
return None
@api_v3.route('/plugins/update', methods=['POST'])
def update_plugin():
"""Update plugin"""
@@ -922,22 +945,31 @@ def update_plugin():
# Always do direct updates (they're fast git pull operations)
# Operation queue is reserved for longer operations like install/uninstall
plugins_base = Path(api_v3.plugin_store_manager.plugins_dir)
plugin_dir = plugins_base / plugin_id
manifest_path = resolve_under(plugin_dir, "manifest.json")
if manifest_path is None:
return error_response(
ErrorCode.INVALID_INPUT,
'Invalid plugin_id',
status_code=400
)
# The resolver finds a plugin installed as ledmatrix-<id>. Either way
# the directory used is taken from a listing of plugins_dir, matched by
# name, never built from the request value -- so no path here depends
# on user input. None means nothing by that name is installed; the
# store manager still gets the id and reports that itself.
resolved = _plugin_directory(plugin_id)
plugin_dir = _listed_plugin_dir(
Path(api_v3.plugin_store_manager.plugins_dir),
resolved.name if resolved else plugin_id)
manifest_path = None
if plugin_dir is not None:
manifest_path = resolve_under(plugin_dir, "manifest.json")
if manifest_path is None:
return error_response(
ErrorCode.INVALID_INPUT,
'Invalid plugin_id',
status_code=400
)
current_last_updated = None
current_version = None
current_commit = None
current_branch = None
if manifest_path.exists():
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)
@@ -958,22 +990,12 @@ def update_plugin():
except Exception as e:
logger.debug("Could not read local manifest for plugin: %s", e)
if api_v3.plugin_store_manager:
git_info_before = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if git_info_before:
current_commit = git_info_before.get('sha')
current_branch = git_info_before.get('branch')
# Check if plugin is a git repo first (for better error messages)
# plugin_id is validated above; reuse the same directory rather
# than rebuilding it from a value that might not match.
plugin_path_dir = plugin_dir
is_git_repo = False
if plugin_path_dir.exists():
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
is_git_repo = git_info is not None
if is_git_repo:
logger.debug("Plugin is a git repository, will update via git pull")
git_info_before = (api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if plugin_dir is not None else None)
if git_info_before:
current_commit = git_info_before.get('sha')
current_branch = git_info_before.get('branch')
logger.debug("Plugin is a git repository, will update via git pull")
remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)
remote_commit = remote_info.get('last_commit_sha') if remote_info else None
@@ -986,7 +1008,7 @@ def update_plugin():
updated_last_updated = current_last_updated
updated_version = current_version
try:
if manifest_path.exists():
if manifest_path is not None and manifest_path.exists():
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
updated_last_updated = manifest.get('last_updated', current_last_updated)
@@ -996,11 +1018,11 @@ def update_plugin():
updated_commit = None
updated_branch = remote_branch or current_branch
if api_v3.plugin_store_manager:
git_info_after = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if git_info_after:
updated_commit = git_info_after.get('sha')
updated_branch = git_info_after.get('branch') or updated_branch
git_info_after = (api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if plugin_dir is not None else None)
if git_info_after:
updated_commit = git_info_after.get('sha')
updated_branch = git_info_after.get('branch') or updated_branch
# update_plugin() answers True for "nothing to do" as well as for
# a real update (a ZIP-installed monorepo plugin already at the
@@ -1069,11 +1091,10 @@ def update_plugin():
message=message
)
else:
plugin_path_dir = plugin_dir
if not plugin_path_dir.exists():
if plugin_dir is None or not plugin_dir.exists():
client_msg = 'Plugin update failed: plugin not found'
else:
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_dir)
if not git_info:
plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)
if not plugin_info:
@@ -2759,6 +2780,16 @@ def _plugin_uploads_dir(plugin_id):
return resolve_under(PROJECT_ROOT / 'assets' / 'plugins', plugin_id, 'uploads')
def _write_upload_metadata(metadata_file, metadata):
"""Replace an uploads directory's .metadata.json atomically.
The plugin config page reads this file to list a plugin's images; one cut
off mid-write (a power loss on a Pi) failed to parse, and every upload it
recorded dropped out of the list while the files stayed on disk.
"""
atomic_write_text(metadata_file, json.dumps(metadata, indent=2))
@api_v3.route('/plugins/assets/upload', methods=['POST'])
def upload_plugin_asset():
"""Upload asset files for a plugin"""
@@ -2804,6 +2835,11 @@ def upload_plugin_asset():
if 'size' in entry:
total_size += entry.get('size', 0)
# Every file is checked before any is saved. Checking and saving in one
# loop meant a bad third file answered 400 after the first two were
# already written -- on disk and in the metadata the UI lists, though
# the user was told the upload failed.
accepted = []
for file in files:
if not file.filename:
continue
@@ -2853,6 +2889,10 @@ def upload_plugin_asset():
'message': f'File {file.filename} is not a valid image file'
}), 400
total_size += file_size
accepted.append((file, file_ext, file_size, file_content))
for file, file_ext, file_size, file_content in accepted:
# Generate unique filename
timestamp = int(_pkg.time.time())
file_hash = hashlib.md5(file_content + file.filename.encode()).hexdigest()[:8]
@@ -2894,11 +2934,7 @@ def upload_plugin_asset():
'uploaded_at': metadata[image_id]['uploaded_at']
})
total_size += file_size
# Save metadata
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
_write_upload_metadata(metadata_file, metadata)
return jsonify({
'status': 'success',
@@ -3018,16 +3054,20 @@ def upload_calendar_credentials():
shutil.copy2(credentials_path, backup_path)
_prune_credential_backups(Path(plugin_dir))
# Save new file
file.save(str(credentials_path))
# Set proper permissions
os.chmod(credentials_path, 0o600) # Read/write for owner only
# Save new file: atomically, and created 0o600 (read/write for owner
# only) rather than chmod-ed after. file.save() truncated the old file
# first, so a failure mid-write left the plugin with a broken
# credentials.json, and the secret sat world-readable until the chmod.
atomic_write_text(credentials_path,
file_content.decode(json.detect_encoding(file_content)),
mode=0o600)
return jsonify({
'status': 'success',
'message': 'Credentials file uploaded successfully',
'path': str(credentials_path)
# Relative to the plugin: nothing reads this field, and the server's
# absolute layout is not the client's business.
'path': credentials_path.name
})
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
@@ -3170,7 +3210,12 @@ def list_calendar_calendars():
@api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset():
"""Delete an asset file for a plugin"""
data = request.get_json()
# silent=True: without it a missing or non-JSON body raised inside
# get_json() and came back as a 415 in the generic error shape, or, for
# a JSON array, an AttributeError 500.
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
plugin_id = data.get('plugin_id')
image_id = data.get('image_id')
@@ -3209,9 +3254,7 @@ def delete_plugin_asset():
# Remove from metadata
del metadata[image_id]
# Save metadata
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
_write_upload_metadata(metadata_file, metadata)
return jsonify({'status': 'success', 'message': 'Image deleted successfully'})
+3 -2
View File
@@ -305,8 +305,9 @@ def enable_ap_mode():
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
# The same parsing as every other boolean on these routes. Anything it
# does not recognise (None) is not a request to force.
force = _parse_bool_ish((request.get_json(silent=True) or {}).get('force', False)) is True
success, message = wifi_manager.enable_ap_mode(force=force)
if success:
+32 -85
View File
@@ -320,13 +320,31 @@ def serve_plugin_web_ui(plugin_id, filename):
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
def _resolved_plugin_dir(plugin_id):
"""The plugin manager's answer for where ``plugin_id`` lives, or None.
Its discovery map is authoritative (a plugin whose directory name is not
its id is found there), and it refuses anything that is not one plain
path segment. See src/plugin_system/plugin_dirs.py.
"""
found = pages_v3.plugin_manager.get_plugin_directory(plugin_id)
if isinstance(found, (str, Path)) and Path(found).exists():
return Path(found)
return None
def _plugin_dir_for(safe_id):
"""A sanitised plugin id's directory, which may not exist.
Contained under the configured plugins directory, with PluginManager's
``ledmatrix-`` prefix fallback. Raises ValueError for an id that would
leave it; the routes answer that with a 403.
The plugin manager's resolver first; otherwise contained under the
configured plugins directory, with PluginManager's ``ledmatrix-`` prefix
fallback. Raises ValueError for an id that would leave it; the routes
answer that with a 403.
"""
resolved = _resolved_plugin_dir(safe_id)
if resolved is not None:
return resolved
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
plugin_dir = resolve_under(plugins_base, safe_id)
if plugin_dir is None:
@@ -556,91 +574,15 @@ def _load_schedule_partial():
def _load_plugins_partial():
"""Load plugins management partial"""
# 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:
# 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:
logger.error("Error loading plugin data", exc_info=True)
return render_template('v3/partials/plugins.html',
plugins=plugins_data)
# plugins.html takes no plugin data: plugins_manager.js fetches the list
# from /api/v3/plugins/installed. Building it here read every manifest on
# disk on each Plugins-tab load for nothing.
return render_template('v3/partials/plugins.html')
def _load_fonts_partial():
"""Load fonts management partial"""
# 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)
# The page fetches its font data from /api/v3/fonts/* itself.
return render_template('v3/partials/fonts.html')
def _load_logs_partial():
"""Load logs viewer partial"""
@@ -749,6 +691,11 @@ def _load_plugin_config_partial(plugin_id):
if not plugin_info:
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
# The containment check above only validates the id. The files are
# read from wherever the plugin manager says the plugin lives, which
# for one installed as ledmatrix-<id> is not plugins_dir/<id>.
_plugin_dir = _resolved_plugin_dir(plugin_id) or _plugin_dir
# Get plugin instance (may be None if not loaded)
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
+16 -1
View File
@@ -17,6 +17,21 @@ logger = logging.getLogger('web_interface.start')
_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 = []
@@ -94,7 +109,7 @@ def main():
return
# For exceptions, check if it's a socket error
if 'exc_info' in kwargs and kwargs['exc_info']:
exc_type, exc_value, exc_tb = 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)
@@ -586,14 +586,6 @@ async function updateFontPreview() {
return;
}
// BDF bitmap fonts cannot be rendered server-side — skip the API call
if (family.toLowerCase().endsWith('.bdf')) {
previewImage.style.display = 'none';
loadingText.style.display = 'block';
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
return;
}
// Show loading state
loadingText.textContent = 'Loading preview...';
loadingText.style.display = 'block';