mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): mask the Config Editor's secrets like GET /config/secrets The Config Editor tab (/partials/raw-json) filled its config_secrets.json editor with the file as it is on disk. GET /api/v3/config/secrets masks every value because the interface is reachable without a login by default, but this page handed the same credentials (GitHub token, Home Assistant token, plugin API keys) to anyone who loaded it. The masked-save path in save_raw_secrets_config was written for a masked editor and never got one. _load_raw_json_partial now masks the section with mask_all_secret_values after strip_auth_section, exactly as the GET does. Saving it back is safe: save_raw_secrets_config drops the masks (strip_masked_values) and merges the rest onto the stored file (deep_merge), so an untouched secret stays as it is and a replaced mask is the only value that changes. The config.json editor is left as it is. Its save (save_raw_main_config) writes the posted object verbatim, with no mask stripping or merge, so a masked main editor would write the bullets over any credential it holds. Masking it needs a merge-on-save of its own first. Tests: TestConfigEditorRoundTrip renders the partial over a real ConfigManager, checks no real value is in the editor, and posts the editor back unchanged (the file is identical) and with one mask replaced (only that value changes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep disabled plugins in the saved rotation order and Vegas exclusions PluginOrderList draws one row per enabled plugin and, once drawn, rewrites its hidden inputs (plugin_rotation_order, vegas_plugin_order, vegas_excluded_plugins) from those rows. A disabled plugin has no row, so merely opening the Display or Rotation & Durations tab took it out of the inputs, and the next save of that form stored the lists without it. Exclude Clock from Vegas, disable it, change the brightness, re-enable it: Clock was scrolling in Vegas again and had moved to the end of the rotation. syncInputs now keeps the saved ids that have no row. In the order, each one keeps its saved slot and the rows fill the other slots in their current order, with rows not in the saved order last, as before. In the exclusions they follow the unchecked rows. Only string ids are carried over, once each: /config/main refuses a list holding anything else, which would block every later save of the tab. Tests: test/js/unit/test_plugin_order_list.js runs the shipped widget in a vm with a fake DOM (draw, reorder, include/exclude, the rotation list, junk ids) and is in run_all.js and the README. The durations DOM suite now reads only its own rows' ids from the input, since a rig's saved order can hold others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore reinstalls only the plugins that are missing POST /backup/restore with reinstall_plugins (the "Reinstall missing plugins" box) passed every plugin in the backup's plugins.json to install_plugin(). That replaces an installed copy with a fresh download, so a restore onto the same device re-downloaded every plugin inside the request. A plugin installed from its own URL is not in the registry, so its install returned False, plugins_failed set success to False, and the restore answered 500 "Restore incomplete ... plugins not reinstalled: <id>" (shown as "Restore failed") with the plugin still installed and the config restored. Each plugin is now looked up first with the store's _existing_install, the same lookup install_plugin makes to decide a copy exists: the id, or an id the registry proves is the same plugin (aliases, the plugin_path name), and never a bare ledmatrix-<id> folder (#686). One that is installed is recorded in result.skipped as "plugin:<id> (installed)", which the page lists under Skipped; a missing one is installed as before. The list_installed_plugins docstring said every listed plugin is reinstalled and now says otherwise. Tests: TestInstalledPluginsAreNotReinstalled, with a mocked store (installed skipped, missing installed; an installed plugin the store can't install is not a failure) and with a real PluginStoreManager (a registry alias and a third-party install are skipped, a missing plugin installed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): /config/main answers malformed JSON with a 400 save_main_config read a JSON body with request.get_json(), which raises Werkzeug's BadRequest for a body that does not parse (or an empty one sent as application/json). That happened inside the handler's try, so the catch-all answered 500 CONFIG_SAVE_FAILED with "Check file permissions on config directory" among its suggested fixes and logged a traceback at ERROR, for what was the caller's mistake. It now reads with get_json(silent=True), as save_raw_main_config does, and answers a sent-but-unparseable body with the same 400 {"status": "error", "message": "Invalid JSON in request body"}. An empty JSON body falls through to the existing 400 "No data provided". The change is limited to the lines that read the body. Tests: TestMalformedBody in test_api_v3_partial_main_save.py (the 400 and its shape, identical to /config/raw/main's, and nothing saved; the empty body). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a restore that brings back fonts clears the font catalog cache GET /api/v3/fonts/catalog caches its answer as fonts_catalog for five minutes. Font upload and delete clear that entry (fonts.py), but POST /backup/restore copies user fonts into assets/fonts without touching it, so restored fonts were missing from the Fonts tab and every font picker until the cache expired. backup_restore now clears fonts_catalog when the result lists restored fonts (restore_backup records them as "fonts (<count>)"). A restore that restored no fonts leaves the cache alone. Tests: TestFontsCatalogCache in test_api_v3_backup_restore.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): drop uninstalled plugins from the carried-over order and exclusions2b34f254made the plugin order list keep every saved id that has no row, so a disabled plugin keeps its rotation slot and Vegas exclusion. That also kept the ids of plugins that have since been uninstalled: they stayed in plugin_rotation_order and vegas_excluded_plugins for good, where before the next save of the tab dropped them. The widget already fetches /api/v3/plugins/installed, every installed plugin with its enabled flag, and draws only the enabled ones. It now keeps that response's full id set and carries over only saved ids that are installed but have no row (disabled). An id outside the set is dropped, as before. With no list, nothing is dropped: a failed request draws no rows and leaves the inputs as saved, and the carry-over keeps everything if the set was never filled. Tests: test/js/unit/test_plugin_order_list.js adds a disabled plugin kept while an uninstalled one is dropped (order and exclusions; fails on2b34f254), and a failed plugin list leaving both inputs as saved. The CHANGELOG bullet and the README row say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(js): register the order-list suite apart from other branches' suites Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
11 KiB
Python
239 lines
11 KiB
Python
"""Backup creation, listing and restore.
|
|
|
|
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,
|
|
datetime, json, jsonify, logger, os, request, tempfile,
|
|
)
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
# 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
|
|
# package is the only patch point that covers every caller.
|
|
from web_interface.cache import delete_cached
|
|
|
|
|
|
@api_v3.route('/backup/preview', methods=['GET'])
|
|
def backup_preview():
|
|
"""Return a summary of what a new backup would include."""
|
|
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."""
|
|
_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."""
|
|
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:
|
|
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})
|
|
#: 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
|
|
#: request clearly meant to exclude, with no indication anything was wrong.
|
|
_RESTORE_OPTION_KEYS = frozenset((
|
|
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
|
|
'restore_plugin_uploads', 'reinstall_plugins',
|
|
))
|
|
def _installed_path(psm, plugin_id):
|
|
"""Where the store finds ``plugin_id`` installed, or None.
|
|
|
|
The same lookup install_plugin makes to decide that a copy exists: the
|
|
id, or an id the registry proves is the same plugin (``aliases``, the
|
|
``plugin_path`` name), never a bare ``ledmatrix-<id>`` folder.
|
|
"""
|
|
found = psm._existing_install(plugin_id)
|
|
return found if isinstance(found, Path) and found.exists() else None
|
|
|
|
|
|
@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:
|
|
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:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
# Restored fonts reach the Fonts tab through a catalog cached for five
|
|
# minutes (fonts.py); upload and delete clear it, and so must this.
|
|
if any(str(item).startswith('fonts') for item in result.restored):
|
|
delete_cached('fonts_catalog')
|
|
|
|
# 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:
|
|
# Only what is missing. install_plugin replaces an installed
|
|
# copy with a fresh download, so restoring onto the same
|
|
# device re-downloaded every plugin, and one installed from
|
|
# its own URL (not in the registry) "failed" and failed the
|
|
# whole restore while it sat there installed. The store's
|
|
# own lookup, so registry aliases count as installed too.
|
|
if psm and _installed_path(psm, pid) is not None:
|
|
result.skipped.append(f'plugin:{pid} (installed)')
|
|
continue
|
|
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'})
|
|
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
|
|
|
|
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."""
|
|
from flask import send_from_directory
|
|
if _safe_backup_path(filename) is None:
|
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
try:
|
|
# send_from_directory uses werkzeug safe_join internally — CodeQL-recognized sanitizer.
|
|
return send_from_directory(_pkg._BACKUP_EXPORT_DIR, filename, as_attachment=True)
|
|
except FileNotFoundError:
|
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
@api_v3.route('/backup/<path:filename>', methods=['DELETE'])
|
|
def backup_delete(filename):
|
|
"""Delete a stored backup ZIP."""
|
|
safe = _safe_backup_path(filename)
|
|
if safe is None:
|
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|
|
# Enumerate the export directory and match by name so the unlink target is
|
|
# a filesystem-derived path rather than one constructed from user input.
|
|
try:
|
|
for entry in _pkg._BACKUP_EXPORT_DIR.iterdir():
|
|
if entry.is_file() and entry.name == safe.name:
|
|
entry.unlink()
|
|
return jsonify({'status': 'success'})
|
|
except OSError as e:
|
|
logger.error("backup_delete failed: %s", e, exc_info=True)
|
|
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
|
return jsonify({'status': 'error', 'message': 'Backup not found'}), 404
|